Compare commits

..

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

133 changed files with 1716 additions and 5830 deletions

View File

@ -2377,10 +2377,8 @@ export function LocalAiImagePipelineSections({
items={[ items={[
{ {
label: label:
extendConfig.provider === "fal" extendConfig.provider === "vip98k"
? "fal · FLUX 2 Pro 扩图" ? "VIP98K 服务商"
: extendConfig.provider === "vip98k"
? `98K · ${extendConfig.modelId || "image-expansion"}`
: "阿里云服务商", : "阿里云服务商",
tone: "enabled", tone: "enabled",
}, },
@ -2393,13 +2391,9 @@ 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"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
</div>
) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。 VIP98K 服务商读取,仅在这里维护扩展比例。
</div> </div>
) : ( ) : (

View File

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

View File

@ -2576,9 +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",
modelId: "",
routeOptionId: "",
xScale: "1.5", // X轴放大倍数 xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数 yScale: "1.5", // Y轴放大倍数
}); });
@ -2893,16 +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";
modelId?: string;
routeOptionId?: string;
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",
modelId: config.modelId || "",
routeOptionId: config.routeOptionId || "",
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",
}); });
@ -5511,8 +5505,6 @@ export function AdminSettingsClient() {
key: "extend_api_config", key: "extend_api_config",
value: { value: {
provider: extendConfig.provider, provider: extendConfig.provider,
modelId: extendConfig.modelId,
routeOptionId: extendConfig.routeOptionId,
xScale, xScale,
yScale, yScale,
}, },

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

@ -976,8 +976,7 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`; const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile( const annotatedUpload = await uploadFile(
annotatedFilename, annotatedFilename,
toArrayBuffer(annotatedBuffer), toArrayBuffer(annotatedBuffer)
{ preferQiniuForm: true }
); );
const prompt = buildLocalEditPrompt(regions); const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) => const manualRegionReferenceImageUrls = regions.flatMap((region) =>

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

@ -133,13 +133,15 @@ function buildImageResponse(
etag: string; etag: string;
vary?: string; vary?: string;
cacheStatus?: "HIT" | "MISS"; cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
} }
) { ) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": params.contentType, "Content-Type": params.contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
ETag: params.etag, ETag: params.etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", "Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
}; };
if (params.vary) { if (params.vary) {
headers.Vary = params.vary; headers.Vary = params.vary;
@ -147,6 +149,11 @@ function buildImageResponse(
if (params.cacheStatus) { if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus; headers["X-Image-Proxy-Cache"] = params.cacheStatus;
} }
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, { return new NextResponse(body, {
status: 200, status: 200,
headers, headers,
@ -331,18 +338,7 @@ export async function GET(request: NextRequest) {
); );
} }
if (!original && !forceProxy && isBrowserImageRequest(request)) { if (!original && isAlibabaModelImageUrl(target.toString())) {
const direct = buildImageProxyUrl(target.toString(), {
width: width || undefined, quality: directQuality || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN && /^https:\/\//.test(direct) &&
new URL(direct).origin === new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN).origin) {
return redirectToDirectImage(new URL(direct));
}
}
if (!original && (isAlibabaModelImageUrl(target.toString()) || (!forceProxy && isBrowserImageRequest(request)))) {
try { try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db"); const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]); const mappings = await getArchivedImageUrlMappings([target.toString()]);
@ -464,29 +460,20 @@ export async function GET(request: NextRequest) {
if (original) return downloadFailure("Source is not an image"); if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }
if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
await upstreamResponse.body?.cancel();
return downloadFailure("源图片传输中断,请重试");
}
const headers = new Headers({
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
});
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try { try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) { } catch (error) {
return upstreamFailure(error); return upstreamFailure(error);
} }
} }
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg"); const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif"); const isGif = contentType.toLowerCase().includes("image/gif");

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

@ -6,7 +6,6 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { import {
createMediaAssetRelation, createMediaAssetRelation,
getDefaultStorageConfig,
getPointsSystemConfig, getPointsSystemConfig,
getMediaAssetsByUserAndUrls, getMediaAssetsByUserAndUrls,
upsertMediaAsset, upsertMediaAsset,
@ -22,27 +21,6 @@ export const maxDuration = 600;
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
const WATERMARK_POLL_INTERVAL_MS = 1200; const WATERMARK_POLL_INTERVAL_MS = 1200;
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
const config = await getDefaultStorageConfig().catch(() => null);
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
try {
const base = new URL(config.publicUrl);
const url = new URL(source);
const prefix = base.pathname.replace(/\/$/, "") + "/";
if (base.protocol !== "https:" || url.origin !== base.origin ||
url.username || url.password || url.search || url.hash ||
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}
}
// 获取去水印API配置 // 获取去水印API配置
async function getWatermarkRemoveConfig() { async function getWatermarkRemoveConfig() {
try { try {
@ -153,16 +131,13 @@ async function persistWatermarkResult(params: {
processing_time?: number; processing_time?: number;
}> { }> {
try { try {
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
if (!resultUrl) {
const imageResponse = await fetch(params.processedImageUrl); const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败"); throw new Error("下载处理后的图片失败");
} }
const imageBuffer = await imageResponse.arrayBuffer(); const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`; const filename = `watermark-removed-${Date.now()}.png`;
resultUrl = (await uploadFile(filename, imageBuffer)).url; const uploadResult = await uploadFile(filename, imageBuffer);
}
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
@ -175,8 +150,8 @@ async function persistWatermarkResult(params: {
try { try {
([savedImage] = await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId: params.userId, userId: params.userId,
thumbnailUrl: resultUrl, thumbnailUrl: uploadResult.url,
originalUrl: resultUrl, originalUrl: uploadResult.url,
imageSize: "2K", imageSize: "2K",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
@ -209,9 +184,9 @@ async function persistWatermarkResult(params: {
mediaType: "image", mediaType: "image",
sourceType: "edited", sourceType: "edited",
status: "ready", status: "ready",
originalUrl: resultUrl, originalUrl: uploadResult.url,
previewUrl: resultUrl, previewUrl: uploadResult.url,
thumbnailUrl: resultUrl, thumbnailUrl: uploadResult.url,
displayName: "去水印处理", displayName: "去水印处理",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
@ -242,8 +217,8 @@ async function persistWatermarkResult(params: {
return { return {
success: true, success: true,
original_url: resultUrl, original_url: uploadResult.url,
thumbnail_url: resultUrl, thumbnail_url: uploadResult.url,
processing_time: processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined, typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
}; };
@ -387,7 +362,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { image_url } = body; const { image_url } = body;
if (typeof image_url !== "string" || !image_url) { if (!image_url) {
return NextResponse.json( return NextResponse.json(
{ error: "请提供图片URL" }, { error: "请提供图片URL" },
{ status: 400 } { status: 400 }
@ -438,11 +413,8 @@ export async function POST(request: NextRequest) {
); );
} }
} }
// 七牛上的公开图片可以直接交给去水印服务读取。 // 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) { else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
publicImageUrl = storedImageUrl || image_url;
if (!storedImageUrl) {
try { try {
console.log("[WatermarkRemove] 下载原图并上传到S3..."); console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url); const imageResponse = await fetch(image_url);
@ -451,11 +423,14 @@ export async function POST(request: NextRequest) {
} }
const imageBuffer = await imageResponse.arrayBuffer(); const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`; const filename = `watermark-input-${Date.now()}.jpg`;
publicImageUrl = (await uploadFile(filename, imageBuffer)).url; const uploadResult = await uploadFile(filename, imageBuffer);
publicImageUrl = uploadResult.url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl); console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) { } catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError); console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError);
} // 如果下载失败,尝试使用原始URL
console.log("[WatermarkRemove] 尝试使用原始URL");
publicImageUrl = image_url;
} }
} else { } else {
return NextResponse.json( return NextResponse.json(

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,8 +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 { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -143,7 +141,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return; if (lastReloadAt) return;
var now = Date.now(); var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run. // If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return; if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
@ -154,10 +152,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
return;
}
window.location.reload(); window.location.reload();
}, 800); }, 800);
} }
@ -221,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

@ -3,7 +3,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react"; import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config"; import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
@ -230,11 +229,12 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "bg-white/[0.04]" : "bg-muted/40" isDark ? "bg-white/[0.04]" : "bg-muted/40"
)} )}
> >
<AgentPreviewImage <img
src={image.imageUrl} src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`} alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover" className="h-full w-full object-cover"
previewWidth={360} loading="lazy"
decoding="async"
/> />
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2"> <div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white"> <span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">

View File

@ -1,77 +0,0 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
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) && !downloading}>
<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,14 +67,13 @@ 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,
type UploadProgress, type UploadProgress,
} from "@/lib/client/upload-with-retry"; } from "@/lib/client/upload-with-retry";
import { import {
downloadOriginalImage, downloadCanvasImageAsPng,
getCanvasImageDownloadErrorMessage, getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress, type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download"; } from "@/lib/client/canvas-image-download";
@ -1462,7 +1457,7 @@ function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
} }
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸) // 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(source: string | File): Promise<{ function getImageDimensions(url: string): Promise<{
width: number; width: number;
height: number; height: number;
originalWidth: number; originalWidth: number;
@ -1470,7 +1465,6 @@ function getImageDimensions(source: string | File): Promise<{
}> { }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const img = new Image(); const img = new Image();
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
let settled = false; let settled = false;
const resolveOnce = (dimensions: { const resolveOnce = (dimensions: {
width: number; width: number;
@ -1483,11 +1477,9 @@ function getImageDimensions(source: string | File): Promise<{
window.clearTimeout(timeout); window.clearTimeout(timeout);
img.onload = null; img.onload = null;
img.onerror = null; img.onerror = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(dimensions); resolve(dimensions);
}; };
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
img.removeAttribute("src");
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS); }, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
@ -1520,7 +1512,7 @@ function getImageDimensions(source: string | File): Promise<{
// 加载失败时使用默认尺寸 // 加载失败时使用默认尺寸
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}; };
img.src = objectUrl ?? String(source); img.src = url;
}); });
} }
@ -3250,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(
@ -3350,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);
@ -3372,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);
@ -3409,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);
@ -3652,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;
@ -4467,15 +4454,17 @@ export function CanvasChatPanel({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
await downloadOriginalImage({ await downloadCanvasImageAsPng({
url: originalUrl, url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("已开始下载"); toast.success("原图已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -5036,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) {
@ -6109,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";
@ -7807,6 +7787,7 @@ export function CanvasChatPanel({
existingMessageImageKeys.add(messageImageKey); existingMessageImageKeys.add(messageImageKey);
} }
const dimensions = await getImageDimensions(url);
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return; if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
const compareSourceUrlRaw = const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] || sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
@ -7839,7 +7820,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null; : null;
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 }; const size = { width: dimensions.width, height: dimensions.height };
const position = placeholderNode const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size) ? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({ : getAnchoredGenerationPosition({
@ -7857,6 +7838,8 @@ export function CanvasChatPanel({
prompt: generationPrompt, prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined, originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: { metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt, generationPrompt,
sourceMessageId: messageId || undefined, sourceMessageId: messageId || undefined,
sourceImageKey, sourceImageKey,
@ -7989,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);
@ -8022,7 +8001,7 @@ export function CanvasChatPanel({
return false; return false;
} }
}, },
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] [applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
); );
const { const {
@ -8039,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-', '')
@ -8153,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,
@ -8441,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
: []; : [];
@ -8514,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) {
@ -8840,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 () => {
@ -8952,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);
@ -8967,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) => {
@ -9137,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([
@ -9157,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>("");
@ -9177,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([]);
@ -9191,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);
@ -9245,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 () => {
@ -9276,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");
@ -9299,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)
); );
}); });
@ -9320,7 +9264,6 @@ export function CanvasChatPanel({
messages, messages,
streamImageGenerationStateByChatId, streamImageGenerationStateByChatId,
clearStreamImageGenerationStateForChat, clearStreamImageGenerationStateForChat,
setStreamImageGenerationStateForChat,
]); ]);
useEffect(() => { useEffect(() => {
@ -9581,6 +9524,7 @@ export function CanvasChatPanel({
continue; continue;
} }
const dimensions = await getImageDimensions(url);
if (cancelled) return; if (cancelled) return;
const compareSourceUrlRaw = const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] || nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
@ -9613,7 +9557,20 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null; : null;
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 }; const size = placeholderNode
? { width: dimensions.width, height: dimensions.height }
: anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const position = placeholderNode const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size) ? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({ : getAnchoredGenerationPosition({
@ -9632,6 +9589,8 @@ export function CanvasChatPanel({
prompt: generationPrompt || "对话生成", prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined, originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: { metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined, generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id, sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey, sourceImageKey: imageEntry.sourceImageKey,
@ -9811,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);
@ -10358,7 +10315,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId }) buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
); );
const dimensions = await getImageDimensions(file); const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex; const nodeIndex = nodes.length + slotIndex;
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -10436,7 +10393,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId }) buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
); );
const dimensions = await getImageDimensions(file); const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex; const nodeIndex = nodes.length + slotIndex;
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -12586,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 = "";
@ -12884,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([
@ -13373,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) {
@ -13417,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([
@ -13456,9 +13390,6 @@ export function CanvasChatPanel({
} }
}, [ }, [
isCanvasAgentRunning, isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest,
isVideoWorkspace,
canvasAgentTask.control, canvasAgentTask.control,
activeChatId, activeChatId,
activeComposerMode, activeComposerMode,
@ -13596,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) => {
@ -13722,7 +13657,7 @@ export function CanvasChatPanel({
}; };
onUploaded?.(uploadedBase); onUploaded?.(uploadedBase);
clearComposerUploadProgress(250); clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(file); const dimensions = await getImageDimensions(imageUrl);
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing); const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
@ -14911,7 +14846,7 @@ export function CanvasChatPanel({
firstImageUrl = imageUrl; firstImageUrl = imageUrl;
} }
const dimensions = await getImageDimensions(file); const dimensions = await getImageDimensions(imageUrl);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions); updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布 // 获取图片原始尺寸后添加到画布
@ -15162,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 ||
@ -15225,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;
@ -15283,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",
@ -15380,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;
@ -15690,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("聊天记录暂时读取失败,请重试");
@ -15704,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) => {
@ -16121,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"
@ -16140,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"
)}> )}>
@ -17343,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(
@ -18605,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}
@ -18639,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

@ -28,7 +28,6 @@ export function CanvasImageDownloadProgressDialog({
: null; : null;
const loadedLabel = formatBytes(progress.loadedBytes); const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes); const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal( return createPortal(
<div <div
@ -42,12 +41,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? ( {progress.phase === "saving" ? (
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
) : ( ) : (
<Loader2 className="h-4 w-4 motion-safe:animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
)} )}
</span> </span>
<div> <div>
<div className="text-[14px] font-semibold">下载原图</div> <div className="text-[14px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div> <div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
</div> </div>
</div> </div>
{onCancel ? ( {onCancel ? (
@ -64,19 +63,14 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4"> <div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55"> <div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span> <span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span> <span>{percent !== null ? `${percent}%` : "..."}</span>
</div> </div>
<div <div className="relative h-2 overflow-hidden rounded-full bg-white/10">
className="relative h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-label={converting ? "图片格式转换" : "图片下载"}
aria-valuenow={percent ?? undefined}
>
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300", "absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]" percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)} )}
style={percent !== null ? { width: `${percent}%` } : undefined} style={percent !== null ? { width: `${percent}%` } : undefined}
/> />

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

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs"; import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { GroupStoryboardPreview } from "./GroupStoryboardPreview"; import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
@ -15105,7 +15104,7 @@ function DirectorNodeComposer({
if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;} if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;}
setBusy(true);setError("");try{ setBusy(true);setError("");try{
const form=new FormData();form.append("file",file);form.append("purpose","director-studio"); const form=new FormData();form.append("file",file);form.append("purpose","director-studio");
const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)}); const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)});
const result=await response.json();const url=result.url||result.data?.url; const result=await response.json();const url=result.url||result.data?.url;
if(!response.ok||!url)throw new Error(result.error||"图片上传失败"); if(!response.ok||!url)throw new Error(result.error||"图片上传失败");
onDraft({directorReferenceUrl:String(url)}); onDraft({directorReferenceUrl:String(url)});

View File

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

View File

@ -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,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene"; import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
@ -65,7 +64,9 @@ export function DirectorReconstructionPanel({
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
form.append("purpose", "director-studio"); form.append("purpose", "director-studio");
const response = await fetchUploadWithRetry(form, { const response = await fetch("/api/files/upload", {
method: "POST",
body: form,
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
}); });
const data = await response.json(); const data = await response.json();

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
commitDirectorScene, commitDirectorScene,
resolveDirectorCamera, resolveDirectorCamera,
@ -44,13 +43,14 @@ async function upload(file: File) {
"purpose", "purpose",
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio", file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
); );
const response = /\.glb$/i.test(file.name) const response = await fetch(
? await fetch("/api/canvas/director/upload-model", { /\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
{
method: "POST", method: "POST",
body: form, body: form,
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
}) },
: await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) }); );
const result = await response.json(); const result = await response.json();
const url = result.url || result.data?.url; const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败"); if (!response.ok || !url) throw new Error(result.error || "上传失败");

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 {
@ -178,7 +176,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
downloadOriginalImage, downloadCanvasImageAsPng,
getCanvasImageDownloadErrorMessage, getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress, type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download"; } from "@/lib/client/canvas-image-download";
@ -2482,11 +2480,14 @@ export function ImageNode({
[compareButtonVisualSize] [compareButtonVisualSize]
); );
const shouldUseHighFidelityDisplayImage = const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode || isCompareMode ||
boxCutoutMode || boxCutoutMode ||
localEditMode || localEditMode ||
cropPanelOpen || cropPanelOpen ||
imageAdjustPanelOpen || imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking; isThisNodePicking;
const displayImageWidth = useMemo(() => { const displayImageWidth = useMemo(() => {
const devicePixelRatio = const devicePixelRatio =
@ -2501,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(
@ -2519,6 +2520,15 @@ export function ImageNode({
}), }),
[data.src, displayImageQuality, displayImageWidth] [data.src, displayImageQuality, displayImageWidth]
); );
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo( const displayCompareSrc = useMemo(
() => () =>
buildImageProxyUrl(compareSource, { buildImageProxyUrl(compareSource, {
@ -4673,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) => {
@ -8223,6 +8295,7 @@ export function ImageNode({
} }
}; };
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress); const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => { const handleDownload = async () => {
if (!data.src) return; if (!data.src) return;
@ -8236,16 +8309,24 @@ export function ImageNode({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
await downloadOriginalImage({ await downloadCanvasImageAsPng({
url: resolveOriginalImageUrl(data.src) || data.src, url: data.src,
name: displayTitle, name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("已开始下载"); toast.success("原图已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -8380,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);
@ -8410,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);
@ -8461,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",
@ -8474,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
@ -9404,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,26 +5,21 @@ 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, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
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";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { randomUUID } from "node:crypto";
type ExtendConfig = { type ExtendConfig = {
provider?: ExtendProvider; provider?: "vip98k" | "dashscope";
apiKey?: string; apiKey?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
modelId?: string;
routeOptionId?: string;
}; };
const SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const DASHSCOPE_MIN_IMAGE_EDGE = 512; const DASHSCOPE_MIN_IMAGE_EDGE = 512;
const DASHSCOPE_MAX_IMAGE_EDGE = 4096; const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@ -88,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,
@ -147,7 +142,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS), signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
}); });
if (!response.ok) { if (!response.ok) {
throw new ExtendServiceError( throw new ExtendServiceError(
@ -242,6 +237,97 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
return uploaded.url; return uploaded.url;
} }
function isTemporaryVip98kExpansionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
message,
);
}
async function createVip98kExpansionCanvas(
imageUrl: string,
xScale: number,
yScale: number,
) {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
"下载待拓展图片失败",
502,
`${response.status} ${response.statusText}`,
);
}
const sourceBuffer = Buffer.from(await response.arrayBuffer());
const sharp = await import("sharp");
const image = sharp.default(sourceBuffer, { failOn: "none" });
const metadata = await image.metadata();
const width = Number(metadata.width || 0);
const height = Number(metadata.height || 0);
if (!width || !height) {
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
}
const targetWidth = Math.max(width, Math.round(width * xScale));
const targetHeight = Math.max(height, Math.round(height * yScale));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const canvas = await image
.ensureAlpha()
.extend({
left,
right: targetWidth - width - left,
top,
bottom: targetHeight - height - top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
const arrayBuffer = canvas.buffer.slice(
canvas.byteOffset,
canvas.byteOffset + canvas.byteLength,
) as ArrayBuffer;
const uploaded = await uploadFile(
`extend-vip98k-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
arrayBuffer,
);
return {
imageUrl: uploaded.url,
width,
height,
targetWidth,
targetHeight,
};
}
async function extendImageWithVip98kEditFallback(
imageUrl: string,
xScale: number,
yScale: number,
) {
const canvas = await createVip98kExpansionCanvas(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: VIP98K_EXPANSION_FALLBACK_MODEL,
kind: "image",
prompt: [
"Outpaint only the transparent regions of this image.",
"Keep every original pixel, subject, scale, position, perspective, lighting and style unchanged.",
`Complete the ${canvas.targetWidth}x${canvas.targetHeight} canvas naturally from the centered ${canvas.width}x${canvas.height} source.`,
"Do not crop, zoom, move, replace or redraw the original content.",
].join(" "),
imageUrls: [canvas.imageUrl],
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 生成式扩图未返回图片", 502);
}
return outputUrl;
}
async function extendImage( async function extendImage(
imageUrl: string, imageUrl: string,
config: ExtendConfig, config: ExtendConfig,
@ -250,9 +336,11 @@ async function extendImage(
requestId?: string | null, requestId?: string | null,
): Promise<string> { ): Promise<string> {
if (config.provider === "vip98k") { if (config.provider === "vip98k") {
try {
const result = await executeConfiguredVip98kTask({ const result = await executeConfiguredVip98kTask({
modelId: "image-expansion", modelId: "image-expansion",
kind: "image-tool", kind: "image-tool",
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
imageUrls: [imageUrl], imageUrls: [imageUrl],
imageParams: { xScale, yScale }, imageParams: { xScale, yScale },
}); });
@ -261,6 +349,14 @@ async function extendImage(
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502); throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
} }
return outputUrl; return outputUrl;
} catch (error) {
if (!isTemporaryVip98kExpansionError(error)) throw error;
console.warn(
"[Extend Service] VIP98K expansion tool is temporarily unavailable; using VIP98K generative outpainting fallback",
error instanceof Error ? error.message : String(error),
);
return extendImageWithVip98kEditFallback(imageUrl, xScale, yScale);
}
} }
if (!config.apiKey) { if (!config.apiKey) {
@ -377,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",
@ -435,48 +522,36 @@ 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 uploadResult: { thumbnail: string; original: string } | undefined; preparedInputImageUrl,
let archivedBytes: number | undefined; config,
let downloadAsset: Awaited<ReturnType<typeof archiveTemporaryImage>>["downloadAsset"]; xScale,
let modelId: string; yScale,
if (config.provider === "fal") { requestId,
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale }); );
modelId = FAL_OUTPAINT_MODEL; const imageResponse = await fetch(extendedImageUrl);
} else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) {
if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503);
const provider = await getVip98kRuntimeProvider();
buffer = await expandWithVip98kOutpaint({
imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey,
routeOptionId: config.routeOptionId, xScale, yScale,
});
modelId = VIP98K_OUTPAINT_MODEL;
} else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);
if (config.provider === "vip98k" && !includeBase64) {
const archived = await archiveTemporaryImage(url, requestId || randomUUID());
uploadResult = { original: archived.url, thumbnail: archived.thumbnailUrl };
archivedBytes = archived.bytes;
downloadAsset = archived.downloadAsset;
} else {
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,
} `${imageResponse.status} ${imageResponse.statusText}`,
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0"; );
} }
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined; const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "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: archivedBytes ?? buffer.length, outputBytes: buffer.length,
}); });
let mediaAssetId: string | null = null; let mediaAssetId: string | null = null;
let generatedImageId: string | null = null; let generatedImageId: string | null = null;
@ -492,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();
@ -516,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,
@ -525,7 +600,6 @@ export async function executeExtendJob(
xScale, xScale,
yScale, yScale,
imageSize: "2K", imageSize: "2K",
...(downloadAsset ? { downloadAsset } : {}),
}, },
}); });
mediaAssetId = media?.id || null; mediaAssetId = media?.id || null;

View File

@ -1,85 +0,0 @@
import sharp from "sharp";
export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare";
const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]];
export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数");
const ratio = targetWidth / targetHeight;
const [a, b] = RATIOS.reduce((best, next) =>
Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best);
const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b));
const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a));
const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight));
const modelWidth = Math.max(1, Math.round(paddedWidth * factor));
const modelHeight = Math.max(1, Math.round(paddedHeight * factor));
const sourceWidth = Math.max(1, Math.round(width * factor));
const sourceHeight = Math.max(1, Math.round(height * factor));
return {
targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight,
left: Math.floor((modelWidth - sourceWidth) / 2),
top: Math.floor((modelHeight - sourceHeight) / 2),
aspectRatio: `${a}:${b}`,
resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K",
};
}
export async function expandWithVip98kOutpaint(params: {
imageUrl: string;
baseUrl: string;
apiKey: string;
routeOptionId: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale);
const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({
left: g.left, right: g.modelWidth - g.sourceWidth - g.left,
top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
}).png().toBuffer();
const body = new FormData();
const prompt = [
"Extend this product image outward on all four sides, continuing its background naturally.",
`The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`,
"Fill only the surrounding empty area as a continuation of the same scene.",
"Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.",
"Keep approximately the same composition and the product centered.",
"Avoid adding new products or additional text. Return a complete seamless image.",
].join(" ");
for (const [key, value] of Object.entries({
model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio,
resolution: g.resolution, quality: "high", response_format: "url",
})) body.append(key, value);
body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png");
const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, "");
const generated = await fetch(`${base}/v1/images/edits`, {
method: "POST", body,
headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId },
signal: AbortSignal.timeout(180_000),
});
const result = await generated.json();
if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`);
const image = result?.data?.[0];
let expanded: Buffer;
if (typeof image?.b64_json === "string") {
expanded = Buffer.from(image.b64_json, "base64");
} else if (typeof image?.url === "string") {
const outputUrl = new URL(image.url);
const headers = new Headers();
if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`);
const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) });
if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`);
expanded = Buffer.from(await output.arrayBuffer());
} else {
throw new Error("98K 扩图未返回图片");
}
return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer();
}

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

@ -99,7 +99,6 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600; const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3; const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000; const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REFERENCE_STAGING_CACHE_MS = 60_000; const REFERENCE_STAGING_CACHE_MS = 60_000;
@ -683,26 +682,25 @@ async function findStagedReference(
} }
} }
async function fetchReferenceImage(mediaUrl: string, preferCdn = false) { async function downloadReferenceImage(mediaUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(mediaUrl, { const response = await fetch(mediaUrl, {
cache: preferCdn ? "default" : "no-store", cache: "no-store",
redirect: preferCdn ? "error" : "follow", redirect: "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" }, headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(preferCdn ? REFERENCE_CDN_TIMEOUT_MS : REFERENCE_DOWNLOAD_TIMEOUT_MS), signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
}); });
if (!response.ok || (preferCdn && response.status !== 200)) { if (!response.ok) {
await response.body?.cancel();
throw new Error(`source returned HTTP ${response.status}`); throw new Error(`source returned HTTP ${response.status}`);
} }
const mimeType = response.headers.get("content-type") || "image/png";
if (preferCdn && !/^image\//i.test(mimeType) && !/^application\/octet-stream\b/i.test(mimeType)) {
await response.body?.cancel();
throw new Error("CDN response was not an image");
}
const declaredLength = Number(response.headers.get("content-length")); const declaredLength = Number(response.headers.get("content-length"));
if (declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES) { if (
await response.body?.cancel(); Number.isFinite(declaredLength) &&
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
) {
throw new Vip98kReferenceTooLargeError(); throw new Vip98kReferenceTooLargeError();
} }
@ -710,17 +708,17 @@ async function fetchReferenceImage(mediaUrl: string, preferCdn = false) {
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) { if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError(); throw new Vip98kReferenceTooLargeError();
} }
if ((declaredLength > 0 && buffer.byteLength !== declaredLength) || (preferCdn && !buffer.byteLength)) { if (
Number.isFinite(declaredLength) &&
declaredLength > 0 &&
buffer.byteLength !== declaredLength
) {
throw new Error("source image download was incomplete"); throw new Error("source image download was incomplete");
} }
return { buffer, mimeType }; return {
} buffer,
mimeType: response.headers.get("content-type") || "image/png",
async function downloadReferenceImage(mediaUrl: string) { };
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
return await fetchReferenceImage(mediaUrl);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (error instanceof Vip98kReferenceTooLargeError) throw error; if (error instanceof Vip98kReferenceTooLargeError) throw error;
@ -740,22 +738,6 @@ async function downloadReferenceImage(mediaUrl: string) {
); );
} }
async function downloadReferenceImageFromCdn(mediaUrl: string, stagingConfig: ReferenceStagingConfig) {
if (stagingConfig.storageConfig.provider !== "qiniu-kodo") return null;
const cdnUrl = process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "";
if (!URL.canParse(cdnUrl) || !getStorageObjectKeyFromPublicUrl(mediaUrl, stagingConfig.sourcePublicUrl)) return null;
const source = new URL(mediaUrl);
if (source.origin !== new URL(cdnUrl).origin || source.search || source.username || source.password) return null;
try {
return await fetchReferenceImage(mediaUrl, true);
} catch (error) {
if (error instanceof Vip98kReferenceTooLargeError) throw error;
logger.warn("reference CDN download failed; trying source storage", { sourceHost: source.hostname });
return null;
}
}
async function downloadReferenceImageFromStorage( async function downloadReferenceImageFromStorage(
mediaUrl: string, mediaUrl: string,
stagingConfig: ReferenceStagingConfig, stagingConfig: ReferenceStagingConfig,
@ -870,10 +852,9 @@ async function stageReferenceImage(
} }
const downloadStartedAt = Date.now(); const downloadStartedAt = Date.now();
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
const downloaded = const downloaded =
cdn || storage || (await downloadReferenceImage(mediaUrl)); (await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
(await downloadReferenceImage(mediaUrl));
const downloadMs = Date.now() - downloadStartedAt; const downloadMs = Date.now() - downloadStartedAt;
const normalizeStartedAt = Date.now(); const normalizeStartedAt = Date.now();
const normalized = await normalizeReferenceImage( const normalized = await normalizeReferenceImage(
@ -891,7 +872,6 @@ async function stageReferenceImage(
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig); cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
logger.info("reference image staged", { logger.info("reference image staged", {
sourceHost: getUrlHost(mediaUrl), sourceHost: getUrlHost(mediaUrl),
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
targetHost: getUrlHost(uploadedUrl), targetHost: getUrlHost(uploadedUrl),
sourceBytes: downloaded.buffer.byteLength, sourceBytes: downloaded.buffer.byteLength,
stagedBytes: normalized.buffer.byteLength, stagedBytes: normalized.buffer.byteLength,
@ -1666,16 +1646,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);
body.output_format = "png";
}
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) {

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,43 +164,53 @@ 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(
@ -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;
@ -3444,13 +3443,11 @@ async function applyLocalEditCompositeToImages(params: {
const [originalUpload, thumbnailUpload] = await Promise.all([ const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile( uploadFile(
`canvas-local-edit-composited-${suffix}.png`, `canvas-local-edit-composited-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer), bufferToArrayBuffer(mergedBuffer)
{ preferQiniuForm: true }
), ),
uploadFile( uploadFile(
`canvas-local-edit-composited-thumb-${suffix}.png`, `canvas-local-edit-composited-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer), bufferToArrayBuffer(thumbnailBuffer)
{ preferQiniuForm: true }
), ),
]); ]);
mergedImages.push({ mergedImages.push({
@ -5124,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,
@ -5239,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,
@ -5669,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,13 +217,9 @@ 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;
const output = image.rotate().toColorspace("srgb"); return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
if (metadata.icc) output.withIccProfile("srgb");
return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
} }
function buildCanvasAssetFilename(params: { function buildCanvasAssetFilename(params: {

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,9 +1,8 @@
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error"; import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { withActivePageDownload } from "@/lib/client/page-refresh";
export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving"; export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
export type CanvasImageDownloadProgress = { export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase; phase: CanvasImageDownloadPhase;
@ -190,15 +189,8 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
return { filename: result.filename, bytes: result.bytes || 0 }; return { filename: result.filename, bytes: result.bytes || 0 };
} }
const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]); /** Keep the source format and bytes for chat, gallery and reference downloads. */
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
/** Preserve compatible originals and prepare a PNG for other stored images. */
export function downloadOriginalImage(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
return withActivePageDownload(() => downloadOriginalImageFile(params));
}
async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const { url, name, signal, onProgress } = params;
const source = String(url || "").trim(); const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效"); if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" }); const proxy = buildImageProxyUrl(source, { delivery: "original" });
@ -226,11 +218,6 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
const metadata = await checkedFetch(target.pathname + target.search); const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json(); const delivery = await metadata.json();
if (delivery.delivery === "cdn") { if (delivery.delivery === "cdn") {
// The resolver names the attachment using the stored MIME type.
const extension = delivery.filename.split(".").pop();
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
return downloadPngFile(params, "converting");
}
const result = triggerCdnDownload(delivery, signal); const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result; return result;
@ -238,16 +225,9 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
if (delivery.delivery !== "proxy") throw new Error("下载链接无效"); if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
} }
const response = await checkedFetch(proxy); const response = await checkedFetch(proxy);
const contentType = response.headers.get("content-type") || "";
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(contentType);
// A local blob can still be downloaded while its upload is in progress.
if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
void response.body?.cancel().catch(() => {});
return downloadPngFile(params, "converting");
}
const blob = await readResponseBlobWithProgress(response, onProgress); const blob = await readResponseBlobWithProgress(response, onProgress);
signal?.throwIfAborted(); if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase; const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename); triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size }; return { filename, bytes: blob.size };
@ -272,15 +252,14 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
async function readResponseBlobWithProgress( async function readResponseBlobWithProgress(
response: Response, response: Response,
onProgress?: (progress: CanvasImageDownloadProgress) => void, onProgress?: (progress: CanvasImageDownloadProgress) => void
message = "正在下载原图..."
) { ) {
const totalBytes = Number(response.headers.get("content-length") || 0); const totalBytes = Number(response.headers.get("content-length") || 0);
if (!response.body) { if (!response.body) {
const blob = await response.blob(); const blob = await response.blob();
onProgress?.({ onProgress?.({
phase: "downloading", phase: "downloading",
message, message: "正在下载原图...",
loadedBytes: blob.size, loadedBytes: blob.size,
totalBytes: totalBytes || blob.size, totalBytes: totalBytes || blob.size,
percent: 100, percent: 100,
@ -300,7 +279,7 @@ async function readResponseBlobWithProgress(
loadedBytes += value.byteLength; loadedBytes += value.byteLength;
onProgress?.({ onProgress?.({
phase: "downloading", phase: "downloading",
message, message: "正在下载原图...",
loadedBytes, loadedBytes,
totalBytes: totalBytes || undefined, totalBytes: totalBytes || undefined,
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined, percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
@ -312,17 +291,13 @@ async function readResponseBlobWithProgress(
}); });
} }
export function downloadCanvasImageAsPng(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> { export async function downloadCanvasImageAsPng({
return withActivePageDownload(() => downloadPngFile(params, "preparing"));
}
async function downloadPngFile({
candidates, candidates,
name, name,
onProgress, onProgress,
signal, signal,
url, url,
}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise<CanvasImageDownloadResult> { }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const raw = String(url || "").trim(); const raw = String(url || "").trim();
if (!raw) { if (!raw) {
throw new Error("图片地址无效"); throw new Error("图片地址无效");
@ -335,9 +310,11 @@ async function downloadPngFile({
"canvas-image" "canvas-image"
); );
const isConverting = preparationPhase === "converting"; onProgress?.({
const conversionMessage = "正在转换为兼容 PNG,完成后自动下载"; phase: "preparing",
onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." }); message: "正在准备原图...",
percent: 8,
});
let response: Response | null = null; let response: Response | null = null;
let errorPayload: DownloadErrorPayload | null = null; let errorPayload: DownloadErrorPayload | null = null;
@ -376,10 +353,7 @@ async function downloadPngFile({
if (Date.now() >= preparationDeadline || preparationPolls >= 60) { if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId }); throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
} }
onProgress?.({ onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
phase: preparationPhase,
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
});
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal); await waitForRetry(delaySeconds * 1000, signal);
continue; continue;
@ -410,6 +384,7 @@ async function downloadPngFile({
retryAfterSeconds > 0 retryAfterSeconds > 0
? `请求较多,${retryAfterSeconds} 秒后自动重试...` ? `请求较多,${retryAfterSeconds} 秒后自动重试...`
: "请求较多,正在自动重试...", : "请求较多,正在自动重试...",
percent: 8,
}); });
await waitForRetry(retryAfterSeconds * 1000, signal); await waitForRetry(retryAfterSeconds * 1000, signal);
} }
@ -438,9 +413,7 @@ async function downloadPngFile({
const filename = const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) || parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`; `${filenameBase}.png`;
const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图..."; const blob = await readResponseBlobWithProgress(response, onProgress);
onProgress?.({ phase: "downloading", message: downloadMessage });
const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
onProgress?.({ onProgress?.({
phase: "saving", phase: "saving",

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,50 +0,0 @@
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active";
export type PageRefreshRequest = {
save: (action: () => Promise<void>) => void;
block: (message: string) => void;
};
const downloadListeners = new Set<() => void>();
let activePageDownloads = 0;
export function isPageDownloadActive() {
return activePageDownloads > 0;
}
export function subscribeToPageDownloadActivity(listener: () => void) {
downloadListeners.add(listener);
return () => { downloadListeners.delete(listener); };
}
function publishDownloadActivity() {
if (typeof document !== "undefined") {
document.documentElement?.toggleAttribute(PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE, isPageDownloadActive());
}
for (const listener of downloadListeners) listener();
}
export async function withActivePageDownload<T>(download: () => Promise<T>): Promise<T> {
if (++activePageDownloads === 1) publishDownloadActivity();
try {
return await download();
} finally {
if (--activePageDownloads === 0) publishDownloadActivity();
}
}
function assertPageDownloadFinished() {
if (isPageDownloadActive()) throw new Error("文件正在下载,请等下载完成后再刷新。");
}
export async function preparePageRefresh() {
assertPageDownloadFinished();
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();
assertPageDownloadFinished();
}

View File

@ -1,15 +1,6 @@
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage"; import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy"; const IMAGE_PROXY_PATH = "/api/image-proxy";
const CDN_PREVIEW_WIDTHS = [64, 160, 192, 240, 320, 360, 480, 520, 640, 720, 840, 960, 1280, 1600, 2048, 2560, 2600, 3200, 4096];
function getConfiguredCdnUrl(): URL | undefined {
try {
return new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
} catch {
return undefined;
}
}
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */ /** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string { export function buildImageFallbackUrl(value: string | null | undefined): string {
@ -111,7 +102,10 @@ export function isAlibabaModelImageUrl(value: string): boolean {
function isTrustedDirectStorageImageHost(hostname: string): boolean { function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase(); const normalized = hostname.trim().toLowerCase();
let configuredCdnHost = "";
try { configuredCdnHost = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "").hostname; } catch { /* optional */ }
return ( return (
(configuredCdnHost !== "" && normalized === configuredCdnHost) ||
normalized === "yybb.top" || normalized === "yybb.top" ||
normalized.endsWith(".qiniucs.com") || normalized.endsWith(".qiniucs.com") ||
normalized.endsWith(".qiniudn.com") || normalized.endsWith(".qiniudn.com") ||
@ -133,36 +127,20 @@ function resolveTrustedDirectStorageDisplayUrl(
): string | null { ): string | null {
try { try {
const target = new URL(url); const target = new URL(url);
const configuredCdn = getConfiguredCdnUrl();
const storageOrigin = process.env.NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN;
if (configuredCdn?.protocol === "https:" && storageOrigin &&
target.origin === new URL(storageOrigin).origin &&
!target.searchParams.has("token") && !target.searchParams.has("X-Amz-Signature")) {
target.host = configuredCdn.host;
target.protocol = configuredCdn.protocol;
}
const isConfiguredCdn = target.origin === configuredCdn?.origin;
if ( if (
target.hostname !== configuredCdn?.hostname &&
!isTrustedDirectStorageImageHost(target.hostname) && !isTrustedDirectStorageImageHost(target.hostname) &&
!isFirstPartyQiniuAsset(target) !isFirstPartyQiniuAsset(target)
) { ) {
return null; return null;
} }
const requestedWidth = normalizeWidth(options.width); const width = normalizeWidth(options.width);
let width = requestedWidth; const quality = normalizeQuality(options.quality);
let quality = normalizeQuality(options.quality);
const format = options.format; const format = options.format;
if ((!width && !quality && !format) || target.search) { if ((!width && !quality && !format) || target.search) {
return target.toString(); return target.toString();
} }
if (isConfiguredCdn && format === "webp" && requestedWidth) {
width = CDN_PREVIEW_WIDTHS.find((size) => size >= requestedWidth) ?? requestedWidth;
if (quality && quality >= 84 && quality <= 92) quality = 88;
}
const operations = ["imageView2", "2"]; const operations = ["imageView2", "2"];
if (width) operations.push("w", String(width)); if (width) operations.push("w", String(width));
if (format) operations.push("format", format === "jpeg" ? "jpg" : format); if (format) operations.push("format", format === "jpeg" ? "jpg" : format);
@ -222,23 +200,6 @@ function resolveAliyunOssDisplayUrl(
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string { export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
let source = String(url || "").trim(); let source = String(url || "").trim();
if (options.delivery !== "original" && options.delivery !== "proxy") {
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
try {
const saved = new URL(source, base);
if (saved.origin === base && saved.pathname === IMAGE_PROXY_PATH &&
!saved.searchParams.has("original") && saved.searchParams.get("proxy") !== "1") {
const nested = saved.searchParams.get("url") || "";
const format = options.format || saved.searchParams.get("f") as ImageProxyFormat | undefined;
const direct = resolveTrustedDirectStorageDisplayUrl(nested, {
width: options.width || Number(saved.searchParams.get("w")) || undefined,
quality: options.quality || Number(saved.searchParams.get("q")) || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(format || "") ? format : undefined,
});
if (direct) return direct;
}
} catch { /* Keep non-storage proxies on their existing path. */ }
}
// Some messages retain our preview URL instead of its source URL. Rebuild // Some messages retain our preview URL instead of its source URL. Rebuild
// that local proxy request without the preview's resize/format parameters. // that local proxy request without the preview's resize/format parameters.
if (options.delivery === "original") { if (options.delivery === "original") {

View File

@ -26,14 +26,14 @@ function getBatchSize() {
return Math.max(1, Math.min(50, Math.round(parsed))); return Math.max(1, Math.min(50, Math.round(parsed)));
} }
export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) { export async function runImageArchiveJob() {
return trackRuntimeWork(async () => { return trackRuntimeWork(async () => {
const batchSize = getBatchSize(); const batchSize = getBatchSize();
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`); console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
await recoverStaleImageArchiveTasks(); await recoverStaleImageArchiveTasks();
scheduleImageDownloadPreparation(); scheduleImageDownloadPreparation();
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId }); const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
let storeSucceeded = 0; let storeSucceeded = 0;
let storeFailed = 0; let storeFailed = 0;
@ -41,13 +41,12 @@ export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?
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,6 +1,6 @@
import "server-only"; import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries"; import { getDefaultStorageConfig } from "@/lib/db/queries";
import { ensureStoredImageDownloadTask, findImageArchiveDownloadTask } from "./image-archive-db"; import { findImageArchiveDownloadTask } from "./image-archive-db";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
export async function resolveCanvasCdnDownload(urls: string[], filename: string) { export async function resolveCanvasCdnDownload(urls: string[], filename: string) {
@ -8,30 +8,17 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
const source = extractImageProxySourceUrl(url) || url; const source = extractImageProxySourceUrl(url) || url;
return [source, inferOriginalImageUrl(source) || source]; return [source, inferOriginalImageUrl(source) || source];
}))); })));
let task = await findImageArchiveDownloadTask(sources, true); const task = await findImageArchiveDownloadTask(sources, true);
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
if (!task) return { task, direct: null, canPrepareDirect: false };
const config = await getDefaultStorageConfig(); const config = await getDefaultStorageConfig();
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false }; if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
if (!task && base.protocol === "https:") {
for (const candidate of sources) {
const stored = new URL(candidate);
if (origin && stored.origin === new URL(origin).origin && !stored.search) {
stored.host = base.host;
stored.protocol = base.protocol;
}
if (stored.origin === base.origin && !stored.search && !stored.hash &&
stored.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/uploads/")) {
task = await ensureStoredImageDownloadTask(stored.toString());
break;
}
}
}
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png"; const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
const source = ready ? asset.url : task?.targetUrl; const source = ready ? asset.url : task.targetUrl;
if (!source) return { task, direct: null, canPrepareDirect: false }; if (!source) return { task, direct: null, canPrepareDirect: false };
const url = new URL(source); const url = new URL(source);
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
if (origin && url.origin === new URL(origin).origin) url.host = base.host; if (origin && url.origin === new URL(origin).origin) url.host = base.host;
if (base.protocol !== "https:" || url.origin !== base.origin if (base.protocol !== "https:" || url.origin !== base.origin
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false }; || !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };

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

@ -98,11 +98,8 @@ export async function enqueueImageArchiveTasks(params: {
return tasks; return tasks;
} }
export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) { export async function getImageArchiveTasksToStore(params: { limit: number }) {
const now = new Date(); const now = new Date();
const priority = params.preferredTaskId
? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)]
: [];
return await db return await db
.select() .select()
.from(imageArchiveTask) .from(imageArchiveTask)
@ -113,7 +110,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number; prefe
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS), lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
), ),
) )
.orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) .orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit))); .limit(Math.max(1, Math.min(50, params.limit)));
} }
@ -141,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)
@ -149,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,
@ -353,17 +349,6 @@ export async function findImageArchiveDownloadTask(urls: string[], prepareOnDema
} }
return task || null; return task || null;
} }
/** Reuse the download worker for stored uploads and synchronous tool results. */
export async function ensureStoredImageDownloadTask(url: string) {
const now = new Date();
const [created] = await db.insert(imageArchiveTask).values({
sourceUrl: url, targetUrl: url, status: "completed", archiveAfterAt: now,
replaceAfterAt: now, archivedAt: now, replacedAt: now, updatedAt: now,
metadata: { downloadAsset: { status: "pending" } },
}).onConflictDoNothing({ target: imageArchiveTask.sourceUrl }).returning();
return created || await findImageArchiveDownloadTask([url], true);
}
export async function hasPendingImageDownloads() { export async function hasPendingImageDownloads() {
const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where( const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where(
sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`, sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`,

View File

@ -6,7 +6,6 @@ type Worker = {
running: boolean; running: boolean;
scheduled: boolean; scheduled: boolean;
requested: boolean; requested: boolean;
preferredTaskId?: string;
retryTimer?: ReturnType<typeof setTimeout>; retryTimer?: ReturnType<typeof setTimeout>;
}; };
function getWorker(): Worker { function getWorker(): Worker {
@ -14,9 +13,8 @@ function getWorker(): Worker {
return (root[workerKey] ??= { running: false, scheduled: false, requested: false }); return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
} }
export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) { export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
const worker = getWorker(); const worker = getWorker();
worker.preferredTaskId ??= preferredTaskId;
worker.requested = true; worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return; if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true; worker.scheduled = true;
@ -33,9 +31,7 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredT
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job"); const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do { do {
worker.requested = false; worker.requested = false;
const nextTaskId = worker.preferredTaskId; const result = await runImageArchiveJob();
worker.preferredTaskId = undefined;
const result = await runImageArchiveJob({ preferredTaskId: nextTaskId });
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true; retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true;
// Stop when only backoff tasks remain; do not spin while storage is down. // Stop when only backoff tasks remain; do not spin while storage is down.
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break; if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;

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, prepareQiniuWebpDownload } 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,16 +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 converted = await prepareQiniuWebpDownload(task.targetUrl, task.id).catch(() => undefined);
if (converted) {
await saveImageDownloadAsset(id, converted);
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

@ -4,7 +4,6 @@ import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery"; import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download"; import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
/** Resolve the archived original without waiting for a separate PNG conversion. */ /** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) { export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
@ -15,10 +14,6 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
let target = new URL(source); let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) { if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]); const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
if (task?.status === "pending" && !task.targetUrl) {
const { scheduleImageArchiveWorker } = await import("./image-archive-worker");
scheduleImageArchiveWorker(false, task.id);
}
target = new URL(task?.targetUrl || source); target = new URL(task?.targetUrl || source);
} }
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) { if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
@ -33,32 +28,8 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
// Only metadata crosses the application server. The browser receives the file // Only metadata crosses the application server. The browser receives the file
// from the CDN, including when cross-origin fetch is disabled by storage CORS. // from the CDN, including when cross-origin fetch is disabled by storage CORS.
const response = await fetch(target, { const response = await fetch(target, {
method: "HEAD", cache: "default", method: "HEAD", cache: "no-store",
signal: AbortSignal.any([signal, AbortSignal.timeout(3_000)]), signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
}).catch(async (error) => {
signal.throwIfAborted();
if (target.search) throw error;
const { createS3Client, toDbS3UploadConfig } = await import("./service");
const storage = toDbS3UploadConfig(config);
if (!storage) throw error;
const key = decodeURIComponent(target.pathname.slice(base.pathname.replace(/\/$/, "").length + 1));
if (!key) throw error;
const client = createS3Client(storage);
try {
const object = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }), {
abortSignal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
});
return new Response(null, { headers: {
"content-type": object.ContentType || "",
"content-length": String(object.ContentLength ?? 0),
} });
} catch (storageError) {
const status = (storageError as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
if (status === 403 || status === 404) throw new Error(`HTTP ${status}`);
throw error;
} finally {
client.destroy();
}
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";

View File

@ -1,149 +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.-]+\/)*[\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) {
let source: URL;
try { source = new URL(sourceUrl); } catch { return null; }
if (!/^https?:$/.test(source.protocol) || source.username || source.password) return null;
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
return null;
}
// Files already in our bucket and inline previews need no remote copy.
const localOrigins = [config.publicUrl, config.endpoint, process.env.CANVAS_QINIU_ORIGIN_URL,
process.env.NEXT_PUBLIC_APP_URL, process.env.AUTH_URL, process.env.NEXTAUTH_URL];
if (localOrigins.some(value => value && new URL(value).origin === source.origin)
|| source.hostname === "localhost" || source.hostname === "[::1]"
|| /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(source.hostname)) return null;
// The archive task owns this object key, including retries after a timeout.
const pathname = `uploads/ai-generated-archive-${archiveId}`;
return fetchImageIntoQiniu(sourceUrl, pathname, target!);
}
/** Prepare uploaded WebP images inside Kodo, retaining their embedded colour profile. */
export async function prepareQiniuWebpDownload(url: string, taskId: string) {
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) return;
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base) || !/^uploads\/(?:[\w.-]+\/)*[\w.-]+\.webp$/i.test(url.slice(base.length))) return;
// No resizing or lossy re-encoding; apply orientation before removing EXIF.
const source = `${url}?imageMogr2/auto-orient/format/png/strip/meta-keep-list/${encodeQiniu("icc")}`;
const result = await fetchImageIntoQiniu(source, `uploads/canvas-download-${taskId}.png`, target!);
return result.downloadAsset;
}
async function fetchImageIntoQiniu(
sourceUrl: string,
pathname: string,
target: NonNullable<Awaited<ReturnType<typeof resolveS3UploadTarget>>>,
) {
const config = target.config;
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

@ -3,7 +3,6 @@ import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSA
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob"; import { put } from "@vercel/blob";
import { createHmac } from "node:crypto";
import { import {
getDefaultStorageConfig, getDefaultStorageConfig,
getStorageConfigById, getStorageConfigById,
@ -240,48 +239,12 @@ export async function maybeEnqueueStorageMigrationTask(params: {
await maybeEnqueueAliyunMigrationTask(params); await maybeEnqueueAliyunMigrationTask(params);
} }
async function uploadQiniuForm(
config: S3UploadConfig,
key: string,
buffer: ArrayBuffer,
contentType: string
) {
const encode = (value: Buffer) =>
value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
const policy = encode(Buffer.from(JSON.stringify({
scope: `${config.bucket}:${key}`,
deadline: Math.floor(Date.now() / 1000) + 120,
insertOnly: 1,
})));
const signature = encode(createHmac("sha1", config.secretAccessKey).update(policy).digest());
const form = new FormData();
form.append("token", `${config.accessKeyId}:${signature}:${policy}`);
form.append("key", key);
form.append("file", new Blob([buffer], { type: contentType }), key.split("/").at(-1));
const startedAt = Date.now();
const response = await fetch("https://upload.qiniup.com/", {
method: "POST",
body: form,
redirect: "error",
signal: AbortSignal.timeout(45_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu form upload returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string };
if (result.key !== key) throw new Error("Qiniu form upload returned an unexpected key");
logger.info("qiniu_form_upload_completed", { bytes: buffer.byteLength, elapsedMs: Date.now() - startedAt });
}
// S3/MinIO 上传函数(使用 AWS SDK) // S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3( async function uploadToS3(
filename: string, filename: string,
buffer: ArrayBuffer, buffer: ArrayBuffer,
config: S3UploadConfig, config: S3UploadConfig,
s3Client = createS3Client(config), s3Client = createS3Client(config)
preferQiniuForm = false
) { ) {
const { const {
endpoint, endpoint,
@ -297,14 +260,6 @@ async function uploadToS3(
const contentType = getContentType(filename); const contentType = getContentType(filename);
try { try {
if (preferQiniuForm && config.provider === "qiniu-kodo" && config.region === "cn-east-1") {
try {
await uploadQiniuForm(config, key, buffer, contentType);
return { url: buildS3PublicUrl(config, key), pathname: key, contentType };
} catch (error) {
logger.warn("[Upload] Qiniu form upload failed, retrying the same object through S3", error);
}
}
logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`);
// 上传文件 // 上传文件
@ -596,8 +551,7 @@ async function uploadWithAnyEnabledStorageConfig(
// 上传文件到存储服务 // 上传文件到存储服务
export async function uploadFile( export async function uploadFile(
filename: string, filename: string,
buffer: ArrayBuffer, buffer: ArrayBuffer
options?: { preferQiniuForm?: boolean }
): Promise<UploadedFileResult> { ): Promise<UploadedFileResult> {
logger.debug(`[Upload] Starting upload for file: ${filename}`); logger.debug(`[Upload] Starting upload for file: ${filename}`);
@ -618,7 +572,7 @@ export async function uploadFile(
logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`); logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`);
logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`); logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`); logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config, undefined, options?.preferQiniuForm); const uploaded = await uploadToS3(filename, buffer, dbS3Config);
lastKnownGoodDbS3Config = dbS3Config; lastKnownGoodDbS3Config = dbS3Config;
await maybeEnqueueAliyunMigrationTask({ await maybeEnqueueAliyunMigrationTask({
uploaded, uploaded,

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,10 +45,6 @@ function resolveDevMediaOrigins(isDev: boolean) {
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_APP_RELEASE: appRelease,
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: process.env.CANVAS_QINIU_ORIGIN_URL || "",
},
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/,

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