Compare commits
10 Commits
5b328355ea
...
fdb5304111
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb5304111 | ||
|
|
3ca5859e26 | ||
|
|
c825618398 | ||
|
|
528968d74d | ||
|
|
2b82d57f73 | ||
|
|
2090dbd582 | ||
|
|
faa70a315c | ||
|
|
9da001e3ee | ||
|
|
b12d61b371 | ||
|
|
800eb10ba4 |
@ -205,6 +205,7 @@ export async function generateImageWithFailoverCandidate(params: {
|
||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||
messages: candidateMessages,
|
||||
imageParams: imageParams || fallbackImageParams,
|
||||
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||
modelDisplayName: candidate.model.displayName,
|
||||
overrideModelId: candidate.model.modelId,
|
||||
});
|
||||
|
||||
@ -410,6 +410,7 @@ async function replayAssistantMessagesForDuplicateRequest({
|
||||
}
|
||||
|
||||
type ChatImageParams = NonNullable<PostRequestBody["imageParams"]>;
|
||||
type ChatImageSize = NonNullable<ChatImageParams["imageSize"]>;
|
||||
type AgentRecommendedImageParams = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
@ -444,6 +445,14 @@ type AgentVideoPlan = {
|
||||
recommendedVideoParams?: AgentRecommendedVideoParams;
|
||||
};
|
||||
|
||||
function normalizeChatImageSize(value?: string | null): ChatImageSize | undefined {
|
||||
const normalized = String(value || "").trim().toUpperCase();
|
||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasUserCustomizedImageParams(params?: PostRequestBody["imageParams"]): boolean {
|
||||
if (!params) return false;
|
||||
const customizedAspectRatio =
|
||||
@ -484,7 +493,8 @@ function mergeImageParamsWithAgentRecommendation(params: {
|
||||
|
||||
if (!hasUserCustomSettings) {
|
||||
if (recommended.aspectRatio) merged.aspectRatio = recommended.aspectRatio;
|
||||
if (recommended.imageSize) merged.imageSize = recommended.imageSize;
|
||||
const recommendedImageSize = normalizeChatImageSize(recommended.imageSize);
|
||||
if (recommendedImageSize) merged.imageSize = recommendedImageSize;
|
||||
if (typeof recommended.imageCount === "number") merged.imageCount = recommended.imageCount;
|
||||
}
|
||||
|
||||
@ -573,6 +583,8 @@ const SMART_AUTO_FALLBACK_TIMEOUT_MS = 20_000;
|
||||
const SMART_AUTO_IMAGE_SUMMARY_TIMEOUT_MS = 18_000;
|
||||
const DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS = 12_000;
|
||||
const CHAT_CREATION_LOOKUP_RETRY_DELAYS_MS = [60, 180, 360] as const;
|
||||
const DETAIL_REPLACEMENT_PROMPT_PACK_REQUIRED_MESSAGE =
|
||||
"请先在「风格化提示词配置」里选择提示词组合,再执行详情场景替换;缺少提示词组时系统不会处理。";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@ -2036,12 +2048,19 @@ export async function POST(request: Request) {
|
||||
imageSize: smartAutoDetectedIntent === "image_edit" ? "4K" : "2K",
|
||||
imageCount: 1,
|
||||
} as const;
|
||||
const normalizedEffectiveImageSize = normalizeChatImageSize(effectiveImageParams?.imageSize);
|
||||
const effectiveImageGenerationParams = {
|
||||
...(effectiveImageParams || {}),
|
||||
aspectRatio: effectiveImageParams?.aspectRatio || defaultImageQualityParams.aspectRatio,
|
||||
imageSize: normalizedEffectiveImageSize || defaultImageQualityParams.imageSize,
|
||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount,
|
||||
};
|
||||
|
||||
if (shouldApplyAgentImageParams) {
|
||||
chatApiLogger.info("[Smart Auto Mode] 🎯 Effective image params:", {
|
||||
incoming: requestBody.imageParams,
|
||||
recommended: smartAutoRecommendedImageParams,
|
||||
merged: effectiveImageParams,
|
||||
merged: effectiveImageGenerationParams,
|
||||
hasUserCustomImageParams,
|
||||
});
|
||||
}
|
||||
@ -3118,6 +3137,15 @@ export async function POST(request: Request) {
|
||||
const replacementReferences = normalizeAgentModeReferences(agentModePayload);
|
||||
const detailReplacementStrategy =
|
||||
normalizeAgentModeDetailReplacementStrategy(agentModePayload);
|
||||
if (detailReplacementStrategy !== "product_only" && !hiddenPromptText) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message: DETAIL_REPLACEMENT_PROMPT_PACK_REQUIRED_MESSAGE,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (replacementReferences.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@ -3540,7 +3568,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
let finalImage = firstImage;
|
||||
let finalImageSizeLabel = resolvedImageSize;
|
||||
let finalImageSizeLabel: string = resolvedImageSize;
|
||||
try {
|
||||
const normalizedImage = await normalizeReplacementResultImage({
|
||||
generatedImageUrl: firstImage.originalUrl,
|
||||
@ -3720,6 +3748,19 @@ export async function POST(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
isDetailReplacementPreset &&
|
||||
detailReplacementStrategy !== "product_only" &&
|
||||
!hiddenPromptText
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message: DETAIL_REPLACEMENT_PROMPT_PACK_REQUIRED_MESSAGE,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const uploadedImageParts = extractCurrentMessageImageParts(message.parts as any[]);
|
||||
const requiredUploadCount = getAgentRequiredUploadCount({
|
||||
@ -4355,7 +4396,7 @@ export async function POST(request: Request) {
|
||||
throw new Error("未返回结果图片");
|
||||
}
|
||||
let finalImage = firstImage;
|
||||
let finalImageSizeLabel = resolvedImageSize;
|
||||
let finalImageSizeLabel: string = resolvedImageSize;
|
||||
try {
|
||||
const normalizedImage = await normalizeReplacementResultImage({
|
||||
generatedImageUrl: firstImage.originalUrl,
|
||||
@ -6103,10 +6144,7 @@ export async function POST(request: Request) {
|
||||
: undefined
|
||||
);
|
||||
const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image", {
|
||||
imageSize:
|
||||
typeof effectiveImageParams?.imageSize === "string"
|
||||
? effectiveImageParams.imageSize
|
||||
: defaultImageQualityParams.imageSize,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
});
|
||||
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", {
|
||||
duration: Number(effectiveVideoParams?.duration) || undefined,
|
||||
@ -6141,7 +6179,7 @@ export async function POST(request: Request) {
|
||||
1,
|
||||
Math.round(
|
||||
Number(
|
||||
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1
|
||||
effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1
|
||||
)
|
||||
)
|
||||
);
|
||||
@ -6221,7 +6259,7 @@ export async function POST(request: Request) {
|
||||
const isGeminiImageProvider = ["gemini-image", "huanke-gemini"].includes((languageModel as any).provider);
|
||||
if (isGeminiImageProvider && typeof (languageModel as any).doGenerate === "function") {
|
||||
chatApiLogger.info("[Chat API] Using Gemini image generation, provider:", (languageModel as any).provider);
|
||||
chatApiLogger.info("[Chat API] Image params:", effectiveImageParams);
|
||||
chatApiLogger.info("[Chat API] Image params:", effectiveImageGenerationParams);
|
||||
|
||||
// 🆕 获取备用模型配置
|
||||
const currentModelConfig = await getAiModelById({ id: finalModelId });
|
||||
@ -6308,11 +6346,7 @@ export async function POST(request: Request) {
|
||||
// 调用 doGenerate 获取图片(如果是备用模型,传入 overrideModelId)
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
messages: modelMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
overrideModelId: isRetry ? modelIdToUse : undefined, // 🆕 使用备用模型 ID
|
||||
});
|
||||
|
||||
@ -6338,7 +6372,7 @@ export async function POST(request: Request) {
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
imageCount: effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
},
|
||||
"gemini:image-status-start",
|
||||
);
|
||||
@ -6360,10 +6394,10 @@ export async function POST(request: Request) {
|
||||
runtimeModelId: actualModelId,
|
||||
finalModelId,
|
||||
providerType: (languageModel as any).provider,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
imageCount:
|
||||
effectiveImageParams?.imageCount ||
|
||||
effectiveImageGenerationParams.imageCount ||
|
||||
defaultImageQualityParams.imageCount ||
|
||||
1,
|
||||
},
|
||||
@ -6381,7 +6415,7 @@ export async function POST(request: Request) {
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
messages: buildMessagesForGemini(),
|
||||
imageParams: effectiveImageParams,
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
fallbackImageParams: {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
@ -6407,8 +6441,8 @@ export async function POST(request: Request) {
|
||||
primaryModelId: actualModelId,
|
||||
fallbackModelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
||||
@ -6443,9 +6477,9 @@ export async function POST(request: Request) {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
chatApiLogger.info(`[Chat API] 🔁 Gemini cross-provider failover: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
messages: buildMessagesForGemini(),
|
||||
imageParams: effectiveImageParams,
|
||||
candidate,
|
||||
messages: buildMessagesForGemini(),
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
fallbackImageParams: {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
@ -6641,12 +6675,7 @@ export async function POST(request: Request) {
|
||||
const volcanoResult = await (volcanoLanguageModel as any).doGenerate({
|
||||
messages: volcanoMessages,
|
||||
maxImages: 1, // 故障转移时只生成 1 张
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
});
|
||||
|
||||
const imageMarkdown = volcanoResult.text || "图片生成失败";
|
||||
@ -6820,7 +6849,7 @@ export async function POST(request: Request) {
|
||||
].includes((languageModel as any).provider);
|
||||
if (isAlibabaLikeImageProvider && typeof (languageModel as any).doGenerate === "function") {
|
||||
chatApiLogger.info("[Chat API] Using image generation provider:", (languageModel as any).provider);
|
||||
chatApiLogger.info("[Chat API] Image params:", effectiveImageParams);
|
||||
chatApiLogger.info("[Chat API] Image params:", effectiveImageGenerationParams);
|
||||
|
||||
// 🆕 获取模型显示名称,用于错误提示
|
||||
const alibabaModelConfig = await getAiModelById({ id: finalModelId });
|
||||
@ -6926,12 +6955,7 @@ export async function POST(request: Request) {
|
||||
apiKey: falApiKey,
|
||||
modelId: actualModelId,
|
||||
messages: falMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
});
|
||||
|
||||
const placeholderMessageId = generateUUID();
|
||||
@ -6958,9 +6982,9 @@ export async function POST(request: Request) {
|
||||
optimizedPrompt: smartAutoOptimizedPrompt || userPrompt || queuedTask.prompt,
|
||||
userPrompt: userPrompt || queuedTask.prompt,
|
||||
requestedImageCount:
|
||||
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
outputSizeLabel: queuedTask.outputSizeLabel,
|
||||
sourceImageUrls: requestTargetResolution?.targetImageUrls || queuedTask.sourceImageUrls,
|
||||
sourceAssetIds: requestTargetResolution?.targetAssetIds || [],
|
||||
@ -6988,7 +7012,7 @@ export async function POST(request: Request) {
|
||||
providerType: "fal-image",
|
||||
modelName: modelDisplayName,
|
||||
imageCount:
|
||||
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
},
|
||||
"alibaba:image-status-queued",
|
||||
);
|
||||
@ -7050,12 +7074,8 @@ export async function POST(request: Request) {
|
||||
// 调用 doGenerate 获取图片,传递模型显示名称
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
messages: modelMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
imageGenConfig: alibabaModelConfig?.imageGenConfig || null,
|
||||
modelDisplayName, // 🆕 传递模型显示名称
|
||||
overrideModelId: isRetry ? modelIdToUse : undefined,
|
||||
});
|
||||
@ -7083,7 +7103,7 @@ export async function POST(request: Request) {
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: alibabaModelConfig?.displayName || actualModelId,
|
||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
imageCount: effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
},
|
||||
"alibaba:image-status-start",
|
||||
);
|
||||
@ -7102,7 +7122,7 @@ export async function POST(request: Request) {
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
messages: messagesForAlibaba,
|
||||
imageParams: effectiveImageParams,
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
fallbackImageParams: {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
@ -7194,12 +7214,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||
messages: candidateMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||
modelDisplayName: candidate.model.displayName,
|
||||
overrideModelId: candidate.model.modelId,
|
||||
});
|
||||
@ -7477,12 +7493,7 @@ export async function POST(request: Request) {
|
||||
const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => {
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
messages: modelMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
overrideModelId: isRetry ? modelIdToUse : undefined,
|
||||
});
|
||||
if (result?.finishReason === "error") {
|
||||
@ -7507,7 +7518,7 @@ export async function POST(request: Request) {
|
||||
phase: "processing",
|
||||
providerType: "volcano-vision",
|
||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
imageCount: effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||
},
|
||||
"volcano:image-status-start",
|
||||
);
|
||||
@ -7524,7 +7535,7 @@ export async function POST(request: Request) {
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
messages: messagesForVolcano,
|
||||
imageParams: effectiveImageParams,
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
fallbackImageParams: {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
@ -7615,12 +7626,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||
messages: candidateMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||
modelDisplayName: candidate.model.displayName,
|
||||
overrideModelId: candidate.model.modelId,
|
||||
});
|
||||
@ -8378,7 +8385,7 @@ export async function POST(request: Request) {
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
messages: messagesForCrossProvider,
|
||||
imageParams: effectiveImageParams,
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
fallbackImageParams: {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
@ -8474,12 +8481,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||
messages: candidateMessages,
|
||||
imageParams:
|
||||
effectiveImageParams || {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
imageParams: effectiveImageGenerationParams,
|
||||
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||
modelDisplayName: candidate.model.displayName,
|
||||
overrideModelId: candidate.model.modelId,
|
||||
});
|
||||
|
||||
@ -30,10 +30,20 @@ const videoParamsSchema = z.object({
|
||||
returnLastFrame: z.boolean().optional(),
|
||||
}).optional();
|
||||
|
||||
const imageSizeValues = ["1K", "2K", "4K"] as const;
|
||||
|
||||
function normalizeImageSizeInput(value: unknown) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return imageSizeValues.includes(normalized as (typeof imageSizeValues)[number])
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// 🆕 图片生成参数 schema
|
||||
const imageParamsSchema = z.object({
|
||||
aspectRatio: z.string().optional(),
|
||||
imageSize: z.string().optional(),
|
||||
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
||||
imageCount: z.number().min(1).max(10).optional(),
|
||||
// 阿里生图专用参数
|
||||
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { upsertSystemSetting } from "@/lib/db/queries";
|
||||
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
|
||||
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
|
||||
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||
import {
|
||||
AGENT_PSD_TEMPLATES_KEY,
|
||||
type AgentPsdTemplate,
|
||||
type AgentPsdTemplateLayerSlot,
|
||||
type AgentPsdTemplateMutationResponse,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
@ -15,6 +12,11 @@ import {
|
||||
} from "@/lib/agents/psd-template-parser";
|
||||
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
|
||||
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
|
||||
import {
|
||||
createAgentPsdTemplateParseHeartbeat,
|
||||
updateAgentPsdTemplateById,
|
||||
updateAgentPsdTemplateParseProgress,
|
||||
} from "@/lib/agents/psd-template-progress";
|
||||
import {
|
||||
cacheAgentPsdSourceLocally,
|
||||
hasAgentPsdLocalCache,
|
||||
@ -158,44 +160,15 @@ function buildInitialTemplateLayers(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateTemplate(
|
||||
templateId: string,
|
||||
updatedBy: string,
|
||||
updater: (template: AgentPsdTemplate) => AgentPsdTemplate
|
||||
) {
|
||||
const templates = await readAgentPsdTemplates();
|
||||
const currentTemplate = templates.find((entry) => entry.id === templateId);
|
||||
|
||||
if (!currentTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTemplate = updater(currentTemplate);
|
||||
await upsertSystemSetting({
|
||||
key: AGENT_PSD_TEMPLATES_KEY,
|
||||
value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)),
|
||||
updatedBy,
|
||||
});
|
||||
|
||||
return nextTemplate;
|
||||
}
|
||||
|
||||
async function updateParseProgress(params: {
|
||||
templateId: string;
|
||||
updatedBy: string;
|
||||
progress: number;
|
||||
stage: string;
|
||||
error?: string;
|
||||
restart?: boolean;
|
||||
}) {
|
||||
const { templateId, updatedBy, progress, stage, error } = params;
|
||||
return updateTemplate(templateId, updatedBy, (template) => ({
|
||||
...template,
|
||||
parseStatus: "processing",
|
||||
parseProgress: progress,
|
||||
parseStage: stage,
|
||||
parseError: error,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
return updateAgentPsdTemplateParseProgress(params);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
@ -231,137 +204,150 @@ export async function POST(
|
||||
return jsonError(409, "该模板正在解析中,请稍后刷新");
|
||||
}
|
||||
|
||||
await updateParseProgress({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
let latestProgress = {
|
||||
progress: 5,
|
||||
stage: "准备解析 PSD",
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
let fileBuffer: ArrayBuffer;
|
||||
if (
|
||||
await hasAgentPsdLocalCache({
|
||||
templateId: id,
|
||||
fileName: currentTemplate.fileName,
|
||||
})
|
||||
) {
|
||||
await updateParseProgress({
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
const setParseProgress = async (progress: number, stage: string, error?: string) => {
|
||||
latestProgress = {
|
||||
progress,
|
||||
stage,
|
||||
error,
|
||||
};
|
||||
return updateParseProgress({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
progress: 18,
|
||||
stage: "正在读取服务器本地 PSD 缓存",
|
||||
error: undefined,
|
||||
});
|
||||
const localBuffer = await readAgentPsdSourceFromLocalCache({
|
||||
templateId: id,
|
||||
fileName: currentTemplate.fileName,
|
||||
});
|
||||
fileBuffer = localBuffer.buffer.slice(
|
||||
localBuffer.byteOffset,
|
||||
localBuffer.byteOffset + localBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
} else {
|
||||
const response = await fetch(currentTemplate.fileUrl, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
progress,
|
||||
stage,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PSD 文件下载失败 (${response.status})`);
|
||||
await updateParseProgress({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
...latestProgress,
|
||||
error: undefined,
|
||||
restart: true,
|
||||
});
|
||||
const heartbeat = createAgentPsdTemplateParseHeartbeat({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
getSnapshot: () => latestProgress,
|
||||
});
|
||||
|
||||
try {
|
||||
let fileBuffer: ArrayBuffer;
|
||||
if (
|
||||
await hasAgentPsdLocalCache({
|
||||
templateId: id,
|
||||
fileName: currentTemplate.fileName,
|
||||
})
|
||||
) {
|
||||
await setParseProgress(18, "正在读取服务器本地 PSD 缓存");
|
||||
const localBuffer = await readAgentPsdSourceFromLocalCache({
|
||||
templateId: id,
|
||||
fileName: currentTemplate.fileName,
|
||||
});
|
||||
fileBuffer = localBuffer.buffer.slice(
|
||||
localBuffer.byteOffset,
|
||||
localBuffer.byteOffset + localBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
} else {
|
||||
const response = await fetch(currentTemplate.fileUrl, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PSD 文件下载失败 (${response.status})`);
|
||||
}
|
||||
|
||||
await setParseProgress(18, "正在下载并写入服务器本地 PSD 缓存");
|
||||
|
||||
fileBuffer = await response.arrayBuffer();
|
||||
await cacheAgentPsdSourceLocally({
|
||||
templateId: id,
|
||||
fileName: currentTemplate.fileName,
|
||||
buffer: fileBuffer,
|
||||
}).catch((error) => {
|
||||
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
|
||||
templateId: id,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await updateParseProgress({
|
||||
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
|
||||
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
|
||||
onProgress: async (event) => {
|
||||
await setParseProgress(event.progress, event.stage);
|
||||
},
|
||||
});
|
||||
|
||||
if (parsedTemplate.layers.length === 0) {
|
||||
throw new Error("PSD 中未识别到可配置图层");
|
||||
}
|
||||
|
||||
await setParseProgress(42, "正在上传模板预览");
|
||||
|
||||
const uploadedPreview = await uploadFile(
|
||||
`agent-psd-preview-${id}-${Date.now()}.png`,
|
||||
toArrayBuffer(parsedTemplate.previewBuffer)
|
||||
);
|
||||
|
||||
await setParseProgress(58, "正在匹配字体与初始化图层");
|
||||
|
||||
const fonts = await readAgentPsdFonts().catch(() => []);
|
||||
const initialLayers = buildInitialTemplateLayers({
|
||||
parsedLayers: parsedTemplate.layers,
|
||||
fonts,
|
||||
canvasWidth: parsedTemplate.width,
|
||||
canvasHeight: parsedTemplate.height,
|
||||
});
|
||||
|
||||
heartbeat.stop();
|
||||
const updatedTemplate = await updateAgentPsdTemplateById({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
progress: 18,
|
||||
stage: "正在下载并写入服务器本地 PSD 缓存",
|
||||
error: undefined,
|
||||
updater: (template) => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...template,
|
||||
previewUrl: uploadedPreview.url,
|
||||
previewPath: uploadedPreview.pathname,
|
||||
width: parsedTemplate.width,
|
||||
height: parsedTemplate.height,
|
||||
layerCount: parsedTemplate.layers.length,
|
||||
layers: initialLayers,
|
||||
parseStatus: "processing",
|
||||
parseProgress: 48,
|
||||
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
|
||||
parseError: undefined,
|
||||
parseHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
fileBuffer = await response.arrayBuffer();
|
||||
await cacheAgentPsdSourceLocally({
|
||||
if (!updatedTemplate) {
|
||||
return jsonError(404, "未找到 PSD 模板");
|
||||
}
|
||||
|
||||
schedulePsdTemplateAssetBackfill({
|
||||
templateId: id,
|
||||
fileName: currentTemplate.fileName,
|
||||
buffer: fileBuffer,
|
||||
}).catch((error) => {
|
||||
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
|
||||
templateId: id,
|
||||
error,
|
||||
});
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
template: updatedTemplate,
|
||||
} satisfies AgentPsdTemplateMutationResponse);
|
||||
} catch (error) {
|
||||
heartbeat.stop();
|
||||
throw error;
|
||||
}
|
||||
|
||||
await updateParseProgress({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
progress: 24,
|
||||
stage: "正在解析 PSD 图层结构与预览",
|
||||
error: undefined,
|
||||
});
|
||||
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
|
||||
|
||||
if (parsedTemplate.layers.length === 0) {
|
||||
throw new Error("PSD 中未识别到可配置图层");
|
||||
}
|
||||
|
||||
await updateParseProgress({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
progress: 42,
|
||||
stage: "正在上传模板预览",
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const uploadedPreview = await uploadFile(
|
||||
`agent-psd-preview-${id}-${Date.now()}.png`,
|
||||
toArrayBuffer(parsedTemplate.previewBuffer)
|
||||
);
|
||||
|
||||
await updateParseProgress({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
progress: 58,
|
||||
stage: "正在匹配字体与初始化图层",
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const fonts = await readAgentPsdFonts().catch(() => []);
|
||||
const initialLayers = buildInitialTemplateLayers({
|
||||
parsedLayers: parsedTemplate.layers,
|
||||
fonts,
|
||||
canvasWidth: parsedTemplate.width,
|
||||
canvasHeight: parsedTemplate.height,
|
||||
});
|
||||
|
||||
const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({
|
||||
...template,
|
||||
previewUrl: uploadedPreview.url,
|
||||
previewPath: uploadedPreview.pathname,
|
||||
width: parsedTemplate.width,
|
||||
height: parsedTemplate.height,
|
||||
layerCount: parsedTemplate.layers.length,
|
||||
layers: initialLayers,
|
||||
parseStatus: "processing",
|
||||
parseProgress: 48,
|
||||
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
|
||||
parseError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
if (!updatedTemplate) {
|
||||
return jsonError(404, "未找到 PSD 模板");
|
||||
}
|
||||
|
||||
schedulePsdTemplateAssetBackfill({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
template: updatedTemplate,
|
||||
} satisfies AgentPsdTemplateMutationResponse);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
|
||||
@ -372,18 +358,22 @@ export async function POST(
|
||||
? "completed"
|
||||
: "failed";
|
||||
|
||||
await updateTemplate(id, session.user.id, (template) => ({
|
||||
...template,
|
||||
parseStatus: fallbackStatus,
|
||||
parseProgress: fallbackStatus === "completed" ? 100 : 0,
|
||||
parseStage:
|
||||
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
|
||||
parseError:
|
||||
fallbackStatus === "completed"
|
||||
? `重新解析失败:${message}`
|
||||
: message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})).catch(() => undefined);
|
||||
await updateAgentPsdTemplateById({
|
||||
templateId: id,
|
||||
updatedBy: session.user.id,
|
||||
updater: (template) => ({
|
||||
...template,
|
||||
parseStatus: fallbackStatus,
|
||||
parseProgress: fallbackStatus === "completed" ? 100 : 0,
|
||||
parseStage:
|
||||
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
|
||||
parseError:
|
||||
fallbackStatus === "completed"
|
||||
? `重新解析失败:${message}`
|
||||
: message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
console.error("[PSD Templates][Admin] parse failed:", error);
|
||||
|
||||
@ -42,7 +42,7 @@ import {
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@ -339,6 +339,36 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskImportContext(task?: TaskRecord | null) {
|
||||
const isWorldTask = task?.provider === "worldlabs-3d";
|
||||
const modelUrl = resolveTaskModelUrl(task);
|
||||
const worldAssetPayload = isWorldTask ? task?.assetPayload ?? null : null;
|
||||
const primarySplatUrl =
|
||||
isWorldTask && worldAssetPayload
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
task?.previewImageUrl ||
|
||||
null;
|
||||
const canImportWorld = Boolean(
|
||||
modelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
|
||||
return {
|
||||
canImport: Boolean(task?.status === "success") && (isWorldTask ? canImportWorld : Boolean(modelUrl)),
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `slot_${index + 1}`,
|
||||
@ -642,15 +672,28 @@ function CostPill({
|
||||
|
||||
export default function CanvasModel3DPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const routeParams = useParams();
|
||||
const id = String(routeParams?.id || "");
|
||||
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
|
||||
const returnTo = useMemo(() => {
|
||||
const raw = searchParams.get("returnTo")?.trim();
|
||||
return raw || `/canvas/${id}`;
|
||||
}, [id, searchParams]);
|
||||
const shouldReturnToVideoCanvas = useMemo(
|
||||
() =>
|
||||
searchParams.get("source") === "video-canvas" ||
|
||||
/(?:\?|&)mode=video(?:&|$)|(?:\?|&)workspaceType=video(?:&|$)/.test(returnTo),
|
||||
[returnTo, searchParams]
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const addNodesBulk = useCanvasStore((state) => state.addNodesBulk);
|
||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
|
||||
const [mode, setMode] = useState<GenerationMode>("text");
|
||||
@ -724,11 +767,12 @@ export default function CanvasModel3DPage() {
|
||||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||
//多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布
|
||||
const [selectedAddTaskIds, setSelectedAddTaskIds] = useState<Set<string>>(new Set());
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>( null
|
||||
);
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
@ -1131,7 +1175,9 @@ export default function CanvasModel3DPage() {
|
||||
const pending = normalized.find(
|
||||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||||
);
|
||||
setCurrentTask(pending || normalized[0] || null);
|
||||
const initialTask = pending || normalized[0] || null;
|
||||
setCurrentTask(initialTask);
|
||||
setWorkspaceMode(inferWorkspaceModeFromTask(initialTask));
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
|
||||
const migrated = await migrateLegacyHistory();
|
||||
@ -1261,21 +1307,31 @@ export default function CanvasModel3DPage() {
|
||||
return next;
|
||||
}, [isolatedLayerId, layerVisibility, viewerLayers]);
|
||||
|
||||
const visibleTaskHistory = useMemo(
|
||||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
const shareableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||||
const visibleTaskHistory = useMemo(
|
||||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
const shareableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||||
|
||||
// 可添加到画布的历史记录(世界 + 模型,仅成功状态)
|
||||
const addableTasks = useMemo(
|
||||
() => visibleTaskHistory.filter((task) => task.status === "success"),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedAddTasks = useMemo(
|
||||
() => addableTasks.filter((task) => selectedAddTaskIds.has(task.taskId)),
|
||||
[addableTasks, selectedAddTaskIds]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
|
||||
@ -1991,31 +2047,40 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentTask || currentTask.status !== "success") {
|
||||
const buildReturnUrl = useCallback(
|
||||
(taskId?: string) => {
|
||||
if (typeof window === "undefined") return returnTo;
|
||||
const url = new URL(returnTo, window.location.origin);
|
||||
if (taskId) {
|
||||
url.searchParams.set("directorImport", "1");
|
||||
url.searchParams.set("directorTaskId", taskId);
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
},
|
||||
[returnTo]
|
||||
);
|
||||
|
||||
const handleImportToCanvas = async (task = currentTask) => {
|
||||
if (!task || task.status !== "success") {
|
||||
toast.error("暂无可添加的 3D 资产");
|
||||
return;
|
||||
}
|
||||
|
||||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||||
const worldAssetPayload = currentTask.assetPayload ?? null;
|
||||
const primarySplatUrl = isWorldTask
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
currentTask.previewImageUrl ||
|
||||
null;
|
||||
if (shouldReturnToVideoCanvas) {
|
||||
router.push(buildReturnUrl(task.taskId));
|
||||
return;
|
||||
}
|
||||
|
||||
const canImportWorld = Boolean(
|
||||
currentModelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
const {
|
||||
canImport,
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
} = getTaskImportContext(task);
|
||||
|
||||
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
|
||||
if (!canImport) {
|
||||
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
|
||||
return;
|
||||
}
|
||||
@ -2041,14 +2106,14 @@ export default function CanvasModel3DPage() {
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
currentTask.previewImageUrl ||
|
||||
task.previewImageUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl
|
||||
? "splat"
|
||||
@ -2059,15 +2124,15 @@ export default function CanvasModel3DPage() {
|
||||
hotspots: [],
|
||||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -2077,22 +2142,22 @@ export default function CanvasModel3DPage() {
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: currentModelUrl!,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl!),
|
||||
src: modelUrl!,
|
||||
previewImage: task.previewImageUrl || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
format: extractModelFormat(modelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -2531,6 +2596,7 @@ export default function CanvasModel3DPage() {
|
||||
const handleSelectTask = (task: TaskRecord) => {
|
||||
setWorkspaceMode(inferWorkspaceModeFromTask(task));
|
||||
setCurrentTask(task);
|
||||
setSelectedWorldShareTaskIds([]);
|
||||
if (task.status === "success" && task.modelUrl) {
|
||||
setViewerLayers([]);
|
||||
setLayerVisibility({});
|
||||
@ -2615,17 +2681,7 @@ export default function CanvasModel3DPage() {
|
||||
currentWorldAsset?.thumbnailUrl ||
|
||||
(workspaceMode === "world" ? currentPreviewImage : null) ||
|
||||
null;
|
||||
const importDisabled =
|
||||
!currentTask ||
|
||||
currentTask.status !== "success" ||
|
||||
(currentTask.provider === "worldlabs-3d"
|
||||
? !Boolean(
|
||||
currentModelUrl ||
|
||||
currentWorldAsset?.worldUrl ||
|
||||
currentWorldImageUrl ||
|
||||
currentPrimarySpzUrl
|
||||
)
|
||||
: !currentModelUrl);
|
||||
const importDisabled = !getTaskImportContext(currentTask).canImport;
|
||||
const availableWorldViewportModes = useMemo(() => {
|
||||
const modes: WorldViewportMode[] = [];
|
||||
if (currentPrimarySpzUrl) modes.push("splat");
|
||||
@ -2746,7 +2802,7 @@ export default function CanvasModel3DPage() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => router.push(`/canvas/${id}`)}
|
||||
onClick={() => router.push(returnTo)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
@ -3414,7 +3470,7 @@ export default function CanvasModel3DPage() {
|
||||
<Button
|
||||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||||
disabled={importDisabled}
|
||||
onClick={handleImportToCanvas}
|
||||
onClick={() => void handleImportToCanvas(currentTask)}
|
||||
variant="secondary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||||
@ -3530,7 +3586,7 @@ export default function CanvasModel3DPage() {
|
||||
<Button
|
||||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||||
disabled={importDisabled}
|
||||
onClick={handleImportToCanvas}
|
||||
onClick={() => void handleImportToCanvas(currentTask)}
|
||||
variant="secondary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||||
|
||||
@ -18,6 +18,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowUp,
|
||||
CheckCircle2,
|
||||
Droplet,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@ -67,6 +68,7 @@ import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-s
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import {
|
||||
@ -1457,6 +1459,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [replacementRerunTarget, setReplacementRerunTarget] =
|
||||
useState<ReplacementRerunTarget | null>(null);
|
||||
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const closePsdTemplateDialog = useCallback(() => {
|
||||
setIsPsdTemplateDialogOpen(false);
|
||||
setPsdTemplateDialogSourceUrl(null);
|
||||
@ -3859,6 +3863,74 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
title: String(params.title || "").trim() || "待改字结果",
|
||||
});
|
||||
}, []);
|
||||
const handleRemoveDetailResultWatermark = useCallback(
|
||||
async (params: {
|
||||
cardId: string;
|
||||
imageUrl: string;
|
||||
}) => {
|
||||
const rawUrl = String(params.imageUrl || "").trim();
|
||||
const cardId = String(params.cardId || "").trim();
|
||||
if (!cardId || !/^https?:\/\//i.test(rawUrl)) {
|
||||
toast.error("结果图地址无效,无法去水印");
|
||||
return;
|
||||
}
|
||||
if (isAnyGenerationRunning) {
|
||||
toast.info("当前仍有生成任务处理中,完成后再去水印");
|
||||
return;
|
||||
}
|
||||
if (detailWatermarkRemoveCardIds.has(cardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailWatermarkRemoveCardIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(cardId);
|
||||
return next;
|
||||
});
|
||||
toast.info("正在去水印...");
|
||||
|
||||
try {
|
||||
const payload = await requestLongTaskResult({
|
||||
endpoint: "/api/watermark-remove",
|
||||
payload: {
|
||||
image_url: rawUrl,
|
||||
},
|
||||
});
|
||||
if (!payload?.success) {
|
||||
throw new Error(payload?.error || payload?.detail || "去水印失败");
|
||||
}
|
||||
const nextUrl = String(payload.original_url || payload.thumbnail_url || "").trim();
|
||||
if (!/^https?:\/\//i.test(nextUrl)) {
|
||||
throw new Error("去水印完成但结果地址无效");
|
||||
}
|
||||
|
||||
handleUpdateReplacementResultRecord({
|
||||
modeType: "detail_replacement",
|
||||
cardId,
|
||||
url: nextUrl,
|
||||
text: "已完成去水印处理。",
|
||||
status: "completed",
|
||||
});
|
||||
setActiveResultModeType("detail_replacement");
|
||||
setSelectedResultUrl(nextUrl);
|
||||
toast.success("去水印完成,已更新当前结果图");
|
||||
} catch (error) {
|
||||
console.error("[AgentGenerationWorkspaceDarkV2] 去水印失败:", error);
|
||||
toast.error(error instanceof Error ? error.message : "去水印失败,请稍后重试");
|
||||
} finally {
|
||||
setDetailWatermarkRemoveCardIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(cardId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
detailWatermarkRemoveCardIds,
|
||||
handleUpdateReplacementResultRecord,
|
||||
isAnyGenerationRunning,
|
||||
]
|
||||
);
|
||||
const getReplacementExecutionCards = useCallback(
|
||||
(params: {
|
||||
modeType: ReplacementModeType;
|
||||
@ -3941,6 +4013,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
|
||||
return;
|
||||
}
|
||||
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
|
||||
if (
|
||||
isDetailReplacementPreset &&
|
||||
detailReplacementStrategy !== "product_only" &&
|
||||
!String(resolvedWorkflowContext?.promptText || "").trim()
|
||||
) {
|
||||
toast.error("请先在「风格化提示词配置」里选择提示词组合,再执行详情场景替换");
|
||||
return;
|
||||
}
|
||||
const replacementModeType =
|
||||
options?.replacementModeType ||
|
||||
(isDetailReplacementPreset
|
||||
@ -4034,7 +4115,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined,
|
||||
});
|
||||
});
|
||||
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
|
||||
let textContent = String(options?.promptText || "").trim() || input.trim();
|
||||
if (!textContent && replacementModeType && replacementExecutionCards.length === 1) {
|
||||
textContent = "请重新生成当前这张替换结果,保持版式和主体一致。";
|
||||
@ -4124,6 +4204,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
activeBusinessModeKey,
|
||||
detailResultCards,
|
||||
detailReplacementReferenceCards.length,
|
||||
detailReplacementStrategy,
|
||||
handleRunAiDetailGeneration,
|
||||
currentResultModeType,
|
||||
getReplacementExecutionCards,
|
||||
@ -7048,6 +7129,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const statusMeta = getReplacementCardStatusMeta(card.status);
|
||||
const isPending = card.status === "generating" || card.status === "queued";
|
||||
const hasResult = Boolean(card.resultUrl) && !isPending;
|
||||
const isWatermarkRemoving = detailWatermarkRemoveCardIds.has(card.id);
|
||||
return (
|
||||
<div
|
||||
key={`${card.id}-result`}
|
||||
@ -7135,6 +7217,27 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
>
|
||||
重新生图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyGenerationRunning || isWatermarkRemoving}
|
||||
onClick={() =>
|
||||
void handleRemoveDetailResultWatermark({
|
||||
cardId: card.id,
|
||||
imageUrl: card.resultUrl!,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
IMAGE_OVERLAY_ACTION_CLASS,
|
||||
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
)}
|
||||
>
|
||||
{isWatermarkRemoving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Droplet className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{isWatermarkRemoving ? "去水印中" : "去水印"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1644,8 +1644,8 @@ function getAssignedImageMaskStyle(
|
||||
? layers.find((item) => item.id === layer.clipMaskLayerId)
|
||||
: null;
|
||||
const fallbackMaskUrl =
|
||||
layer.forceClipMask && clipMaskLayer
|
||||
? getPsdLayerPreviewSourceUrl(clipMaskLayer)
|
||||
layer.forceClipMask
|
||||
? getPsdLayerPreviewSourceUrl(clipMaskLayer || layer)
|
||||
: "";
|
||||
const normalizedMaskUrl = String(
|
||||
layer.maskUrl || fallbackMaskUrl || ""
|
||||
@ -2247,8 +2247,8 @@ export function AgentPsdTemplateEditorDialog({
|
||||
);
|
||||
const orderedUserManageablePreviewLayers = useMemo(
|
||||
() =>
|
||||
orderedPreviewLayers.filter((layer) => !isAgentPsdLayerSuperLocked(layer)),
|
||||
[orderedPreviewLayers]
|
||||
orderedTemplateLayers.filter((layer) => !isAgentPsdLayerSuperLocked(layer)),
|
||||
[orderedTemplateLayers]
|
||||
);
|
||||
const orderedEditableImageLayers = useMemo(
|
||||
() =>
|
||||
@ -3417,13 +3417,13 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}
|
||||
|
||||
if (direction === "front") {
|
||||
nextOrder.push(removed);
|
||||
} else if (direction === "back") {
|
||||
nextOrder.unshift(removed);
|
||||
} else if (direction === "back") {
|
||||
nextOrder.push(removed);
|
||||
} else if (direction === "forward") {
|
||||
nextOrder.splice(Math.min(baseOrder.length - 1, currentIndex + 1), 0, removed);
|
||||
} else {
|
||||
nextOrder.splice(Math.max(0, currentIndex - 1), 0, removed);
|
||||
} else {
|
||||
nextOrder.splice(Math.min(baseOrder.length - 1, currentIndex + 1), 0, removed);
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
|
||||
@ -71,8 +71,17 @@ const IMAGE_SIZE_OPTIONS = [
|
||||
{ value: "2K", label: "2K", disabled: false },
|
||||
{ value: "4K", label: "4K", disabled: false },
|
||||
] as const;
|
||||
type ImageSizeValue = (typeof IMAGE_SIZE_OPTIONS)[number]["value"];
|
||||
|
||||
const IMAGE_SIZE_PIXEL_MAP: Record<string, number> = {
|
||||
function normalizeImageSizeValue(value?: string, fallback: ImageSizeValue = "2K"): ImageSizeValue {
|
||||
const normalized = String(value || "").trim().toUpperCase();
|
||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
||||
return normalized;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE_PIXEL_MAP: Record<ImageSizeValue, number> = {
|
||||
"1K": 1024,
|
||||
"2K": 2048,
|
||||
"4K": 4096,
|
||||
@ -143,16 +152,16 @@ export function ImageParamsBar({
|
||||
const fallbackCount = options[0] ?? 1;
|
||||
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
|
||||
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
|
||||
let imageSize = raw.imageSize || "2K";
|
||||
let imageSize = normalizeImageSizeValue(raw.imageSize);
|
||||
if (imageGenConfig) {
|
||||
const sizeAllowed = (s: string) => {
|
||||
const sizeAllowed = (s: ImageSizeValue) => {
|
||||
if (s === "1K") return imageGenConfig.supports1K !== false;
|
||||
if (s === "2K") return imageGenConfig.supports2K !== false;
|
||||
if (s === "4K") return imageGenConfig.supports4K !== false;
|
||||
return true;
|
||||
};
|
||||
if (!sizeAllowed(imageSize)) {
|
||||
imageSize = ["2K", "1K", "4K"].find(sizeAllowed) || "2K";
|
||||
imageSize = (["2K", "1K", "4K"] as const).find(sizeAllowed) || "2K";
|
||||
}
|
||||
}
|
||||
return {
|
||||
@ -209,7 +218,7 @@ export function ImageParamsBar({
|
||||
return getGrsaiGptImageOutputSize(aspectRatio, imageSize);
|
||||
}
|
||||
|
||||
const size = IMAGE_SIZE_PIXEL_MAP[imageSize] || IMAGE_SIZE_PIXEL_MAP["2K"];
|
||||
const size = IMAGE_SIZE_PIXEL_MAP[normalizeImageSizeValue(imageSize)];
|
||||
if (aspectRatio === "auto" || aspectRatio === "1:1") {
|
||||
return { width: size, height: size };
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import "server-only";
|
||||
|
||||
import { upsertSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
AGENT_PSD_TEMPLATES_KEY,
|
||||
AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdTextLayerSlot,
|
||||
type AgentPsdTemplate,
|
||||
type AgentPsdTemplateLayerSlot,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
import {
|
||||
createAgentPsdTemplateParseHeartbeat,
|
||||
updateAgentPsdTemplateById,
|
||||
updateAgentPsdTemplateParseProgress,
|
||||
} from "@/lib/agents/psd-template-progress";
|
||||
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
|
||||
import {
|
||||
preparePsdTemplateRenderAssets,
|
||||
@ -51,28 +53,6 @@ function getErrorMessage(error: unknown) {
|
||||
return "PSD 后台资产补全失败";
|
||||
}
|
||||
|
||||
async function updateTemplateById(params: {
|
||||
templateId: string;
|
||||
updatedBy: string;
|
||||
updater: (template: AgentPsdTemplate) => AgentPsdTemplate;
|
||||
}) {
|
||||
const templates = await readAgentPsdTemplates();
|
||||
const currentTemplate = templates.find((entry) => entry.id === params.templateId);
|
||||
|
||||
if (!currentTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTemplate = params.updater(currentTemplate);
|
||||
await upsertSystemSetting({
|
||||
key: AGENT_PSD_TEMPLATES_KEY,
|
||||
value: templates.map((entry) => (entry.id === params.templateId ? nextTemplate : entry)),
|
||||
updatedBy: params.updatedBy,
|
||||
});
|
||||
|
||||
return nextTemplate;
|
||||
}
|
||||
|
||||
function mergePreparedLayerAssets(params: {
|
||||
currentLayers: AgentPsdTemplateLayerSlot[];
|
||||
preparedLayers: AgentPsdTemplateLayerSlot[];
|
||||
@ -152,79 +132,98 @@ async function backfillTemplateAssetsOnce(params: {
|
||||
throw new Error("PSD 模板尚未完成快速解析");
|
||||
}
|
||||
|
||||
await updateTemplateById({
|
||||
templateId: template.id,
|
||||
updatedBy: params.updatedBy,
|
||||
updater: (currentTemplate) => ({
|
||||
...currentTemplate,
|
||||
parseStatus: "processing",
|
||||
parseProgress: Math.max(62, Number(currentTemplate.parseProgress || 0)),
|
||||
parseStage: "快速解析完成,正在后台补全图层资源",
|
||||
parseError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const uploadAsset = await createStorageUploadSession();
|
||||
const preparedAssets = await preparePsdTemplateRenderAssets({
|
||||
templateId: template.id,
|
||||
fileUrl: template.fileUrl,
|
||||
filePath: resolveAgentPsdLocalCachePath({
|
||||
let latestProgress = {
|
||||
progress: Math.max(62, Number(template.parseProgress || 0)),
|
||||
stage: "快速解析完成,正在后台补全图层资源",
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
const setBackfillProgress = async (
|
||||
progress: number,
|
||||
stage: string,
|
||||
error?: string
|
||||
) => {
|
||||
latestProgress = {
|
||||
progress,
|
||||
stage,
|
||||
error,
|
||||
};
|
||||
await updateAgentPsdTemplateParseProgress({
|
||||
templateId: template.id,
|
||||
fileName: template.fileName,
|
||||
}),
|
||||
layers: template.layers,
|
||||
uploadAsset,
|
||||
});
|
||||
|
||||
await updateTemplateById({
|
||||
updatedBy: params.updatedBy,
|
||||
progress,
|
||||
stage,
|
||||
error,
|
||||
});
|
||||
};
|
||||
const heartbeat = createAgentPsdTemplateParseHeartbeat({
|
||||
templateId: template.id,
|
||||
updatedBy: params.updatedBy,
|
||||
updater: (currentTemplate) => ({
|
||||
...currentTemplate,
|
||||
parseStatus: "processing",
|
||||
parseProgress: Math.max(88, Number(currentTemplate.parseProgress || 0)),
|
||||
parseStage: "正在后台生成模板底图和快速导出分层",
|
||||
parseError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
getSnapshot: () => latestProgress,
|
||||
});
|
||||
|
||||
const uploadedRenderBase = await uploadAsset(
|
||||
`agent-psd-base-${template.id}-${Date.now()}.png`,
|
||||
toArrayBuffer(preparedAssets.renderBaseBuffer)
|
||||
);
|
||||
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
|
||||
templateId: template.id,
|
||||
segments: preparedAssets.renderSegments,
|
||||
uploadAsset,
|
||||
});
|
||||
await setBackfillProgress(latestProgress.progress, latestProgress.stage);
|
||||
|
||||
await updateTemplateById({
|
||||
templateId: template.id,
|
||||
updatedBy: params.updatedBy,
|
||||
updater: (currentTemplate) => {
|
||||
const mergedLayers = mergePreparedLayerAssets({
|
||||
currentLayers: currentTemplate.layers,
|
||||
preparedLayers: preparedAssets.layers,
|
||||
});
|
||||
try {
|
||||
const uploadAsset = await createStorageUploadSession();
|
||||
const preparedAssets = await preparePsdTemplateRenderAssets({
|
||||
templateId: template.id,
|
||||
fileUrl: template.fileUrl,
|
||||
filePath: resolveAgentPsdLocalCachePath({
|
||||
templateId: template.id,
|
||||
fileName: template.fileName,
|
||||
}),
|
||||
layers: template.layers,
|
||||
uploadAsset,
|
||||
onProgress: async (event) => {
|
||||
await setBackfillProgress(event.progress, event.stage);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...currentTemplate,
|
||||
renderBaseUrl: uploadedRenderBase.url,
|
||||
renderBasePath: uploadedRenderBase.pathname,
|
||||
renderSegments: uploadedRenderSegments,
|
||||
layers: mergedLayers,
|
||||
layerCount: mergedLayers.length,
|
||||
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
parseStatus: "completed",
|
||||
parseProgress: 100,
|
||||
parseStage: "快速解析和可用资产补全已完成",
|
||||
parseError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
await setBackfillProgress(88, "正在后台生成模板底图和快速导出分层");
|
||||
|
||||
const uploadedRenderBase = await uploadAsset(
|
||||
`agent-psd-base-${template.id}-${Date.now()}.png`,
|
||||
toArrayBuffer(preparedAssets.renderBaseBuffer)
|
||||
);
|
||||
await setBackfillProgress(96, "正在上传快速导出分层");
|
||||
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
|
||||
templateId: template.id,
|
||||
segments: preparedAssets.renderSegments,
|
||||
uploadAsset,
|
||||
});
|
||||
|
||||
heartbeat.stop();
|
||||
await updateAgentPsdTemplateById({
|
||||
templateId: template.id,
|
||||
updatedBy: params.updatedBy,
|
||||
updater: (currentTemplate) => {
|
||||
const now = new Date().toISOString();
|
||||
const mergedLayers = mergePreparedLayerAssets({
|
||||
currentLayers: currentTemplate.layers,
|
||||
preparedLayers: preparedAssets.layers,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentTemplate,
|
||||
renderBaseUrl: uploadedRenderBase.url,
|
||||
renderBasePath: uploadedRenderBase.pathname,
|
||||
renderSegments: uploadedRenderSegments,
|
||||
layers: mergedLayers,
|
||||
layerCount: mergedLayers.length,
|
||||
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
parseStatus: "completed",
|
||||
parseProgress: 100,
|
||||
parseStage: "快速解析和可用资产补全已完成",
|
||||
parseError: undefined,
|
||||
parseHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
heartbeat.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function schedulePsdTemplateAssetBackfill(params: {
|
||||
@ -253,7 +252,7 @@ export function schedulePsdTemplateAssetBackfill(params: {
|
||||
} while (jobState.rerun);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
await updateTemplateById({
|
||||
await updateAgentPsdTemplateById({
|
||||
templateId: params.templateId,
|
||||
updatedBy: params.updatedBy,
|
||||
updater: (currentTemplate) => ({
|
||||
|
||||
@ -105,6 +105,8 @@ export interface AgentPsdTemplate {
|
||||
parseProgress?: number;
|
||||
parseStage?: string;
|
||||
parseError?: string;
|
||||
parseStartedAt?: string;
|
||||
parseHeartbeatAt?: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
filePath?: string;
|
||||
@ -528,6 +530,8 @@ export function parseAgentPsdTemplates(value: unknown) {
|
||||
|
||||
const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus);
|
||||
const rawParseProgress = normalizePositiveInteger(record.parseProgress);
|
||||
const parseStartedAt = toIsoDateString(record.parseStartedAt) || undefined;
|
||||
const parseHeartbeatAt = toIsoDateString(record.parseHeartbeatAt) || undefined;
|
||||
|
||||
const categoryTags = normalizeAgentPsdTemplateCategoryTags(
|
||||
record.categoryTags ?? record.productCategoryTags
|
||||
@ -549,6 +553,8 @@ export function parseAgentPsdTemplates(value: unknown) {
|
||||
: Math.min(100, Math.max(0, rawParseProgress)),
|
||||
parseStage: normalizeText(record.parseStage) || undefined,
|
||||
parseError: normalizeText(record.parseError) || undefined,
|
||||
parseStartedAt,
|
||||
parseHeartbeatAt,
|
||||
fileName,
|
||||
fileUrl,
|
||||
filePath: normalizeText(record.filePath) || undefined,
|
||||
|
||||
@ -126,6 +126,26 @@ export interface PreparedPsdTemplateRenderSegment {
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface PsdTemplateParseProgressEvent {
|
||||
progress: number;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
type PsdTemplateParseProgressReporter = (
|
||||
event: PsdTemplateParseProgressEvent
|
||||
) => void | Promise<void>;
|
||||
|
||||
async function reportPsdTemplateParseProgress(
|
||||
reporter: PsdTemplateParseProgressReporter | undefined,
|
||||
event: PsdTemplateParseProgressEvent
|
||||
) {
|
||||
if (!reporter) {
|
||||
return;
|
||||
}
|
||||
|
||||
await reporter(event);
|
||||
}
|
||||
|
||||
function resolveVisibleContentBounds(params: {
|
||||
rgba: Uint8ClampedArray;
|
||||
width: number;
|
||||
@ -509,17 +529,44 @@ async function resolvePsdSourceBuffer(params: {
|
||||
}
|
||||
|
||||
export async function parsePsdTemplateFile(
|
||||
buffer: ArrayBuffer
|
||||
buffer: ArrayBuffer,
|
||||
options?: {
|
||||
onProgress?: PsdTemplateParseProgressReporter;
|
||||
}
|
||||
): Promise<ParsedPsdTemplatePayload> {
|
||||
await reportPsdTemplateParseProgress(options?.onProgress, {
|
||||
progress: 26,
|
||||
stage: "正在加载 PSD 解析引擎",
|
||||
});
|
||||
const psd = await parsePsdBuffer(buffer);
|
||||
const width = Math.max(1, Math.round(Number(psd.width || 0)));
|
||||
const height = Math.max(1, Math.round(Number(psd.height || 0)));
|
||||
|
||||
await reportPsdTemplateParseProgress(options?.onProgress, {
|
||||
progress: 30,
|
||||
stage: "正在合成 PSD 整体预览",
|
||||
});
|
||||
const previewRgba = await psd.composite();
|
||||
|
||||
await reportPsdTemplateParseProgress(options?.onProgress, {
|
||||
progress: 35,
|
||||
stage: "正在生成 PSD 预览图",
|
||||
});
|
||||
const previewBuffer = await rgbaToPngBuffer(previewRgba, width, height);
|
||||
|
||||
await reportPsdTemplateParseProgress(options?.onProgress, {
|
||||
progress: 39,
|
||||
stage: "正在扫描可配置图层",
|
||||
});
|
||||
const layers = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []).map(
|
||||
(entry) => entry.layer
|
||||
);
|
||||
|
||||
await reportPsdTemplateParseProgress(options?.onProgress, {
|
||||
progress: 41,
|
||||
stage: `已识别 ${layers.length} 个可配置图层`,
|
||||
});
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@ -705,11 +752,17 @@ export async function uploadPreparedPsdTemplateRenderSegments(params: {
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
mapper: (item: T, index: number) => Promise<R>,
|
||||
onItemComplete?: (params: {
|
||||
completed: number;
|
||||
total: number;
|
||||
index: number;
|
||||
}) => void | Promise<void>
|
||||
) {
|
||||
const results = new Array<R>(items.length);
|
||||
const workerCount = Math.max(1, Math.min(Math.round(concurrency), items.length || 1));
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
@ -717,6 +770,12 @@ async function mapWithConcurrency<T, R>(
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
completed += 1;
|
||||
await onItemComplete?.({
|
||||
completed,
|
||||
total: items.length,
|
||||
index: currentIndex,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
@ -752,16 +811,27 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
assetProfile?: PsdTemplateLayerAssetProfile;
|
||||
assetConcurrency?: number;
|
||||
uploadAsset?: StorageUploadFunction;
|
||||
onProgress?: PsdTemplateParseProgressReporter;
|
||||
}) {
|
||||
const assetProfile = params.assetProfile || "fast-runtime";
|
||||
const assetConcurrency =
|
||||
params.assetConcurrency || DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY;
|
||||
const uploadAsset = params.uploadAsset || uploadFile;
|
||||
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 63,
|
||||
stage: "正在读取 PSD 源文件并准备补全资产",
|
||||
});
|
||||
const psdBuffer = await resolvePsdSourceBuffer({
|
||||
fileUrl: params.fileUrl,
|
||||
filePath: params.filePath,
|
||||
fileBuffer: params.fileBuffer,
|
||||
});
|
||||
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 65,
|
||||
stage: "正在解析 PSD 图层资源",
|
||||
});
|
||||
const psd = await parsePsdBuffer(psdBuffer);
|
||||
const canvasWidth = Math.max(1, Math.round(Number(psd.width || 0)));
|
||||
const canvasHeight = Math.max(1, Math.round(Number(psd.height || 0)));
|
||||
@ -778,6 +848,11 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
|
||||
const nextLayers = [...params.layers];
|
||||
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 66,
|
||||
stage: `正在补全图层缩略图和源图 0/${nextLayers.length}`,
|
||||
});
|
||||
|
||||
const preparedLayerResults = await mapWithConcurrency(
|
||||
nextLayers,
|
||||
assetConcurrency,
|
||||
@ -934,6 +1009,17 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
}
|
||||
|
||||
return preparedLayer;
|
||||
},
|
||||
async ({ completed, total }) => {
|
||||
if (completed !== total && completed % 4 !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = 66 + Math.round((completed / Math.max(1, total)) * 12);
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress,
|
||||
stage: `正在补全图层缩略图和源图 ${completed}/${total}`,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@ -945,6 +1031,11 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
.reverse()
|
||||
.filter((entry) => entry.node && typeof entry.node.composite === "function");
|
||||
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 79,
|
||||
stage: `正在生成模板底图图层 0/${drawableEntries.length}`,
|
||||
});
|
||||
|
||||
const layerCompositeResults = await mapWithConcurrency(
|
||||
drawableEntries,
|
||||
assetConcurrency,
|
||||
@ -996,6 +1087,17 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async ({ completed, total }) => {
|
||||
if (completed !== total && completed % 4 !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = 79 + Math.round((completed / Math.max(1, total)) * 9);
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress,
|
||||
stage: `正在生成模板底图图层 ${completed}/${total}`,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@ -1025,6 +1127,11 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
.map((result) => result.input);
|
||||
|
||||
const sharp = await getSharp();
|
||||
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 89,
|
||||
stage: "正在合成模板底图",
|
||||
});
|
||||
const transparentBaseBuffer = await createTransparentCanvasBuffer(
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
@ -1042,6 +1149,10 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
for (const result of sortedLayerCompositeResults) {
|
||||
if (result.editableBoundary) {
|
||||
if (pendingSegmentInputs.length > 0) {
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 92,
|
||||
stage: "正在生成快速导出分层",
|
||||
});
|
||||
renderSegments.push({
|
||||
id: `before-${sanitizeFileSegment(
|
||||
result.beforeLayerId,
|
||||
@ -1061,6 +1172,10 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
}
|
||||
|
||||
if (pendingSegmentInputs.length > 0) {
|
||||
await reportPsdTemplateParseProgress(params.onProgress, {
|
||||
progress: 94,
|
||||
stage: "正在生成顶部快速导出分层",
|
||||
});
|
||||
renderSegments.push({
|
||||
id: "above-editable-layers",
|
||||
buffer: await composeSegmentBuffer(pendingSegmentInputs),
|
||||
|
||||
127
lib/agents/psd-template-progress.ts
Normal file
127
lib/agents/psd-template-progress.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import "server-only";
|
||||
|
||||
import { upsertSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
AGENT_PSD_TEMPLATES_KEY,
|
||||
type AgentPsdTemplate,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||
|
||||
const DEFAULT_PARSE_HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(99, Math.round(Number(value || 0))));
|
||||
}
|
||||
|
||||
export async function updateAgentPsdTemplateById(params: {
|
||||
templateId: string;
|
||||
updatedBy: string;
|
||||
updater: (template: AgentPsdTemplate) => AgentPsdTemplate;
|
||||
}) {
|
||||
const templates = await readAgentPsdTemplates();
|
||||
const currentTemplate = templates.find((entry) => entry.id === params.templateId);
|
||||
|
||||
if (!currentTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTemplate = params.updater(currentTemplate);
|
||||
await upsertSystemSetting({
|
||||
key: AGENT_PSD_TEMPLATES_KEY,
|
||||
value: templates.map((entry) =>
|
||||
entry.id === params.templateId ? nextTemplate : entry
|
||||
),
|
||||
updatedBy: params.updatedBy,
|
||||
});
|
||||
|
||||
return nextTemplate;
|
||||
}
|
||||
|
||||
export async function updateAgentPsdTemplateParseProgress(params: {
|
||||
templateId: string;
|
||||
updatedBy: string;
|
||||
progress: number;
|
||||
stage: string;
|
||||
error?: string;
|
||||
restart?: boolean;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return updateAgentPsdTemplateById({
|
||||
templateId: params.templateId,
|
||||
updatedBy: params.updatedBy,
|
||||
updater: (template) => {
|
||||
if (!params.restart && template.parseStatus !== "processing") {
|
||||
return template;
|
||||
}
|
||||
|
||||
return {
|
||||
...template,
|
||||
parseStatus: "processing",
|
||||
parseProgress: clampProgress(params.progress),
|
||||
parseStage: params.stage,
|
||||
parseError: params.error,
|
||||
parseStartedAt:
|
||||
params.restart || !template.parseStartedAt
|
||||
? now
|
||||
: template.parseStartedAt,
|
||||
parseHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createAgentPsdTemplateParseHeartbeat(params: {
|
||||
templateId: string;
|
||||
updatedBy: string;
|
||||
getSnapshot: () => {
|
||||
progress: number;
|
||||
stage: string;
|
||||
error?: string;
|
||||
};
|
||||
intervalMs?: number;
|
||||
}) {
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
let failedBeats = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (stopped || inFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = true;
|
||||
const snapshot = params.getSnapshot();
|
||||
void updateAgentPsdTemplateParseProgress({
|
||||
templateId: params.templateId,
|
||||
updatedBy: params.updatedBy,
|
||||
progress: snapshot.progress,
|
||||
stage: snapshot.stage,
|
||||
error: snapshot.error,
|
||||
})
|
||||
.then(() => {
|
||||
failedBeats = 0;
|
||||
})
|
||||
.catch((error) => {
|
||||
failedBeats += 1;
|
||||
if (failedBeats <= 3) {
|
||||
console.warn("[PSD Template] parse heartbeat update failed:", {
|
||||
templateId: params.templateId,
|
||||
failedBeats,
|
||||
error,
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
});
|
||||
}, params.intervalMs || DEFAULT_PARSE_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(interval);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -602,6 +602,12 @@ export async function renderAgentPsdTemplate(params: {
|
||||
"PSD 剪切蒙版目标资源",
|
||||
true
|
||||
);
|
||||
} else if (layer.forceClipMask) {
|
||||
pushAsset(
|
||||
getStoredLayerSourceUrl(layer),
|
||||
"PSD 自身剪切蒙版资源",
|
||||
true
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pushAsset(
|
||||
@ -938,6 +944,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
return;
|
||||
}
|
||||
const renderFrameLayer = clipMaskLayer || layer;
|
||||
const maskSourceLayer = clipMaskLayer || (layer.forceClipMask ? layer : null);
|
||||
const renderFrameContentBounds =
|
||||
clipMaskLayer && isAgentPsdImageLayerSlot(clipMaskLayer)
|
||||
? clipMaskLayer.contentBounds
|
||||
@ -990,7 +997,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
}
|
||||
|
||||
const layerPositionOffset = getLayerPositionOffset(renderFrameLayer.id);
|
||||
const shouldApplyMask = Boolean(layer.maskUrl || clipMaskLayer);
|
||||
const shouldApplyMask = Boolean(layer.maskUrl || maskSourceLayer);
|
||||
if (!shouldApplyMask) {
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
@ -1025,7 +1032,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
try {
|
||||
const rawMaskBuffer = layer.maskUrl
|
||||
? await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
|
||||
: await readReusableLayerBuffer(clipMaskLayer!);
|
||||
: await readReusableLayerBuffer(maskSourceLayer!);
|
||||
if (!rawMaskBuffer) {
|
||||
throw new Error("Missing PSD layer mask source");
|
||||
}
|
||||
|
||||
@ -20,15 +20,42 @@ export const AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS = getEnvPositiveInt(
|
||||
"AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS",
|
||||
20 * 60 * 1000
|
||||
);
|
||||
export const AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS = getEnvPositiveInt(
|
||||
"AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS",
|
||||
45 * 60 * 1000
|
||||
);
|
||||
|
||||
function getTemplateUpdatedAtTime(template: AgentPsdTemplate) {
|
||||
const updatedAt = new Date(template.updatedAt).getTime();
|
||||
if (Number.isFinite(updatedAt)) {
|
||||
function getIsoTime(value?: string) {
|
||||
const time = new Date(value || "").getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function getTemplateHeartbeatTime(template: AgentPsdTemplate) {
|
||||
const heartbeatAt = getIsoTime(template.parseHeartbeatAt);
|
||||
if (heartbeatAt) {
|
||||
return heartbeatAt;
|
||||
}
|
||||
|
||||
const updatedAt = getIsoTime(template.updatedAt);
|
||||
if (updatedAt) {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
const createdAt = new Date(template.createdAt).getTime();
|
||||
return Number.isFinite(createdAt) ? createdAt : null;
|
||||
return getIsoTime(template.createdAt);
|
||||
}
|
||||
|
||||
function getTemplateParseStartedAtTime(template: AgentPsdTemplate) {
|
||||
const startedAt = getIsoTime(template.parseStartedAt);
|
||||
if (startedAt) {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
const updatedAt = getIsoTime(template.updatedAt);
|
||||
if (updatedAt) {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
return getIsoTime(template.createdAt);
|
||||
}
|
||||
|
||||
function hasPassedFastParsePhase(template: AgentPsdTemplate) {
|
||||
@ -49,12 +76,24 @@ export function getAgentPsdTemplateParseAgeMs(
|
||||
template: AgentPsdTemplate,
|
||||
now = Date.now()
|
||||
) {
|
||||
const updatedAt = getTemplateUpdatedAtTime(template);
|
||||
if (!updatedAt) {
|
||||
const heartbeatAt = getTemplateHeartbeatTime(template);
|
||||
if (!heartbeatAt) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return Math.max(0, now - updatedAt);
|
||||
return Math.max(0, now - heartbeatAt);
|
||||
}
|
||||
|
||||
export function getAgentPsdTemplateParseDurationMs(
|
||||
template: AgentPsdTemplate,
|
||||
now = Date.now()
|
||||
) {
|
||||
const startedAt = getTemplateParseStartedAtTime(template);
|
||||
if (!startedAt) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return Math.max(0, now - startedAt);
|
||||
}
|
||||
|
||||
export function isAgentPsdTemplateParseTimedOut(
|
||||
@ -67,20 +106,32 @@ export function isAgentPsdTemplateParseTimedOut(
|
||||
|
||||
return (
|
||||
getAgentPsdTemplateParseAgeMs(template, now) >
|
||||
getAgentPsdTemplateParseTimeoutMs(template)
|
||||
getAgentPsdTemplateParseTimeoutMs(template) ||
|
||||
getAgentPsdTemplateParseDurationMs(template, now) >
|
||||
AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
function buildTimeoutMessage(template: AgentPsdTemplate) {
|
||||
const timeoutMinutes = Math.round(
|
||||
getAgentPsdTemplateParseTimeoutMs(template) / 60_000
|
||||
);
|
||||
function buildTimeoutMessage(template: AgentPsdTemplate, now = Date.now()) {
|
||||
const phase = hasPassedFastParsePhase(template)
|
||||
? "后台图层资产补全"
|
||||
: "PSD 快速解析";
|
||||
const stage = template.parseStage ? `,最后阶段:${template.parseStage}` : "";
|
||||
|
||||
return `${phase}超过 ${timeoutMinutes} 分钟未更新进度,系统已自动结束本次任务${stage}。可检查 PSD 文件后重新解析。`;
|
||||
if (
|
||||
getAgentPsdTemplateParseDurationMs(template, now) >
|
||||
AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS
|
||||
) {
|
||||
const timeoutMinutes = Math.round(
|
||||
AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS / 60_000
|
||||
);
|
||||
return `${phase}总耗时超过 ${timeoutMinutes} 分钟,系统已自动结束本次任务${stage}。请检查 PSD 是否过大、图层是否异常,或重新上传后再解析。`;
|
||||
}
|
||||
|
||||
const timeoutMinutes = Math.round(
|
||||
getAgentPsdTemplateParseTimeoutMs(template) / 60_000
|
||||
);
|
||||
return `${phase}超过 ${timeoutMinutes} 分钟没有收到解析心跳,系统已自动结束本次任务${stage}。可检查 PSD 文件后重新解析。`;
|
||||
}
|
||||
|
||||
export function failTimedOutAgentPsdTemplatesInMemory(params: {
|
||||
@ -107,7 +158,8 @@ export function failTimedOutAgentPsdTemplatesInMemory(params: {
|
||||
parseStage: hasPassedFastParsePhase(template)
|
||||
? "后台图层资产补全超时"
|
||||
: "PSD 快速解析超时",
|
||||
parseError: buildTimeoutMessage(template),
|
||||
parseError: buildTimeoutMessage(template, now.getTime()),
|
||||
parseHeartbeatAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
};
|
||||
timedOutTemplates.push(nextTemplate);
|
||||
|
||||
@ -15,29 +15,29 @@ interface RuxaImageParams {
|
||||
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
|
||||
}
|
||||
|
||||
interface RuxaImageModelConfig {
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
}
|
||||
|
||||
interface UploadedImage {
|
||||
thumbnail: string;
|
||||
original: string;
|
||||
}
|
||||
|
||||
const DEFAULT_RESOLUTION = "1k";
|
||||
const DEFAULT_RESOLUTION = "1K";
|
||||
const DEFAULT_OUTPUT_FORMAT = "png";
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_MAX_ATTEMPTS = 90;
|
||||
const RUXA_NANO_BANANA_2_DOC_MODEL_ID = "gemini-3.1-flash-image-preview";
|
||||
const RUXA_MODEL_ID_ALIASES: Record<string, string> = {
|
||||
"google/nano-banana-2": RUXA_NANO_BANANA_2_DOC_MODEL_ID,
|
||||
};
|
||||
const POLL_MAX_ATTEMPTS = 240;
|
||||
|
||||
function normalizeRuxaModelId(modelId?: string) {
|
||||
const raw = String(modelId || "").trim();
|
||||
if (!raw) return "gpt-image-1.5";
|
||||
return RUXA_MODEL_ID_ALIASES[raw] || raw;
|
||||
}
|
||||
|
||||
function isRuxaNanoBanana2Model(modelId?: string) {
|
||||
const normalized = normalizeRuxaModelId(modelId);
|
||||
return normalized === RUXA_NANO_BANANA_2_DOC_MODEL_ID;
|
||||
if (!raw) {
|
||||
throw new Error("RUXA 模型 ID 未配置");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeUrlForCompare(url: string) {
|
||||
@ -68,7 +68,9 @@ function normalizeAspectRatio(aspectRatio?: string) {
|
||||
const normalized = aspectRatio.trim().toLowerCase();
|
||||
if (normalized === "auto") return undefined;
|
||||
const ratio = aspectRatio.trim();
|
||||
if (["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2"].includes(ratio)) {
|
||||
if (
|
||||
["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2", "5:4", "4:5", "21:9"].includes(ratio)
|
||||
) {
|
||||
return ratio;
|
||||
}
|
||||
return undefined;
|
||||
@ -76,25 +78,65 @@ function normalizeAspectRatio(aspectRatio?: string) {
|
||||
|
||||
function normalizeResolution(imageSize?: string) {
|
||||
if (!imageSize) return DEFAULT_RESOLUTION;
|
||||
const normalized = imageSize.trim().toLowerCase();
|
||||
if (["1k", "2k", "4k"].includes(normalized)) return normalized;
|
||||
const normalized = imageSize.trim().toUpperCase();
|
||||
if (["1K", "2K", "4K"].includes(normalized)) return normalized;
|
||||
return DEFAULT_RESOLUTION;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(outputFormat?: string): "png" | "jpg" | "webp" {
|
||||
function normalizeOutputFormat(outputFormat?: string): "png" | "jpg" {
|
||||
if (!outputFormat) return DEFAULT_OUTPUT_FORMAT;
|
||||
const normalized = outputFormat.trim().toLowerCase();
|
||||
if (normalized === "jpg" || normalized === "jpeg") return "jpg";
|
||||
if (normalized === "webp") return "webp";
|
||||
return "png";
|
||||
}
|
||||
|
||||
function normalizeRuxaOutputFormat(modelId: string | undefined, outputFormat?: string) {
|
||||
const normalized = normalizeOutputFormat(outputFormat);
|
||||
if (!isRuxaNanoBanana2Model(modelId)) {
|
||||
return normalized;
|
||||
function isResolutionSupported(resolution: string, config?: RuxaImageModelConfig | null) {
|
||||
if (config?.supportsSize === false) {
|
||||
return false;
|
||||
}
|
||||
return normalized === "jpg" ? "jpeg" : normalized;
|
||||
|
||||
const normalized = resolution.toLowerCase();
|
||||
if (normalized === "1k") return config?.supports1K !== false;
|
||||
if (normalized === "2k") return config?.supports2K !== false;
|
||||
if (normalized === "4k") return config?.supports4K !== false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRuxaTaskInput({
|
||||
prompt,
|
||||
imageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
outputFormat,
|
||||
modelConfig,
|
||||
}: {
|
||||
prompt: string;
|
||||
imageUrls: string[];
|
||||
aspectRatio?: string;
|
||||
resolution: string;
|
||||
outputFormat: string;
|
||||
modelConfig?: RuxaImageModelConfig | null;
|
||||
}) {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
output_format: outputFormat,
|
||||
};
|
||||
const ratio = aspectRatio || "1:1";
|
||||
|
||||
input.size = ratio;
|
||||
input.aspect_ratio = ratio;
|
||||
if (isResolutionSupported(resolution, modelConfig)) {
|
||||
input.resolution = resolution;
|
||||
}
|
||||
|
||||
if (imageUrls[0]) {
|
||||
input.image = imageUrls[0];
|
||||
}
|
||||
if (imageUrls.length > 0) {
|
||||
input.image_input = imageUrls.slice(0, 8);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function extractTaskId(payload: any): string | undefined {
|
||||
@ -132,9 +174,16 @@ function extractStatus(payload: any): string | undefined {
|
||||
|
||||
function extractErrorMessage(payload: any): string {
|
||||
let parsedResultJson: any = null;
|
||||
if (typeof payload?.resultJson === "string" && payload.resultJson.trim()) {
|
||||
const rawResultJson =
|
||||
typeof payload?.resultJson === "string" && payload.resultJson.trim()
|
||||
? payload.resultJson
|
||||
: typeof payload?.data?.resultJson === "string" && payload.data.resultJson.trim()
|
||||
? payload.data.resultJson
|
||||
: "";
|
||||
|
||||
if (rawResultJson) {
|
||||
try {
|
||||
parsedResultJson = JSON.parse(payload.resultJson);
|
||||
parsedResultJson = JSON.parse(rawResultJson);
|
||||
} catch {
|
||||
parsedResultJson = null;
|
||||
}
|
||||
@ -148,7 +197,7 @@ function extractErrorMessage(payload: any): string {
|
||||
payload?.errorMessage ||
|
||||
parsedResultJson?.failMsg ||
|
||||
parsedResultJson?.error ||
|
||||
payload?.resultJson ||
|
||||
rawResultJson ||
|
||||
payload?.data?.message ||
|
||||
payload?.data?.error ||
|
||||
"RUXA 任务执行失败"
|
||||
@ -388,48 +437,38 @@ export function createRuxaImage({ apiKey, baseURL }: RuxaImageConfig) {
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
imageParams?: RuxaImageParams;
|
||||
imageGenConfig?: RuxaImageModelConfig | null;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||
const aspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
||||
const resolution = normalizeResolution(options.imageParams?.imageSize);
|
||||
const requestedModelId = options.overrideModelId || modelId || "gpt-image-1.5";
|
||||
const requestedModelId = options.overrideModelId || modelId;
|
||||
const activeModelId = normalizeRuxaModelId(requestedModelId);
|
||||
const outputFormat = normalizeRuxaOutputFormat(activeModelId, options.imageParams?.outputFormat);
|
||||
const isNanoBanana2 = isRuxaNanoBanana2Model(activeModelId);
|
||||
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
|
||||
|
||||
const createEndpoint = joinApiPath(baseURL, "/api/v1/tasks/create");
|
||||
const callbackUrl = process.env.RUXA_CALLBACK_URL?.trim();
|
||||
const input: Record<string, unknown> = {
|
||||
const input = buildRuxaTaskInput({
|
||||
prompt,
|
||||
output_format: outputFormat,
|
||||
};
|
||||
if (isNanoBanana2) {
|
||||
input.size = aspectRatio || "1:1";
|
||||
} else {
|
||||
input.resolution = resolution;
|
||||
}
|
||||
if (aspectRatio && !isNanoBanana2) {
|
||||
input.size = aspectRatio;
|
||||
input.aspect_ratio = aspectRatio;
|
||||
}
|
||||
if (imageUrls[0]) {
|
||||
input.image = imageUrls[0];
|
||||
}
|
||||
if (imageUrls.length > 1) {
|
||||
input.image_input = imageUrls;
|
||||
}
|
||||
imageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
outputFormat,
|
||||
modelConfig: options.imageGenConfig,
|
||||
});
|
||||
const requestBody = {
|
||||
input,
|
||||
model: activeModelId,
|
||||
...(callbackUrl ? { callBackUrl: callbackUrl } : {}),
|
||||
...(callbackUrl ? { callback_url: callbackUrl } : {}),
|
||||
};
|
||||
if (activeModelId !== requestedModelId) {
|
||||
console.log("[Ruxa Image] Remapped modelId for provider compatibility:", {
|
||||
requestedModelId,
|
||||
activeModelId,
|
||||
});
|
||||
}
|
||||
console.log("[Ruxa Image] Creating task:", {
|
||||
model: activeModelId,
|
||||
resolution: input.resolution || null,
|
||||
aspectRatio: input.aspect_ratio || input.size || null,
|
||||
outputFormat,
|
||||
imageInputCount: imageUrls.length,
|
||||
});
|
||||
const createResponse = await fetch(createEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@ -494,6 +533,7 @@ export function createRuxaImage({ apiKey, baseURL }: RuxaImageConfig) {
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
imageParams?: RuxaImageParams;
|
||||
imageGenConfig?: RuxaImageModelConfig | null;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
|
||||
@ -587,6 +587,7 @@ export async function generateCanvasImage(
|
||||
...imageParams,
|
||||
imageCount: batchImageCount,
|
||||
},
|
||||
imageGenConfig: selectedModel.model.imageGenConfig || null,
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@ const MAX_STRING_LENGTH = 240;
|
||||
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/(authorization|cookie|password|secret|token|apikey|api_key|accesskey|access_key|clientsecret|client_secret|credential|prompt|session|csrf|code)/i;
|
||||
const URL_KEY_PATTERN = /(url|uri|endpoint|origin|href|src|image|thumbnail|avatar|file)/i;
|
||||
const URL_KEY_PATTERN = /(url|uri|endpoint|origin|href|src|thumbnail|avatar|file)/i;
|
||||
const DATA_URL_PATTERN = /^data:[^;]+;base64,/i;
|
||||
const EMBEDDED_DATA_URL_PATTERN = /data:[^;,\s]+;base64,[A-Za-z0-9+/=_-]+/gi;
|
||||
const EMBEDDED_URL_PATTERN = /https?:\/\/[^\s"'<>),]+/gi;
|
||||
|
||||
@ -122,7 +122,10 @@ interface CanvasStore {
|
||||
|
||||
// 节点操作
|
||||
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
|
||||
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
||||
addNodesBulk: (
|
||||
nodes: Array<Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>>
|
||||
) => string[];
|
||||
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
||||
deleteNode: (id: string) => void;
|
||||
deleteSelectedNodes: () => void;
|
||||
addSuppressedMediaKeys: (keys: string[]) => void;
|
||||
@ -230,82 +233,97 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
|
||||
// 节点操作
|
||||
addNode: (nodeData) => {
|
||||
const imageMetadata =
|
||||
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
|
||||
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
|
||||
: {};
|
||||
const shouldPreserveImagePosition =
|
||||
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
|
||||
const imageMetadata =
|
||||
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
|
||||
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
|
||||
: {};
|
||||
const shouldPreserveImagePosition =
|
||||
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
|
||||
|
||||
const resolveImagePosition = (
|
||||
basePosition: Position,
|
||||
size: Size,
|
||||
nodes: CanvasNode[]
|
||||
): Position => {
|
||||
if (nodeData.type !== 'image' || shouldPreserveImagePosition) return basePosition;
|
||||
const padding = 20;
|
||||
const width = size?.width || 300;
|
||||
const height = size?.height || 300;
|
||||
const stepX = width + padding;
|
||||
const stepY = height + padding;
|
||||
//通用避让算法:对所有节点类型生效(除 image 的 placementMode兼容情况外)
|
||||
// 历史原因:早期只对 image 类型做避让,导致 world3d/model3d 等重复添加会叠到一起。
|
||||
// 现在统一避让,image 的 allowOverlap/overlay 元数据仍可跳过避让以保持向后兼容。
|
||||
const resolveAutoPosition = (
|
||||
basePosition: Position,
|
||||
size: Size,
|
||||
nodes: CanvasNode[],
|
||||
nodeType: NodeType
|
||||
): Position => {
|
||||
// image 类型若标记了"允许重叠",保持原坐标以兼容既有行为
|
||||
if (nodeType === 'image' && shouldPreserveImagePosition) return basePosition;
|
||||
const padding =20;
|
||||
const width = size?.width ||300;
|
||||
const height = size?.height ||300;
|
||||
const stepX = width + padding;
|
||||
const stepY = height + padding;
|
||||
|
||||
const overlaps = (pos: Position) => nodes.some(n => {
|
||||
if (n.type !== 'image') return false;
|
||||
const nSize = n.size || { width: 300, height: 300 };
|
||||
return !(
|
||||
pos.x + width + padding <= n.position.x ||
|
||||
pos.x >= n.position.x + nSize.width + padding ||
|
||||
pos.y + height + padding <= n.position.y ||
|
||||
pos.y >= n.position.y + nSize.height + padding
|
||||
);
|
||||
});
|
||||
const overlaps = (pos: Position) => nodes.some(n => {
|
||||
if (n.type !== nodeType) return false;
|
||||
const nSize = n.size || { width:300, height:300 };
|
||||
return !(
|
||||
pos.x + width + padding <= n.position.x ||
|
||||
pos.x >= n.position.x + nSize.width + padding ||
|
||||
pos.y + height + padding <= n.position.y ||
|
||||
pos.y >= n.position.y + nSize.height + padding
|
||||
);
|
||||
});
|
||||
|
||||
if (!overlaps(basePosition)) return basePosition;
|
||||
if (!overlaps(basePosition)) return basePosition;
|
||||
|
||||
const maxRings = 20;
|
||||
for (let ring = 1; ring <= maxRings; ring++) {
|
||||
for (let dx = -ring; dx <= ring; dx++) {
|
||||
for (let dy = -ring; dy <= ring; dy++) {
|
||||
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
||||
const candidate = {
|
||||
x: basePosition.x + dx * stepX,
|
||||
y: basePosition.y + dy * stepY,
|
||||
};
|
||||
if (!overlaps(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const maxRings =20;
|
||||
for (let ring =1; ring <= maxRings; ring++) {
|
||||
for (let dx = -ring; dx <= ring; dx++) {
|
||||
for (let dy = -ring; dy <= ring; dy++) {
|
||||
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
||||
const candidate = {
|
||||
x: basePosition.x + dx * stepX,
|
||||
y: basePosition.y + dy * stepY,
|
||||
};
|
||||
if (!overlaps(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return basePosition;
|
||||
};
|
||||
return basePosition;
|
||||
};
|
||||
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const basePosition = nodeData.position || { x: 0, y: 0 };
|
||||
const nodeSize = nodeData.size || { width: 300, height: 300 };
|
||||
const finalPosition = resolveImagePosition(basePosition, nodeSize, get().nodes);
|
||||
const node: CanvasNode = {
|
||||
...nodeData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
position: finalPosition,
|
||||
};
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const basePosition = nodeData.position || { x:0, y:0 };
|
||||
const nodeSize = nodeData.size || { width:300, height:300 };
|
||||
const finalPosition = resolveAutoPosition(basePosition, nodeSize, get().nodes, nodeData.type);
|
||||
const node: CanvasNode = {
|
||||
...nodeData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
position: finalPosition,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
state.nodes.push(node);
|
||||
if (node.type === 'image') {
|
||||
state.lastAddedNodeId = id;
|
||||
}
|
||||
});
|
||||
set((state) => {
|
||||
state.nodes.push(node);
|
||||
if (node.type === 'image') {
|
||||
state.lastAddedNodeId = id;
|
||||
}
|
||||
});
|
||||
|
||||
// 添加到历史
|
||||
get().pushHistory();
|
||||
// 添加到历史
|
||||
get().pushHistory();
|
||||
|
||||
return id;
|
||||
},
|
||||
return id;
|
||||
},
|
||||
|
||||
//批量添加节点:循环调用 addNode 以复用其避让逻辑,
|
||||
//适用于从历史列表中多选世界/模型添加到画布等场景。
|
||||
addNodesBulk: (nodesData) => {
|
||||
const ids: string[] = [];
|
||||
for (const nodeData of nodesData) {
|
||||
ids.push(get().addNode(nodeData));
|
||||
}
|
||||
return ids;
|
||||
},
|
||||
|
||||
updateNode: (id, updates) => set((state) => {
|
||||
const index = state.nodes.findIndex(n => n.id === id);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user