Compare commits
12 Commits
main
...
codex/main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e62962b8 | ||
|
|
afd1f84789 | ||
|
|
c9fe549e86 | ||
|
|
fdb5304111 | ||
|
|
3ca5859e26 | ||
|
|
c825618398 | ||
|
|
528968d74d | ||
|
|
5b328355ea | ||
|
|
2b82d57f73 | ||
|
|
21209894aa | ||
|
|
2090dbd582 | ||
|
|
456c498c32 |
@ -192,6 +192,8 @@ import {
|
|||||||
type CanvasToolbarConfig,
|
type CanvasToolbarConfig,
|
||||||
type CanvasToolbarItemKey,
|
type CanvasToolbarItemKey,
|
||||||
DEFAULT_CANVAS_TOOLBAR_ITEMS,
|
DEFAULT_CANVAS_TOOLBAR_ITEMS,
|
||||||
|
MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
|
MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
defaultCanvasToolbarConfig,
|
defaultCanvasToolbarConfig,
|
||||||
getDefaultCanvasToolbarItem,
|
getDefaultCanvasToolbarItem,
|
||||||
normalizeCanvasToolbarConfig,
|
normalizeCanvasToolbarConfig,
|
||||||
@ -3691,6 +3693,7 @@ export function AdminSettingsClient() {
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
canvasToolbarConfig: {
|
canvasToolbarConfig: {
|
||||||
|
...normalized,
|
||||||
items: {
|
items: {
|
||||||
...normalized.items,
|
...normalized.items,
|
||||||
[key]: {
|
[key]: {
|
||||||
@ -12468,6 +12471,54 @@ export function AdminSettingsClient() {
|
|||||||
恢复全部默认
|
恢复全部默认
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border bg-background/60 p-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
className="font-medium text-sm"
|
||||||
|
htmlFor="selectedImageInlineActionCount"
|
||||||
|
>
|
||||||
|
选中图片一级按钮数量
|
||||||
|
</Label>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
控制画布中选中图片后,快捷弹窗主条直接显示几个功能按钮;超出的功能自动收进“更多”菜单。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="w-28"
|
||||||
|
id="selectedImageInlineActionCount"
|
||||||
|
max={MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT}
|
||||||
|
min={MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT}
|
||||||
|
onChange={(event) => {
|
||||||
|
const normalized =
|
||||||
|
normalizeCanvasToolbarConfig(
|
||||||
|
siteSettings.canvasToolbarConfig
|
||||||
|
);
|
||||||
|
const parsed = Number(event.target.value);
|
||||||
|
const nextValue = Number.isFinite(parsed)
|
||||||
|
? Math.min(
|
||||||
|
MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
|
Math.max(
|
||||||
|
MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
|
Math.round(parsed)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: normalized.selectedImageInlineActionCount;
|
||||||
|
setSiteSettings({
|
||||||
|
...siteSettings,
|
||||||
|
canvasToolbarConfig: {
|
||||||
|
...normalized,
|
||||||
|
selectedImageInlineActionCount: nextValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
value={
|
||||||
|
normalizeCanvasToolbarConfig(
|
||||||
|
siteSettings.canvasToolbarConfig
|
||||||
|
).selectedImageInlineActionCount
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{Array.from(
|
{Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
DEFAULT_CANVAS_TOOLBAR_ITEMS.map(
|
DEFAULT_CANVAS_TOOLBAR_ITEMS.map(
|
||||||
|
|||||||
@ -205,6 +205,7 @@ export async function generateImageWithFailoverCandidate(params: {
|
|||||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||||
messages: candidateMessages,
|
messages: candidateMessages,
|
||||||
imageParams: imageParams || fallbackImageParams,
|
imageParams: imageParams || fallbackImageParams,
|
||||||
|
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||||
modelDisplayName: candidate.model.displayName,
|
modelDisplayName: candidate.model.displayName,
|
||||||
overrideModelId: candidate.model.modelId,
|
overrideModelId: candidate.model.modelId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -410,6 +410,7 @@ async function replayAssistantMessagesForDuplicateRequest({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatImageParams = NonNullable<PostRequestBody["imageParams"]>;
|
type ChatImageParams = NonNullable<PostRequestBody["imageParams"]>;
|
||||||
|
type ChatImageSize = NonNullable<ChatImageParams["imageSize"]>;
|
||||||
type AgentRecommendedImageParams = {
|
type AgentRecommendedImageParams = {
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
imageSize?: string;
|
imageSize?: string;
|
||||||
@ -444,6 +445,14 @@ type AgentVideoPlan = {
|
|||||||
recommendedVideoParams?: AgentRecommendedVideoParams;
|
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 {
|
function hasUserCustomizedImageParams(params?: PostRequestBody["imageParams"]): boolean {
|
||||||
if (!params) return false;
|
if (!params) return false;
|
||||||
const customizedAspectRatio =
|
const customizedAspectRatio =
|
||||||
@ -484,7 +493,8 @@ function mergeImageParamsWithAgentRecommendation(params: {
|
|||||||
|
|
||||||
if (!hasUserCustomSettings) {
|
if (!hasUserCustomSettings) {
|
||||||
if (recommended.aspectRatio) merged.aspectRatio = recommended.aspectRatio;
|
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;
|
if (typeof recommended.imageCount === "number") merged.imageCount = recommended.imageCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2038,12 +2048,19 @@ export async function POST(request: Request) {
|
|||||||
imageSize: smartAutoDetectedIntent === "image_edit" ? "4K" : "2K",
|
imageSize: smartAutoDetectedIntent === "image_edit" ? "4K" : "2K",
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
} as const;
|
} 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) {
|
if (shouldApplyAgentImageParams) {
|
||||||
chatApiLogger.info("[Smart Auto Mode] 🎯 Effective image params:", {
|
chatApiLogger.info("[Smart Auto Mode] 🎯 Effective image params:", {
|
||||||
incoming: requestBody.imageParams,
|
incoming: requestBody.imageParams,
|
||||||
recommended: smartAutoRecommendedImageParams,
|
recommended: smartAutoRecommendedImageParams,
|
||||||
merged: effectiveImageParams,
|
merged: effectiveImageGenerationParams,
|
||||||
hasUserCustomImageParams,
|
hasUserCustomImageParams,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -3551,7 +3568,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let finalImage = firstImage;
|
let finalImage = firstImage;
|
||||||
let finalImageSizeLabel = resolvedImageSize;
|
let finalImageSizeLabel: string = resolvedImageSize;
|
||||||
try {
|
try {
|
||||||
const normalizedImage = await normalizeReplacementResultImage({
|
const normalizedImage = await normalizeReplacementResultImage({
|
||||||
generatedImageUrl: firstImage.originalUrl,
|
generatedImageUrl: firstImage.originalUrl,
|
||||||
@ -4379,7 +4396,7 @@ export async function POST(request: Request) {
|
|||||||
throw new Error("未返回结果图片");
|
throw new Error("未返回结果图片");
|
||||||
}
|
}
|
||||||
let finalImage = firstImage;
|
let finalImage = firstImage;
|
||||||
let finalImageSizeLabel = resolvedImageSize;
|
let finalImageSizeLabel: string = resolvedImageSize;
|
||||||
try {
|
try {
|
||||||
const normalizedImage = await normalizeReplacementResultImage({
|
const normalizedImage = await normalizeReplacementResultImage({
|
||||||
generatedImageUrl: firstImage.originalUrl,
|
generatedImageUrl: firstImage.originalUrl,
|
||||||
@ -6127,10 +6144,7 @@ export async function POST(request: Request) {
|
|||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image", {
|
const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image", {
|
||||||
imageSize:
|
imageSize: effectiveImageGenerationParams.imageSize,
|
||||||
typeof effectiveImageParams?.imageSize === "string"
|
|
||||||
? effectiveImageParams.imageSize
|
|
||||||
: defaultImageQualityParams.imageSize,
|
|
||||||
});
|
});
|
||||||
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", {
|
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", {
|
||||||
duration: Number(effectiveVideoParams?.duration) || undefined,
|
duration: Number(effectiveVideoParams?.duration) || undefined,
|
||||||
@ -6165,7 +6179,7 @@ export async function POST(request: Request) {
|
|||||||
1,
|
1,
|
||||||
Math.round(
|
Math.round(
|
||||||
Number(
|
Number(
|
||||||
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1
|
effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -6245,7 +6259,7 @@ export async function POST(request: Request) {
|
|||||||
const isGeminiImageProvider = ["gemini-image", "huanke-gemini"].includes((languageModel as any).provider);
|
const isGeminiImageProvider = ["gemini-image", "huanke-gemini"].includes((languageModel as any).provider);
|
||||||
if (isGeminiImageProvider && typeof (languageModel as any).doGenerate === "function") {
|
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] 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 });
|
const currentModelConfig = await getAiModelById({ id: finalModelId });
|
||||||
@ -6332,11 +6346,7 @@ export async function POST(request: Request) {
|
|||||||
// 调用 doGenerate 获取图片(如果是备用模型,传入 overrideModelId)
|
// 调用 doGenerate 获取图片(如果是备用模型,传入 overrideModelId)
|
||||||
const result = await (languageModel as any).doGenerate({
|
const result = await (languageModel as any).doGenerate({
|
||||||
messages: modelMessages,
|
messages: modelMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
},
|
|
||||||
overrideModelId: isRetry ? modelIdToUse : undefined, // 🆕 使用备用模型 ID
|
overrideModelId: isRetry ? modelIdToUse : undefined, // 🆕 使用备用模型 ID
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -6362,7 +6372,7 @@ export async function POST(request: Request) {
|
|||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: currentModelConfig?.displayName || actualModelId,
|
modelName: currentModelConfig?.displayName || actualModelId,
|
||||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
imageCount: effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||||
},
|
},
|
||||||
"gemini:image-status-start",
|
"gemini:image-status-start",
|
||||||
);
|
);
|
||||||
@ -6384,10 +6394,10 @@ export async function POST(request: Request) {
|
|||||||
runtimeModelId: actualModelId,
|
runtimeModelId: actualModelId,
|
||||||
finalModelId,
|
finalModelId,
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||||
imageSize: effectiveImageParams?.imageSize,
|
imageSize: effectiveImageGenerationParams.imageSize,
|
||||||
imageCount:
|
imageCount:
|
||||||
effectiveImageParams?.imageCount ||
|
effectiveImageGenerationParams.imageCount ||
|
||||||
defaultImageQualityParams.imageCount ||
|
defaultImageQualityParams.imageCount ||
|
||||||
1,
|
1,
|
||||||
},
|
},
|
||||||
@ -6405,7 +6415,7 @@ export async function POST(request: Request) {
|
|||||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||||
candidate,
|
candidate,
|
||||||
messages: buildMessagesForGemini(),
|
messages: buildMessagesForGemini(),
|
||||||
imageParams: effectiveImageParams,
|
imageParams: effectiveImageGenerationParams,
|
||||||
fallbackImageParams: {
|
fallbackImageParams: {
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
imageSize: defaultImageQualityParams.imageSize,
|
||||||
@ -6431,8 +6441,8 @@ export async function POST(request: Request) {
|
|||||||
primaryModelId: actualModelId,
|
primaryModelId: actualModelId,
|
||||||
fallbackModelId: candidate.model.modelId,
|
fallbackModelId: candidate.model.modelId,
|
||||||
providerType: candidate.provider.type,
|
providerType: candidate.provider.type,
|
||||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||||
imageSize: effectiveImageParams?.imageSize,
|
imageSize: effectiveImageGenerationParams.imageSize,
|
||||||
},
|
},
|
||||||
}).catch((healthError) => {
|
}).catch((healthError) => {
|
||||||
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
||||||
@ -6467,9 +6477,9 @@ export async function POST(request: Request) {
|
|||||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔁 Gemini cross-provider failover: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔁 Gemini cross-provider failover: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||||
candidate,
|
candidate,
|
||||||
messages: buildMessagesForGemini(),
|
messages: buildMessagesForGemini(),
|
||||||
imageParams: effectiveImageParams,
|
imageParams: effectiveImageGenerationParams,
|
||||||
fallbackImageParams: {
|
fallbackImageParams: {
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
imageSize: defaultImageQualityParams.imageSize,
|
||||||
@ -6665,12 +6675,7 @@ export async function POST(request: Request) {
|
|||||||
const volcanoResult = await (volcanoLanguageModel as any).doGenerate({
|
const volcanoResult = await (volcanoLanguageModel as any).doGenerate({
|
||||||
messages: volcanoMessages,
|
messages: volcanoMessages,
|
||||||
maxImages: 1, // 故障转移时只生成 1 张
|
maxImages: 1, // 故障转移时只生成 1 张
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageMarkdown = volcanoResult.text || "图片生成失败";
|
const imageMarkdown = volcanoResult.text || "图片生成失败";
|
||||||
@ -6844,7 +6849,7 @@ export async function POST(request: Request) {
|
|||||||
].includes((languageModel as any).provider);
|
].includes((languageModel as any).provider);
|
||||||
if (isAlibabaLikeImageProvider && typeof (languageModel as any).doGenerate === "function") {
|
if (isAlibabaLikeImageProvider && typeof (languageModel as any).doGenerate === "function") {
|
||||||
chatApiLogger.info("[Chat API] Using image generation provider:", (languageModel as any).provider);
|
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 });
|
const alibabaModelConfig = await getAiModelById({ id: finalModelId });
|
||||||
@ -6950,12 +6955,7 @@ export async function POST(request: Request) {
|
|||||||
apiKey: falApiKey,
|
apiKey: falApiKey,
|
||||||
modelId: actualModelId,
|
modelId: actualModelId,
|
||||||
messages: falMessages,
|
messages: falMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const placeholderMessageId = generateUUID();
|
const placeholderMessageId = generateUUID();
|
||||||
@ -6982,9 +6982,9 @@ export async function POST(request: Request) {
|
|||||||
optimizedPrompt: smartAutoOptimizedPrompt || userPrompt || queuedTask.prompt,
|
optimizedPrompt: smartAutoOptimizedPrompt || userPrompt || queuedTask.prompt,
|
||||||
userPrompt: userPrompt || queuedTask.prompt,
|
userPrompt: userPrompt || queuedTask.prompt,
|
||||||
requestedImageCount:
|
requestedImageCount:
|
||||||
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||||
imageSize: effectiveImageParams?.imageSize,
|
imageSize: effectiveImageGenerationParams.imageSize,
|
||||||
outputSizeLabel: queuedTask.outputSizeLabel,
|
outputSizeLabel: queuedTask.outputSizeLabel,
|
||||||
sourceImageUrls: requestTargetResolution?.targetImageUrls || queuedTask.sourceImageUrls,
|
sourceImageUrls: requestTargetResolution?.targetImageUrls || queuedTask.sourceImageUrls,
|
||||||
sourceAssetIds: requestTargetResolution?.targetAssetIds || [],
|
sourceAssetIds: requestTargetResolution?.targetAssetIds || [],
|
||||||
@ -7012,7 +7012,7 @@ export async function POST(request: Request) {
|
|||||||
providerType: "fal-image",
|
providerType: "fal-image",
|
||||||
modelName: modelDisplayName,
|
modelName: modelDisplayName,
|
||||||
imageCount:
|
imageCount:
|
||||||
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||||
},
|
},
|
||||||
"alibaba:image-status-queued",
|
"alibaba:image-status-queued",
|
||||||
);
|
);
|
||||||
@ -7074,12 +7074,8 @@ export async function POST(request: Request) {
|
|||||||
// 调用 doGenerate 获取图片,传递模型显示名称
|
// 调用 doGenerate 获取图片,传递模型显示名称
|
||||||
const result = await (languageModel as any).doGenerate({
|
const result = await (languageModel as any).doGenerate({
|
||||||
messages: modelMessages,
|
messages: modelMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
imageGenConfig: alibabaModelConfig?.imageGenConfig || null,
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
modelDisplayName, // 🆕 传递模型显示名称
|
modelDisplayName, // 🆕 传递模型显示名称
|
||||||
overrideModelId: isRetry ? modelIdToUse : undefined,
|
overrideModelId: isRetry ? modelIdToUse : undefined,
|
||||||
});
|
});
|
||||||
@ -7107,7 +7103,7 @@ export async function POST(request: Request) {
|
|||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: (languageModel as any).provider,
|
providerType: (languageModel as any).provider,
|
||||||
modelName: alibabaModelConfig?.displayName || actualModelId,
|
modelName: alibabaModelConfig?.displayName || actualModelId,
|
||||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
imageCount: effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||||
},
|
},
|
||||||
"alibaba:image-status-start",
|
"alibaba:image-status-start",
|
||||||
);
|
);
|
||||||
@ -7126,7 +7122,7 @@ export async function POST(request: Request) {
|
|||||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||||
candidate,
|
candidate,
|
||||||
messages: messagesForAlibaba,
|
messages: messagesForAlibaba,
|
||||||
imageParams: effectiveImageParams,
|
imageParams: effectiveImageGenerationParams,
|
||||||
fallbackImageParams: {
|
fallbackImageParams: {
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
imageSize: defaultImageQualityParams.imageSize,
|
||||||
@ -7218,12 +7214,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||||
messages: candidateMessages,
|
messages: candidateMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
modelDisplayName: candidate.model.displayName,
|
modelDisplayName: candidate.model.displayName,
|
||||||
overrideModelId: candidate.model.modelId,
|
overrideModelId: candidate.model.modelId,
|
||||||
});
|
});
|
||||||
@ -7501,12 +7493,7 @@ export async function POST(request: Request) {
|
|||||||
const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => {
|
const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => {
|
||||||
const result = await (languageModel as any).doGenerate({
|
const result = await (languageModel as any).doGenerate({
|
||||||
messages: modelMessages,
|
messages: modelMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
overrideModelId: isRetry ? modelIdToUse : undefined,
|
overrideModelId: isRetry ? modelIdToUse : undefined,
|
||||||
});
|
});
|
||||||
if (result?.finishReason === "error") {
|
if (result?.finishReason === "error") {
|
||||||
@ -7531,7 +7518,7 @@ export async function POST(request: Request) {
|
|||||||
phase: "processing",
|
phase: "processing",
|
||||||
providerType: "volcano-vision",
|
providerType: "volcano-vision",
|
||||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||||
imageCount: effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
|
imageCount: effectiveImageGenerationParams.imageCount || defaultImageQualityParams.imageCount || 1,
|
||||||
},
|
},
|
||||||
"volcano:image-status-start",
|
"volcano:image-status-start",
|
||||||
);
|
);
|
||||||
@ -7548,7 +7535,7 @@ export async function POST(request: Request) {
|
|||||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||||
candidate,
|
candidate,
|
||||||
messages: messagesForVolcano,
|
messages: messagesForVolcano,
|
||||||
imageParams: effectiveImageParams,
|
imageParams: effectiveImageGenerationParams,
|
||||||
fallbackImageParams: {
|
fallbackImageParams: {
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
imageSize: defaultImageQualityParams.imageSize,
|
||||||
@ -7639,12 +7626,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||||
messages: candidateMessages,
|
messages: candidateMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
modelDisplayName: candidate.model.displayName,
|
modelDisplayName: candidate.model.displayName,
|
||||||
overrideModelId: candidate.model.modelId,
|
overrideModelId: candidate.model.modelId,
|
||||||
});
|
});
|
||||||
@ -8402,7 +8385,7 @@ export async function POST(request: Request) {
|
|||||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||||
candidate,
|
candidate,
|
||||||
messages: messagesForCrossProvider,
|
messages: messagesForCrossProvider,
|
||||||
imageParams: effectiveImageParams,
|
imageParams: effectiveImageGenerationParams,
|
||||||
fallbackImageParams: {
|
fallbackImageParams: {
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
imageSize: defaultImageQualityParams.imageSize,
|
||||||
@ -8498,12 +8481,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||||
messages: candidateMessages,
|
messages: candidateMessages,
|
||||||
imageParams:
|
imageParams: effectiveImageGenerationParams,
|
||||||
effectiveImageParams || {
|
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
|
||||||
imageSize: defaultImageQualityParams.imageSize,
|
|
||||||
imageCount: defaultImageQualityParams.imageCount,
|
|
||||||
},
|
|
||||||
modelDisplayName: candidate.model.displayName,
|
modelDisplayName: candidate.model.displayName,
|
||||||
overrideModelId: candidate.model.modelId,
|
overrideModelId: candidate.model.modelId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,10 +30,20 @@ const videoParamsSchema = z.object({
|
|||||||
returnLastFrame: z.boolean().optional(),
|
returnLastFrame: z.boolean().optional(),
|
||||||
}).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
|
// 🆕 图片生成参数 schema
|
||||||
const imageParamsSchema = z.object({
|
const imageParamsSchema = z.object({
|
||||||
aspectRatio: z.string().optional(),
|
aspectRatio: z.string().optional(),
|
||||||
imageSize: z.string().optional(),
|
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
||||||
imageCount: z.number().min(1).max(10).optional(),
|
imageCount: z.number().min(1).max(10).optional(),
|
||||||
// 阿里生图专用参数
|
// 阿里生图专用参数
|
||||||
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
||||||
|
|||||||
@ -1894,11 +1894,13 @@ const updateSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
canvasToolbarConfig: z
|
canvasToolbarConfig: z
|
||||||
.object({
|
.object({
|
||||||
|
selectedImageInlineActionCount: z.number().int().min(3).max(12).optional(),
|
||||||
items: z
|
items: z
|
||||||
.record(
|
.record(
|
||||||
z.object({
|
z.object({
|
||||||
label: z.string().trim().max(40).optional(),
|
label: z.string().trim().max(40).optional(),
|
||||||
iconUrl: z.string().trim().max(2000).optional(),
|
iconUrl: z.string().trim().max(2000).optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -27,7 +27,8 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
PenLine,
|
PenLine,
|
||||||
Play,
|
Play,
|
||||||
PlusCircle,
|
Plus,
|
||||||
|
PlusCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Scissors,
|
Scissors,
|
||||||
@ -649,8 +650,10 @@ export default function CanvasModel3DPage() {
|
|||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
|
|
||||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||||
const addNode = useCanvasStore((state) => state.addNode);
|
const addNode = useCanvasStore((state) => state.addNode);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
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 [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
|
||||||
const [mode, setMode] = useState<GenerationMode>("text");
|
const [mode, setMode] = useState<GenerationMode>("text");
|
||||||
@ -724,8 +727,10 @@ export default function CanvasModel3DPage() {
|
|||||||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||||||
|
|
||||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||||
|
// 多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布
|
||||||
|
const [selectedAddTaskIds, setSelectedAddTaskIds] = useState<Set<string>>(new Set());
|
||||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@ -1273,9 +1278,19 @@ export default function CanvasModel3DPage() {
|
|||||||
[visibleTaskHistory]
|
[visibleTaskHistory]
|
||||||
);
|
);
|
||||||
const selectedWorldShareTasks = useMemo(() => {
|
const selectedWorldShareTasks = useMemo(() => {
|
||||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
|
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
|
||||||
@ -2264,7 +2279,216 @@ export default function CanvasModel3DPage() {
|
|||||||
}
|
}
|
||||||
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
|
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
|
||||||
|
|
||||||
const handleDisableCurrentShare = useCallback(async () => {
|
// 多选添加到画布的 handlers
|
||||||
|
const handleToggleAddSelection = useCallback(
|
||||||
|
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event?.stopPropagation();
|
||||||
|
if (task.status !== "success") {
|
||||||
|
toast.error("仅成功生成的世界/模型可添加到画布");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAddTaskIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(task.taskId)) {
|
||||||
|
next.delete(task.taskId);
|
||||||
|
} else {
|
||||||
|
next.add(task.taskId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectAllForAdd = useCallback(() => {
|
||||||
|
setSelectedAddTaskIds((prev) => {
|
||||||
|
if (prev.size === addableTasks.length) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(addableTasks.map((task) => task.taskId));
|
||||||
|
});
|
||||||
|
}, [addableTasks]);
|
||||||
|
|
||||||
|
//清理已不存在的选中项
|
||||||
|
useEffect(() => {
|
||||||
|
const addableIds = new Set(addableTasks.map((task) => task.taskId));
|
||||||
|
setSelectedAddTaskIds((prev) => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const id of prev) {
|
||||||
|
if (addableIds.has(id)) next.add(id);
|
||||||
|
}
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}, [addableTasks]);
|
||||||
|
|
||||||
|
//批量添加到画布:复用 addNodesBulk → addNode ring-scan 自动避让
|
||||||
|
const handleBatchImportToCanvas = useCallback(async () => {
|
||||||
|
const targets = selectedAddTasks;
|
||||||
|
if (targets.length ===0) {
|
||||||
|
toast.error("请至少选择1 个成功生成的世界/模型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportX = viewport.x ||0;
|
||||||
|
const viewportY = viewport.y ||0;
|
||||||
|
const viewportZoom = viewport.zoom ||1;
|
||||||
|
const stageWidth =
|
||||||
|
typeof window !== "undefined" ? window.innerWidth :1280;
|
||||||
|
const stageHeight =
|
||||||
|
typeof window !== "undefined" ? window.innerHeight :720;
|
||||||
|
const targetWidth =380;
|
||||||
|
const targetHeight =300;
|
||||||
|
|
||||||
|
const baseX =
|
||||||
|
(stageWidth *0.48 - viewportX) / viewportZoom - targetWidth /2;
|
||||||
|
const baseY =
|
||||||
|
(stageHeight *0.45 - viewportY) / viewportZoom - targetHeight /2;
|
||||||
|
|
||||||
|
const nodeConfigs: Array<Parameters<typeof addNode>[0]> = [];
|
||||||
|
|
||||||
|
for (const task of targets) {
|
||||||
|
if (task.status !== "success") continue;
|
||||||
|
|
||||||
|
const isWorldTask = task.provider === "worldlabs-3d";
|
||||||
|
const worldAssetPayload = task.assetPayload ?? null;
|
||||||
|
const taskModelUrl = resolveTaskModelUrl(task);
|
||||||
|
const primarySplatUrl = isWorldTask
|
||||||
|
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
|
||||||
|
: null;
|
||||||
|
const worldImageUrl =
|
||||||
|
worldAssetPayload?.panoUrl ||
|
||||||
|
worldAssetPayload?.thumbnailUrl ||
|
||||||
|
task.previewImageUrl ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const canImport = isWorldTask
|
||||||
|
? Boolean(
|
||||||
|
taskModelUrl ||
|
||||||
|
worldAssetPayload?.worldUrl ||
|
||||||
|
worldImageUrl ||
|
||||||
|
primarySplatUrl
|
||||||
|
)
|
||||||
|
: Boolean(taskModelUrl);
|
||||||
|
|
||||||
|
if (!canImport) continue;
|
||||||
|
|
||||||
|
nodeConfigs.push(
|
||||||
|
isWorldTask
|
||||||
|
? {
|
||||||
|
type: "world3d",
|
||||||
|
position: { x: baseX, y: baseY },
|
||||||
|
size: { width: targetWidth, height: targetHeight },
|
||||||
|
data: {
|
||||||
|
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||||
|
imageUrl: worldImageUrl || undefined,
|
||||||
|
meshUrl: taskModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||||
|
splatUrl: primarySplatUrl || undefined,
|
||||||
|
previewImage:
|
||||||
|
task.previewImageUrl ||
|
||||||
|
worldAssetPayload?.thumbnailUrl ||
|
||||||
|
undefined,
|
||||||
|
prompt: task.prompt,
|
||||||
|
modelVersion: task.modelVersion,
|
||||||
|
status: "completed",
|
||||||
|
activeView: primarySplatUrl
|
||||||
|
? "splat"
|
||||||
|
: worldImageUrl
|
||||||
|
? "image"
|
||||||
|
: "mesh",
|
||||||
|
firstPerson: false,
|
||||||
|
hotspots: [],
|
||||||
|
semanticsMetadata:
|
||||||
|
worldAssetPayload?.semanticsMetadata || undefined,
|
||||||
|
metadata: {
|
||||||
|
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: task.assetPayload ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "model3d",
|
||||||
|
position: { x: baseX, y: baseY },
|
||||||
|
size: { width: targetWidth, height: targetHeight },
|
||||||
|
data: {
|
||||||
|
src: taskModelUrl!,
|
||||||
|
previewImage: task.previewImageUrl || undefined,
|
||||||
|
prompt: task.prompt,
|
||||||
|
modelVersion: task.modelVersion,
|
||||||
|
format: extractModelFormat(taskModelUrl!),
|
||||||
|
status: "completed",
|
||||||
|
metadata: {
|
||||||
|
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: task.assetPayload ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeConfigs.length ===0) {
|
||||||
|
toast.error("选中的资产没有可添加到画布的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdNodeIds = addNodesBulk(nodeConfigs);
|
||||||
|
|
||||||
|
if (createdNodeIds.length >0) {
|
||||||
|
selectNodes(createdNodeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedAddTaskIds(new Set());
|
||||||
|
const skipped = targets.length - nodeConfigs.length;
|
||||||
|
const worldCount = nodeConfigs.filter((c) => c.type === "world3d").length;
|
||||||
|
const modelCount = nodeConfigs.length - worldCount;
|
||||||
|
const summary = [
|
||||||
|
worldCount >0 ? `${worldCount} 个世界` : "",
|
||||||
|
modelCount >0 ? `${modelCount} 个模型` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" + ");
|
||||||
|
toast.success(
|
||||||
|
skipped >0
|
||||||
|
? `已批量添加 ${summary}(跳过 ${skipped} 个无资源项)`
|
||||||
|
: `已批量添加 ${summary}到画布`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await canvasService.saveCanvasState(id, getCanvasState());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Model3DPage] 保存批量画布失败:", error);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
addNode,
|
||||||
|
addNodesBulk,
|
||||||
|
getCanvasState,
|
||||||
|
id,
|
||||||
|
selectNodes,
|
||||||
|
selectedAddTasks,
|
||||||
|
viewerStats.meshCount,
|
||||||
|
viewerStats.triangles,
|
||||||
|
viewerStats.vertices,
|
||||||
|
viewport.x,
|
||||||
|
viewport.y,
|
||||||
|
viewport.zoom,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDisableCurrentShare = useCallback(async () => {
|
||||||
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
|
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
|
||||||
toast.error("当前没有可关闭分享的世界资产");
|
toast.error("当前没有可关闭分享的世界资产");
|
||||||
return;
|
return;
|
||||||
@ -4050,17 +4274,50 @@ export default function CanvasModel3DPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
分享所选
|
分享所选
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ScrollArea className="flex-1">
|
{addableTasks.length >0 ? (
|
||||||
|
<div className="space-y-2 border-white/10 border-b px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||||
|
<span>批量添加到画布</span>
|
||||||
|
<span>已选 {selectedAddTaskIds.size}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||||||
|
disabled={addableTasks.length ===0}
|
||||||
|
onClick={handleSelectAllForAdd}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{selectedAddTaskIds.size === addableTasks.length && addableTasks.length >0
|
||||||
|
? "取消全选"
|
||||||
|
: "全选可添加"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-8 border-yellow-400/30 bg-yellow-400/15 text-xs text-yellow-100 hover:bg-yellow-400/25"
|
||||||
|
disabled={selectedAddTaskIds.size ===0}
|
||||||
|
onClick={() => void handleBatchImportToCanvas()}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
添加到画布 ({selectedAddTaskIds.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
<div className="grid grid-cols-2 gap-2 p-3">
|
<div className="grid grid-cols-2 gap-2 p-3">
|
||||||
{visibleTaskHistory.map((task) => {
|
{visibleTaskHistory.map((task) => {
|
||||||
const canShareWorld =
|
const canShareWorld =
|
||||||
task.provider === "worldlabs-3d" && task.status === "success";
|
task.provider === "worldlabs-3d" && task.status === "success";
|
||||||
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
|
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
|
||||||
|
const isSelectedForAdd =
|
||||||
|
task.status === "success" && selectedAddTaskIds.has(task.taskId);
|
||||||
|
const canAddToCanvas = task.status === "success";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -4069,8 +4326,10 @@ export default function CanvasModel3DPage() {
|
|||||||
currentTask?.taskId === task.taskId &&
|
currentTask?.taskId === task.taskId &&
|
||||||
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
|
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
|
||||||
isSelectedForShare &&
|
isSelectedForShare &&
|
||||||
"border-cyan-300 ring-2 ring-cyan-300/40"
|
"border-cyan-300 ring-2 ring-cyan-300/40",
|
||||||
)}
|
isSelectedForAdd &&
|
||||||
|
"border-yellow-300 ring-2 ring-yellow-300/40"
|
||||||
|
)}
|
||||||
key={task.taskId}
|
key={task.taskId}
|
||||||
onClick={() => handleSelectTask(task)}
|
onClick={() => handleSelectTask(task)}
|
||||||
type="button"
|
type="button"
|
||||||
@ -4111,19 +4370,35 @@ export default function CanvasModel3DPage() {
|
|||||||
<Check className="mr-1 h-3 w-3" />
|
<Check className="mr-1 h-3 w-3" />
|
||||||
) : null}
|
) : null}
|
||||||
{isSelectedForShare ? "已选" : "选择"}
|
{isSelectedForShare ? "已选" : "选择"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
{canAddToCanvas ? (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
|
className={cn(
|
||||||
onClick={(event) => handleDownloadTask(task, event)}
|
"absolute right-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
|
||||||
size="icon"
|
isSelectedForAdd &&
|
||||||
type="button"
|
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50 opacity-100"
|
||||||
variant="ghost"
|
)}
|
||||||
>
|
onClick={(event) => handleToggleAddSelection(task, event)}
|
||||||
<Download className="h-3.5 w-3.5" />
|
size="sm"
|
||||||
</Button>
|
type="button"
|
||||||
) : null}
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{isSelectedForAdd ? "已选" : "加画布"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||||||
|
<Button
|
||||||
|
className="absolute right-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
|
||||||
|
onClick={(event) => handleDownloadTask(task, event)}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{task.previewImageUrl ? (
|
{task.previewImageUrl ? (
|
||||||
<img
|
<img
|
||||||
alt="preview"
|
alt="preview"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Droplet,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@ -67,6 +68,7 @@ import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-s
|
|||||||
import { ImageAnnotator } from "@/components/image-annotator";
|
import { ImageAnnotator } from "@/components/image-annotator";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
import {
|
import {
|
||||||
@ -1457,6 +1459,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [replacementRerunTarget, setReplacementRerunTarget] =
|
const [replacementRerunTarget, setReplacementRerunTarget] =
|
||||||
useState<ReplacementRerunTarget | null>(null);
|
useState<ReplacementRerunTarget | null>(null);
|
||||||
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
||||||
|
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||||
|
useState<Set<string>>(() => new Set());
|
||||||
const closePsdTemplateDialog = useCallback(() => {
|
const closePsdTemplateDialog = useCallback(() => {
|
||||||
setIsPsdTemplateDialogOpen(false);
|
setIsPsdTemplateDialogOpen(false);
|
||||||
setPsdTemplateDialogSourceUrl(null);
|
setPsdTemplateDialogSourceUrl(null);
|
||||||
@ -3859,6 +3863,74 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
title: String(params.title || "").trim() || "待改字结果",
|
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(
|
const getReplacementExecutionCards = useCallback(
|
||||||
(params: {
|
(params: {
|
||||||
modeType: ReplacementModeType;
|
modeType: ReplacementModeType;
|
||||||
@ -7057,6 +7129,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const statusMeta = getReplacementCardStatusMeta(card.status);
|
const statusMeta = getReplacementCardStatusMeta(card.status);
|
||||||
const isPending = card.status === "generating" || card.status === "queued";
|
const isPending = card.status === "generating" || card.status === "queued";
|
||||||
const hasResult = Boolean(card.resultUrl) && !isPending;
|
const hasResult = Boolean(card.resultUrl) && !isPending;
|
||||||
|
const isWatermarkRemoving = detailWatermarkRemoveCardIds.has(card.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${card.id}-result`}
|
key={`${card.id}-result`}
|
||||||
@ -7144,6 +7217,27 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
>
|
>
|
||||||
重新生图
|
重新生图
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1284,14 +1284,14 @@ export function AiWashWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-16 items-center rounded-[22px] bg-[#111111] px-9 text-[18px] font-semibold text-white shadow-[0_18px_42px_rgba(0,0,0,0.14)] transition hover:-translate-y-0.5 hover:bg-black"
|
className="inline-flex h-12 items-center rounded-[18px] bg-[#111111] px-6 text-[15px] font-semibold text-white shadow-[0_12px_28px_rgba(0,0,0,0.12)] transition hover:-translate-y-0.5 hover:bg-black"
|
||||||
onClick={() => onRequestAddImage?.()}
|
onClick={() => onRequestAddImage?.()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ImageIcon className="mr-3 h-5 w-5" />
|
<ImageIcon className="mr-2.5 h-4 w-4" />
|
||||||
添加图片
|
添加图片
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-4 text-[14px] text-[#777777]">
|
<div className="mt-2.5 text-[13px] text-[#777777]">
|
||||||
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
|
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -170,6 +170,7 @@ import {
|
|||||||
getScaledNodeContentStyle,
|
getScaledNodeContentStyle,
|
||||||
} from "@/lib/canvas/node-content-scale";
|
} from "@/lib/canvas/node-content-scale";
|
||||||
import {
|
import {
|
||||||
|
getSelectedImageInlineActionCount,
|
||||||
getCanvasToolbarItem,
|
getCanvasToolbarItem,
|
||||||
isCanvasToolbarItemEnabled,
|
isCanvasToolbarItemEnabled,
|
||||||
type CanvasToolbarConfig,
|
type CanvasToolbarConfig,
|
||||||
@ -1180,6 +1181,47 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
const imageLayerSplitEnabled =
|
const imageLayerSplitEnabled =
|
||||||
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
|
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
|
||||||
|
const selectedImageInlineActionCount =
|
||||||
|
getSelectedImageInlineActionCount(canvasToolbarConfig);
|
||||||
|
const selectedImageToolbarActionCandidates = [
|
||||||
|
{ key: "pickObject", enabled: imagePickObjectEnabled },
|
||||||
|
{ key: "enhance", enabled: imageEnhanceEnabled },
|
||||||
|
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
|
||||||
|
{ key: "aiWashQuick", enabled: imageAiWashQuickEnabled },
|
||||||
|
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||||||
|
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||||||
|
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||||||
|
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||||||
|
{ key: "resize", enabled: imageResizeEnabled },
|
||||||
|
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||||||
|
{ key: "generate3dModel", enabled: imageGenerate3dModelEnabled },
|
||||||
|
{ key: "generate3dWorld", enabled: imageGenerate3dWorldEnabled },
|
||||||
|
{ key: "layerSplit", enabled: imageLayerSplitEnabled },
|
||||||
|
{ key: "extend", enabled: imageExtendEnabled },
|
||||||
|
{ key: "crop", enabled: imageCropEnabled },
|
||||||
|
{ key: "adjust", enabled: imageAdjustEnabled },
|
||||||
|
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
|
||||||
|
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
|
||||||
|
{ key: "note", enabled: imageNoteEnabled },
|
||||||
|
] as const;
|
||||||
|
type SelectedImageToolbarActionKey =
|
||||||
|
(typeof selectedImageToolbarActionCandidates)[number]["key"];
|
||||||
|
const enabledSelectedImageToolbarActions =
|
||||||
|
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
|
||||||
|
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
|
||||||
|
enabledSelectedImageToolbarActions
|
||||||
|
.slice(0, selectedImageInlineActionCount)
|
||||||
|
.map((action) => action.key)
|
||||||
|
);
|
||||||
|
const selectedImageMoreActionKeys = new Set<SelectedImageToolbarActionKey>(
|
||||||
|
enabledSelectedImageToolbarActions
|
||||||
|
.slice(selectedImageInlineActionCount)
|
||||||
|
.map((action) => action.key)
|
||||||
|
);
|
||||||
|
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
|
||||||
|
selectedImageInlineActionKeys.has(key);
|
||||||
|
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
|
||||||
|
selectedImageMoreActionKeys.has(key);
|
||||||
const hasHoverImageMorePrimaryActions =
|
const hasHoverImageMorePrimaryActions =
|
||||||
imageRemoveBackgroundEnabled ||
|
imageRemoveBackgroundEnabled ||
|
||||||
imageRemoveWatermarkEnabled ||
|
imageRemoveWatermarkEnabled ||
|
||||||
@ -1201,29 +1243,12 @@ export function ImageNode({
|
|||||||
imageNodeMoreEnabled &&
|
imageNodeMoreEnabled &&
|
||||||
(hasHoverImageMorePrimaryActions || hasHoverImageMoreSecondaryActions);
|
(hasHoverImageMorePrimaryActions || hasHoverImageMoreSecondaryActions);
|
||||||
const hasSelectedImageMorePrimaryActions =
|
const hasSelectedImageMorePrimaryActions =
|
||||||
imageExtendEnabled ||
|
selectedImageMoreActionKeys.size > 0;
|
||||||
imageCropEnabled ||
|
|
||||||
imageAdjustEnabled ||
|
|
||||||
imageFlipRotateEnabled ||
|
|
||||||
imagePromptRegenerateEnabled ||
|
|
||||||
imageNoteEnabled;
|
|
||||||
const hasSelectedImageMoreMenu =
|
const hasSelectedImageMoreMenu =
|
||||||
imageNodeMoreEnabled &&
|
hasSelectedImageMorePrimaryActions ||
|
||||||
(hasSelectedImageMorePrimaryActions || imageDeleteEnabled);
|
(imageNodeMoreEnabled && imageDeleteEnabled);
|
||||||
const hasSelectedImagePrimaryToolbarActions =
|
const hasSelectedImagePrimaryToolbarActions =
|
||||||
imagePickObjectEnabled ||
|
selectedImageInlineActionKeys.size > 0;
|
||||||
imageEnhanceEnabled ||
|
|
||||||
imageRemoveBackgroundEnabled ||
|
|
||||||
imageBoxCutoutEnabled ||
|
|
||||||
imageLocalEditEnabled ||
|
|
||||||
imageRemoveWatermarkEnabled ||
|
|
||||||
imageResizeEnabled ||
|
|
||||||
imageTextEditEnabled ||
|
|
||||||
imageMultiAngleEnabled ||
|
|
||||||
imageGenerate3dModelEnabled ||
|
|
||||||
imageGenerate3dWorldEnabled ||
|
|
||||||
imageAiWashQuickEnabled ||
|
|
||||||
imageLayerSplitEnabled;
|
|
||||||
const hasSelectedImageSecondaryToolbarActions =
|
const hasSelectedImageSecondaryToolbarActions =
|
||||||
hasSelectedImageMoreMenu || imageDownloadEnabled;
|
hasSelectedImageMoreMenu || imageDownloadEnabled;
|
||||||
const contentScale = useMemo(
|
const contentScale = useMemo(
|
||||||
@ -4294,7 +4319,7 @@ export function ImageNode({
|
|||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
const toolbarWidth = Math.max(
|
const toolbarWidth = Math.max(
|
||||||
280,
|
280,
|
||||||
(toolbarSize.width || 980) * FLOATING_TOOLBAR_SCALE
|
(toolbarSize.width || 640) * FLOATING_TOOLBAR_SCALE
|
||||||
);
|
);
|
||||||
const toolbarHeight = Math.max(
|
const toolbarHeight = Math.max(
|
||||||
42,
|
42,
|
||||||
@ -4302,12 +4327,12 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
const titleSafeHeight = 34;
|
const titleSafeHeight = 34;
|
||||||
const gap = 12;
|
const gap = 12;
|
||||||
const { viewportPadding, maxTop, safeLeft, safeRight } =
|
const { viewportPadding, maxTop, safeBottom, safeLeft, safeRight } =
|
||||||
getFloatingViewportBounds(toolbarHeight);
|
getFloatingViewportBounds(toolbarHeight);
|
||||||
|
|
||||||
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
|
let top = rect.bottom + gap;
|
||||||
if (top < viewportPadding) {
|
if (top + toolbarHeight > safeBottom) {
|
||||||
top = rect.bottom + gap;
|
top = rect.top - toolbarHeight - titleSafeHeight - gap;
|
||||||
}
|
}
|
||||||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||||||
|
|
||||||
@ -4332,36 +4357,31 @@ export function ImageNode({
|
|||||||
return { top: 0, left: 0, maxWidth: 480 };
|
return { top: 0, left: 0, maxWidth: 480 };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAiWashQuickPanelPosition = (
|
const getAiWashQuickPanelPosition = () => {
|
||||||
anchor: { top: number; left: number; maxWidth: number }
|
if (!containerRef.current || typeof window === "undefined") {
|
||||||
) => {
|
return { top: 0, left: 0, width: 286, maxHeight: 430 };
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return { top: 0, left: 0, width: 360, maxHeight: 520 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelWidth = 360;
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
const panelHeight = 540;
|
const panelWidth = 286;
|
||||||
const gap = 10;
|
const panelHeight = 430;
|
||||||
const toolbarHeight = Math.max(
|
const gap = 12;
|
||||||
42,
|
|
||||||
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
|
|
||||||
);
|
|
||||||
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
|
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
|
||||||
getFloatingViewportBounds(panelHeight);
|
getFloatingViewportBounds(panelHeight);
|
||||||
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
|
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
|
||||||
|
|
||||||
let top = anchor.top + toolbarHeight + gap;
|
let left = rect.right + gap;
|
||||||
|
if (left + width > safeRight) {
|
||||||
|
left = rect.left - width - gap;
|
||||||
|
}
|
||||||
|
left = Math.min(Math.max(left, safeLeft), Math.max(safeLeft, safeRight - width));
|
||||||
|
|
||||||
|
let top = rect.top;
|
||||||
if (top + panelHeight > safeBottom) {
|
if (top + panelHeight > safeBottom) {
|
||||||
top = anchor.top - panelHeight - gap;
|
top = rect.bottom - panelHeight;
|
||||||
}
|
}
|
||||||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||||||
|
|
||||||
const preferredLeft = anchor.left;
|
|
||||||
const left = Math.min(
|
|
||||||
Math.max(preferredLeft, safeLeft),
|
|
||||||
Math.max(safeLeft, safeRight - width)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top,
|
top,
|
||||||
left,
|
left,
|
||||||
@ -7457,7 +7477,7 @@ export function ImageNode({
|
|||||||
const cropPanelPosition = getCropPanelPosition();
|
const cropPanelPosition = getCropPanelPosition();
|
||||||
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
|
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
|
||||||
const toolbarPosition = getToolbarPosition();
|
const toolbarPosition = getToolbarPosition();
|
||||||
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition(toolbarPosition);
|
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition();
|
||||||
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
||||||
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
||||||
const anglePanelPosition = getAnglePanelPosition();
|
const anglePanelPosition = getAnglePanelPosition();
|
||||||
@ -8653,7 +8673,7 @@ export function ImageNode({
|
|||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[10000] rounded-[28px] border border-slate-200 bg-white/96 p-3.5 text-slate-950 shadow-[0_20px_54px_rgba(15,23,42,0.16)] backdrop-blur-xl dark:border-white/12 dark:bg-slate-950/94 dark:text-white"
|
className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
||||||
data-canvas-no-drag="true"
|
data-canvas-no-drag="true"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
@ -8664,104 +8684,89 @@ export function ImageNode({
|
|||||||
width: aiWashQuickPanelPosition.width,
|
width: aiWashQuickPanelPosition.width,
|
||||||
maxHeight: aiWashQuickPanelPosition.maxHeight,
|
maxHeight: aiWashQuickPanelPosition.maxHeight,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
scrollbarWidth: "thin",
|
scrollbarWidth: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-slate-200 bg-white shadow-[0_8px_18px_rgba(15,23,42,0.06)] dark:border-white/12 dark:bg-white/8">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
|
||||||
<div className="truncate font-semibold text-[16px] tracking-[-0.03em]">
|
{aiWashQuickModeLabel}
|
||||||
{aiWashQuickModeLabel}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[12px] text-slate-500 dark:text-white/52">
|
|
||||||
选中图片快捷洗图
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition hover:border-slate-950 hover:text-slate-950 dark:border-white/12 dark:bg-white/8 dark:text-white/60 dark:hover:border-white dark:hover:text-white"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
onClick={() => setAiWashQuickPanelOpen(false)}
|
onClick={() => setAiWashQuickPanelOpen(false)}
|
||||||
title="关闭"
|
title="关闭"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiWashConfigLoading ? (
|
{aiWashConfigLoading ? (
|
||||||
<div className="mb-3 flex items-center gap-2 rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/58">
|
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
正在加载 AI洗图 配置...
|
正在加载 AI洗图 配置...
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
||||||
<div className="rounded-[22px] border border-slate-200 bg-slate-50/70 p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
<div className="px-4 py-3">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-[14px] font-semibold">
|
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
渲染目标
|
目标
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||||||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<Select
|
||||||
{aiWashQuickPresets.map((preset) => {
|
onValueChange={(value) => {
|
||||||
const isActive = selectedAiWashQuickPreset?.id === preset.id;
|
setAiWashQuickPresetId(value);
|
||||||
return (
|
setAiWashQuickStatus("idle");
|
||||||
<button
|
}}
|
||||||
className={cn(
|
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
||||||
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
|
>
|
||||||
isActive
|
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||||
? "border-slate-950 bg-slate-950 text-white shadow-[0_10px_22px_rgba(15,23,42,0.16)] dark:border-white dark:bg-white dark:text-slate-950"
|
<SelectValue placeholder="选择渲染目标" />
|
||||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-950 hover:text-slate-950 dark:border-white/10 dark:bg-white/7 dark:text-white/64 dark:hover:border-white dark:hover:text-white"
|
</SelectTrigger>
|
||||||
)}
|
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||||
key={preset.id}
|
{aiWashQuickPresets.map((preset) => (
|
||||||
onClick={() => {
|
<SelectItem key={preset.id} value={preset.id}>
|
||||||
setAiWashQuickPresetId(preset.id);
|
{preset.title}
|
||||||
setAiWashQuickStatus("idle");
|
</SelectItem>
|
||||||
}}
|
))}
|
||||||
type="button"
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
<div className="truncate font-semibold text-[13px]">
|
{selectedAiWashQuickPreset?.description ? (
|
||||||
{preset.title}
|
<div className="mt-2 line-clamp-2 text-[12px] leading-5 text-slate-500 dark:text-white/46">
|
||||||
</div>
|
{selectedAiWashQuickPreset.description}
|
||||||
{preset.description ? (
|
</div>
|
||||||
<div
|
) : null}
|
||||||
className={cn(
|
|
||||||
"mt-1 line-clamp-2 text-[12px] leading-5",
|
|
||||||
isActive ? "text-white/72 dark:text-slate-700" : "text-slate-500 dark:text-white/46"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.description}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[22px] border border-slate-200 bg-white p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
<div className="px-4 py-3">
|
||||||
<div className="mb-2 text-[14px] font-semibold">参数设置</div>
|
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||||
|
参数
|
||||||
|
</div>
|
||||||
{shouldShowAiWashQuickModelSelector ? (
|
{shouldShowAiWashQuickModelSelector ? (
|
||||||
<div className="mb-3 space-y-1.5">
|
<div className="mb-3 space-y-1.5">
|
||||||
<Label className="text-[12px] text-slate-500 dark:text-white/54">
|
<Label className="text-[12px] font-medium text-slate-500 dark:text-white/54">
|
||||||
生图模型
|
生图模型
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={setAiWashQuickModelId}
|
onValueChange={setAiWashQuickModelId}
|
||||||
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 rounded-[15px] border-slate-200 bg-white text-[13px] shadow-[0_1px_2px_rgba(15,23,42,0.04)] dark:border-white/10 dark:bg-white/8">
|
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||||
<SelectValue placeholder="选择生图模型" />
|
<SelectValue placeholder="选择生图模型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||||
@ -8787,7 +8792,7 @@ export function ImageNode({
|
|||||||
</Select>
|
</Select>
|
||||||
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
|
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
|
||||||
{selectedAiWashQuickModel
|
{selectedAiWashQuickModel
|
||||||
? `当前模型:${selectedAiWashQuickModel.displayName} · ${
|
? `${selectedAiWashQuickModel.displayName} · ${
|
||||||
selectedAiWashQuickModel.unitCost > 0
|
selectedAiWashQuickModel.unitCost > 0
|
||||||
? `${selectedAiWashQuickModel.unitCost} 积分/张`
|
? `${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||||
: "未设置积分"
|
: "未设置积分"
|
||||||
@ -8798,15 +8803,15 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : selectedAiWashQuickModel ? (
|
) : selectedAiWashQuickModel ? (
|
||||||
<div className="mb-3 rounded-[15px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
<div className="mb-3 rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
||||||
管理员已锁定模型:{selectedAiWashQuickModel.displayName}
|
{selectedAiWashQuickModel.displayName}
|
||||||
{selectedAiWashQuickModel.unitCost > 0
|
{selectedAiWashQuickModel.unitCost > 0
|
||||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
|
||||||
{aiWashQuickControls.map((control) => {
|
{aiWashQuickControls.map((control) => {
|
||||||
const selectedIndex =
|
const selectedIndex =
|
||||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||||
@ -8816,14 +8821,14 @@ export function ImageNode({
|
|||||||
null;
|
null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-[16px] border border-slate-200 bg-slate-50/70 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.04]"
|
className="px-4 py-3"
|
||||||
key={control.id}
|
key={control.id}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="text-[13px] font-semibold">
|
<div className="text-[14px] font-semibold">
|
||||||
{control.label}
|
{control.label}
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-9 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[11px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
<span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||||||
{activeLevel?.label || selectedIndex + 1}
|
{activeLevel?.label || selectedIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -8839,7 +8844,7 @@ export function ImageNode({
|
|||||||
}
|
}
|
||||||
rangeClassName="bg-slate-950 dark:bg-white"
|
rangeClassName="bg-slate-950 dark:bg-white"
|
||||||
step={1}
|
step={1}
|
||||||
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_3px_rgba(15,23,42,0.06)]"
|
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_4px_rgba(15,23,42,0.06)]"
|
||||||
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
|
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
|
||||||
value={[selectedIndex]}
|
value={[selectedIndex]}
|
||||||
/>
|
/>
|
||||||
@ -8849,14 +8854,9 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiWashQuickPrompt ? (
|
<div className="px-3 py-3">
|
||||||
<div className="max-h-20 overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] leading-5 text-slate-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-white/48">
|
|
||||||
{aiWashQuickPrompt}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="h-11 w-full rounded-[18px] bg-slate-950 text-[14px] font-semibold text-white shadow-[0_12px_26px_rgba(15,23,42,0.16)] hover:bg-slate-900 hover:text-white disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||||||
disabled={
|
disabled={
|
||||||
isSubmittingAiWashQuick ||
|
isSubmittingAiWashQuick ||
|
||||||
!selectedAiWashQuickPreset ||
|
!selectedAiWashQuickPreset ||
|
||||||
@ -8866,18 +8866,19 @@ export function ImageNode({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{isSubmittingAiWashQuick ? (
|
{isSubmittingAiWashQuick ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{isSubmittingAiWashQuick
|
{isSubmittingAiWashQuick
|
||||||
? "正在 AI洗图"
|
? "生成中"
|
||||||
: `开始 ${aiWashQuickModeLabel}${
|
: `${aiWashQuickModeLabel}${
|
||||||
selectedAiWashQuickModel?.unitCost
|
selectedAiWashQuickModel?.unitCost
|
||||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
@ -8898,7 +8899,7 @@ export function ImageNode({
|
|||||||
createPortal(
|
createPortal(
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-slate-700 dark:bg-slate-900 dark:text-gray-100"
|
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-slate-200 bg-white px-2 py-1.5 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-slate-700 dark:bg-slate-900 dark:text-gray-100"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@ -8913,7 +8914,7 @@ export function ImageNode({
|
|||||||
180,
|
180,
|
||||||
Math.floor(toolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
|
Math.floor(toolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
|
||||||
)}px`,
|
)}px`,
|
||||||
overflowX: "auto",
|
overflowX: "visible",
|
||||||
scrollbarWidth: "none",
|
scrollbarWidth: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -8939,11 +8940,13 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageAddToChatEnabled && hasSelectedImagePrimaryToolbarActions ? (
|
{imageAddToChatEnabled &&
|
||||||
|
(hasSelectedImagePrimaryToolbarActions ||
|
||||||
|
hasSelectedImageSecondaryToolbarActions) ? (
|
||||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imagePickObjectEnabled ? (
|
{isSelectedImageActionInline("pickObject") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||||||
@ -8960,7 +8963,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageEnhanceEnabled ? (
|
{isSelectedImageActionInline("enhance") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@ -8977,7 +8980,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageRemoveBackgroundEnabled ? (
|
{isSelectedImageActionInline("removeBackground") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@ -8994,41 +8997,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageBoxCutoutEnabled ? (
|
{isSelectedImageActionInline("aiWashQuick") ? (
|
||||||
<Button
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
|
||||||
disabled={isProcessing}
|
|
||||||
onClick={() => handleAction("box-cutout")}
|
|
||||||
title={imageBoxCutoutItem.label}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<CanvasToolbarIcon
|
|
||||||
className="mr-1.5 h-4 w-4"
|
|
||||||
fallback={Scan}
|
|
||||||
item={imageBoxCutoutItem}
|
|
||||||
/>
|
|
||||||
{imageBoxCutoutItem.label}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{imageLocalEditEnabled ? (
|
|
||||||
<Button
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
|
||||||
disabled={isProcessing || isSubmittingLocalEdit}
|
|
||||||
onClick={() => handleAction("local-edit")}
|
|
||||||
title={imageLocalEditItem.label}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<CanvasToolbarIcon
|
|
||||||
className="mr-1.5 h-4 w-4"
|
|
||||||
fallback={Wand2}
|
|
||||||
item={imageLocalEditItem}
|
|
||||||
/>
|
|
||||||
{imageLocalEditItem.label}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{imageAiWashQuickEnabled ? (
|
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
"h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||||||
@ -9055,41 +9024,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageRemoveWatermarkEnabled ? (
|
{isSelectedImageActionInline("textEdit") ? (
|
||||||
<Button
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
|
||||||
disabled={isProcessing}
|
|
||||||
onClick={() => handleAction("watermark")}
|
|
||||||
title={imageRemoveWatermarkItem.label}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<CanvasToolbarIcon
|
|
||||||
className="mr-1.5 h-4 w-4"
|
|
||||||
fallback={Eraser}
|
|
||||||
item={imageRemoveWatermarkItem}
|
|
||||||
/>
|
|
||||||
{imageRemoveWatermarkItem.label}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{imageResizeEnabled ? (
|
|
||||||
<Button
|
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
|
||||||
disabled={isProcessing}
|
|
||||||
onClick={() => handleAction("resize")}
|
|
||||||
title={imageResizeItem.label}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<CanvasToolbarIcon
|
|
||||||
className="mr-1.5 h-4 w-4"
|
|
||||||
fallback={Maximize2}
|
|
||||||
item={imageResizeItem}
|
|
||||||
/>
|
|
||||||
{imageResizeItem.label}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{imageTextEditEnabled ? (
|
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||||||
@ -9106,7 +9041,75 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageMultiAngleEnabled ? (
|
{isSelectedImageActionInline("boxCutout") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("box-cutout")}
|
||||||
|
title={imageBoxCutoutItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Scan}
|
||||||
|
item={imageBoxCutoutItem}
|
||||||
|
/>
|
||||||
|
{imageBoxCutoutItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("localEdit") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing || isSubmittingLocalEdit}
|
||||||
|
onClick={() => handleAction("local-edit")}
|
||||||
|
title={imageLocalEditItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Wand2}
|
||||||
|
item={imageLocalEditItem}
|
||||||
|
/>
|
||||||
|
{imageLocalEditItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("removeWatermark") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("watermark")}
|
||||||
|
title={imageRemoveWatermarkItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Eraser}
|
||||||
|
item={imageRemoveWatermarkItem}
|
||||||
|
/>
|
||||||
|
{imageRemoveWatermarkItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("resize") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("resize")}
|
||||||
|
title={imageResizeItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Maximize2}
|
||||||
|
item={imageResizeItem}
|
||||||
|
/>
|
||||||
|
{imageResizeItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("multiAngle") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@ -9123,7 +9126,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageGenerate3dModelEnabled ? (
|
{isSelectedImageActionInline("generate3dModel") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@ -9140,7 +9143,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageGenerate3dWorldEnabled ? (
|
{isSelectedImageActionInline("generate3dWorld") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@ -9157,7 +9160,7 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{imageLayerSplitEnabled ? (
|
{isSelectedImageActionInline("layerSplit") ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@ -9174,6 +9177,108 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("extend") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("extend")}
|
||||||
|
title={imageExtendItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Expand}
|
||||||
|
item={imageExtendItem}
|
||||||
|
/>
|
||||||
|
{imageExtendItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("crop") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("crop")}
|
||||||
|
title={imageCropItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Crop}
|
||||||
|
item={imageCropItem}
|
||||||
|
/>
|
||||||
|
{imageCropItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("adjust") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("adjust")}
|
||||||
|
title={imageAdjustItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={SlidersHorizontal}
|
||||||
|
item={imageAdjustItem}
|
||||||
|
/>
|
||||||
|
{imageAdjustItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("flipRotate") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("flip-rotate")}
|
||||||
|
title={imageFlipRotateItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={FlipHorizontal2}
|
||||||
|
item={imageFlipRotateItem}
|
||||||
|
/>
|
||||||
|
{imageFlipRotateItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("promptRegenerate") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("prompt-regenerate")}
|
||||||
|
title={imagePromptRegenerateItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={RefreshCw}
|
||||||
|
item={imagePromptRegenerateItem}
|
||||||
|
/>
|
||||||
|
{imagePromptRegenerateItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("note") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("note")}
|
||||||
|
title={imageNoteItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={StickyNote}
|
||||||
|
item={imageNoteItem}
|
||||||
|
/>
|
||||||
|
{imageNoteItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{hasSelectedImagePrimaryToolbarActions &&
|
{hasSelectedImagePrimaryToolbarActions &&
|
||||||
hasSelectedImageSecondaryToolbarActions ? (
|
hasSelectedImageSecondaryToolbarActions ? (
|
||||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||||
@ -9201,7 +9306,91 @@ export function ImageNode({
|
|||||||
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
|
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
{imageExtendEnabled ? (
|
{isSelectedImageActionInMore("boxCutout") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("box-cutout")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Scan}
|
||||||
|
item={imageBoxCutoutItem}
|
||||||
|
/>
|
||||||
|
{imageBoxCutoutItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("localEdit") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("local-edit")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Wand2}
|
||||||
|
item={imageLocalEditItem}
|
||||||
|
/>
|
||||||
|
{imageLocalEditItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Eraser}
|
||||||
|
item={imageRemoveWatermarkItem}
|
||||||
|
/>
|
||||||
|
{imageRemoveWatermarkItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("resize") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("resize")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Maximize2}
|
||||||
|
item={imageResizeItem}
|
||||||
|
/>
|
||||||
|
{imageResizeItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("multiAngle") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("angle")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Box}
|
||||||
|
item={imageMultiAngleItem}
|
||||||
|
/>
|
||||||
|
{imageMultiAngleItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("generate3dModel") ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleAction("generate-3d-model")}
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Box}
|
||||||
|
item={imageGenerate3dModelItem}
|
||||||
|
/>
|
||||||
|
{imageGenerate3dModelItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("generate3dWorld") ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleAction("generate-3d-world")}
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Globe2}
|
||||||
|
item={imageGenerate3dWorldItem}
|
||||||
|
/>
|
||||||
|
{imageGenerate3dWorldItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("layerSplit") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Layers}
|
||||||
|
item={imageLayerSplitItem}
|
||||||
|
/>
|
||||||
|
{imageLayerSplitItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("extend") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
@ -9211,7 +9400,7 @@ export function ImageNode({
|
|||||||
{imageExtendItem.label}
|
{imageExtendItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imageCropEnabled ? (
|
{isSelectedImageActionInMore("crop") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("crop")}>
|
<DropdownMenuItem onClick={() => handleAction("crop")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
@ -9221,7 +9410,7 @@ export function ImageNode({
|
|||||||
{imageCropItem.label}
|
{imageCropItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imageAdjustEnabled ? (
|
{isSelectedImageActionInMore("adjust") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("adjust")}>
|
<DropdownMenuItem onClick={() => handleAction("adjust")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
@ -9231,7 +9420,7 @@ export function ImageNode({
|
|||||||
{imageAdjustItem.label}
|
{imageAdjustItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imageFlipRotateEnabled ? (
|
{isSelectedImageActionInMore("flipRotate") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("flip-rotate")}>
|
<DropdownMenuItem onClick={() => handleAction("flip-rotate")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
@ -9241,7 +9430,7 @@ export function ImageNode({
|
|||||||
{imageFlipRotateItem.label}
|
{imageFlipRotateItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imagePromptRegenerateEnabled ? (
|
{isSelectedImageActionInMore("promptRegenerate") ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleAction("prompt-regenerate")}
|
onClick={() => handleAction("prompt-regenerate")}
|
||||||
>
|
>
|
||||||
@ -9253,7 +9442,7 @@ export function ImageNode({
|
|||||||
{imagePromptRegenerateItem.label}
|
{imagePromptRegenerateItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{imageNoteEnabled ? (
|
{isSelectedImageActionInMore("note") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("note")}>
|
<DropdownMenuItem onClick={() => handleAction("note")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
|
|||||||
@ -71,8 +71,17 @@ const IMAGE_SIZE_OPTIONS = [
|
|||||||
{ value: "2K", label: "2K", disabled: false },
|
{ value: "2K", label: "2K", disabled: false },
|
||||||
{ value: "4K", label: "4K", disabled: false },
|
{ value: "4K", label: "4K", disabled: false },
|
||||||
] as const;
|
] 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,
|
"1K": 1024,
|
||||||
"2K": 2048,
|
"2K": 2048,
|
||||||
"4K": 4096,
|
"4K": 4096,
|
||||||
@ -143,16 +152,16 @@ export function ImageParamsBar({
|
|||||||
const fallbackCount = options[0] ?? 1;
|
const fallbackCount = options[0] ?? 1;
|
||||||
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
|
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
|
||||||
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
|
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
|
||||||
let imageSize = raw.imageSize || "2K";
|
let imageSize = normalizeImageSizeValue(raw.imageSize);
|
||||||
if (imageGenConfig) {
|
if (imageGenConfig) {
|
||||||
const sizeAllowed = (s: string) => {
|
const sizeAllowed = (s: ImageSizeValue) => {
|
||||||
if (s === "1K") return imageGenConfig.supports1K !== false;
|
if (s === "1K") return imageGenConfig.supports1K !== false;
|
||||||
if (s === "2K") return imageGenConfig.supports2K !== false;
|
if (s === "2K") return imageGenConfig.supports2K !== false;
|
||||||
if (s === "4K") return imageGenConfig.supports4K !== false;
|
if (s === "4K") return imageGenConfig.supports4K !== false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
if (!sizeAllowed(imageSize)) {
|
if (!sizeAllowed(imageSize)) {
|
||||||
imageSize = ["2K", "1K", "4K"].find(sizeAllowed) || "2K";
|
imageSize = (["2K", "1K", "4K"] as const).find(sizeAllowed) || "2K";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -209,7 +218,7 @@ export function ImageParamsBar({
|
|||||||
return getGrsaiGptImageOutputSize(aspectRatio, imageSize);
|
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") {
|
if (aspectRatio === "auto" || aspectRatio === "1:1") {
|
||||||
return { width: size, height: size };
|
return { width: size, height: size };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,29 +15,29 @@ interface RuxaImageParams {
|
|||||||
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
|
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuxaImageModelConfig {
|
||||||
|
supportsSize?: boolean;
|
||||||
|
supports1K?: boolean;
|
||||||
|
supports2K?: boolean;
|
||||||
|
supports4K?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UploadedImage {
|
interface UploadedImage {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
original: string;
|
original: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RESOLUTION = "1k";
|
const DEFAULT_RESOLUTION = "1K";
|
||||||
const DEFAULT_OUTPUT_FORMAT = "png";
|
const DEFAULT_OUTPUT_FORMAT = "png";
|
||||||
const POLL_INTERVAL_MS = 2000;
|
const POLL_INTERVAL_MS = 2000;
|
||||||
const POLL_MAX_ATTEMPTS = 90;
|
const POLL_MAX_ATTEMPTS = 240;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeRuxaModelId(modelId?: string) {
|
function normalizeRuxaModelId(modelId?: string) {
|
||||||
const raw = String(modelId || "").trim();
|
const raw = String(modelId || "").trim();
|
||||||
if (!raw) return "gpt-image-1.5";
|
if (!raw) {
|
||||||
return RUXA_MODEL_ID_ALIASES[raw] || raw;
|
throw new Error("RUXA 模型 ID 未配置");
|
||||||
}
|
}
|
||||||
|
return raw;
|
||||||
function isRuxaNanoBanana2Model(modelId?: string) {
|
|
||||||
const normalized = normalizeRuxaModelId(modelId);
|
|
||||||
return normalized === RUXA_NANO_BANANA_2_DOC_MODEL_ID;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUrlForCompare(url: string) {
|
function normalizeUrlForCompare(url: string) {
|
||||||
@ -68,7 +68,9 @@ function normalizeAspectRatio(aspectRatio?: string) {
|
|||||||
const normalized = aspectRatio.trim().toLowerCase();
|
const normalized = aspectRatio.trim().toLowerCase();
|
||||||
if (normalized === "auto") return undefined;
|
if (normalized === "auto") return undefined;
|
||||||
const ratio = aspectRatio.trim();
|
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 ratio;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -76,25 +78,65 @@ function normalizeAspectRatio(aspectRatio?: string) {
|
|||||||
|
|
||||||
function normalizeResolution(imageSize?: string) {
|
function normalizeResolution(imageSize?: string) {
|
||||||
if (!imageSize) return DEFAULT_RESOLUTION;
|
if (!imageSize) return DEFAULT_RESOLUTION;
|
||||||
const normalized = imageSize.trim().toLowerCase();
|
const normalized = imageSize.trim().toUpperCase();
|
||||||
if (["1k", "2k", "4k"].includes(normalized)) return normalized;
|
if (["1K", "2K", "4K"].includes(normalized)) return normalized;
|
||||||
return DEFAULT_RESOLUTION;
|
return DEFAULT_RESOLUTION;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOutputFormat(outputFormat?: string): "png" | "jpg" | "webp" {
|
function normalizeOutputFormat(outputFormat?: string): "png" | "jpg" {
|
||||||
if (!outputFormat) return DEFAULT_OUTPUT_FORMAT;
|
if (!outputFormat) return DEFAULT_OUTPUT_FORMAT;
|
||||||
const normalized = outputFormat.trim().toLowerCase();
|
const normalized = outputFormat.trim().toLowerCase();
|
||||||
if (normalized === "jpg" || normalized === "jpeg") return "jpg";
|
if (normalized === "jpg" || normalized === "jpeg") return "jpg";
|
||||||
if (normalized === "webp") return "webp";
|
|
||||||
return "png";
|
return "png";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRuxaOutputFormat(modelId: string | undefined, outputFormat?: string) {
|
function isResolutionSupported(resolution: string, config?: RuxaImageModelConfig | null) {
|
||||||
const normalized = normalizeOutputFormat(outputFormat);
|
if (config?.supportsSize === false) {
|
||||||
if (!isRuxaNanoBanana2Model(modelId)) {
|
return false;
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
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 {
|
function extractTaskId(payload: any): string | undefined {
|
||||||
@ -132,9 +174,16 @@ function extractStatus(payload: any): string | undefined {
|
|||||||
|
|
||||||
function extractErrorMessage(payload: any): string {
|
function extractErrorMessage(payload: any): string {
|
||||||
let parsedResultJson: any = null;
|
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 {
|
try {
|
||||||
parsedResultJson = JSON.parse(payload.resultJson);
|
parsedResultJson = JSON.parse(rawResultJson);
|
||||||
} catch {
|
} catch {
|
||||||
parsedResultJson = null;
|
parsedResultJson = null;
|
||||||
}
|
}
|
||||||
@ -148,7 +197,7 @@ function extractErrorMessage(payload: any): string {
|
|||||||
payload?.errorMessage ||
|
payload?.errorMessage ||
|
||||||
parsedResultJson?.failMsg ||
|
parsedResultJson?.failMsg ||
|
||||||
parsedResultJson?.error ||
|
parsedResultJson?.error ||
|
||||||
payload?.resultJson ||
|
rawResultJson ||
|
||||||
payload?.data?.message ||
|
payload?.data?.message ||
|
||||||
payload?.data?.error ||
|
payload?.data?.error ||
|
||||||
"RUXA 任务执行失败"
|
"RUXA 任务执行失败"
|
||||||
@ -388,48 +437,38 @@ export function createRuxaImage({ apiKey, baseURL }: RuxaImageConfig) {
|
|||||||
doGenerate: async (options: {
|
doGenerate: async (options: {
|
||||||
messages: Array<{ role: string; content: any }>;
|
messages: Array<{ role: string; content: any }>;
|
||||||
imageParams?: RuxaImageParams;
|
imageParams?: RuxaImageParams;
|
||||||
|
imageGenConfig?: RuxaImageModelConfig | null;
|
||||||
overrideModelId?: string;
|
overrideModelId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||||
const aspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
const aspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
||||||
const resolution = normalizeResolution(options.imageParams?.imageSize);
|
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 activeModelId = normalizeRuxaModelId(requestedModelId);
|
||||||
const outputFormat = normalizeRuxaOutputFormat(activeModelId, options.imageParams?.outputFormat);
|
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
|
||||||
const isNanoBanana2 = isRuxaNanoBanana2Model(activeModelId);
|
|
||||||
|
|
||||||
const createEndpoint = joinApiPath(baseURL, "/api/v1/tasks/create");
|
const createEndpoint = joinApiPath(baseURL, "/api/v1/tasks/create");
|
||||||
const callbackUrl = process.env.RUXA_CALLBACK_URL?.trim();
|
const callbackUrl = process.env.RUXA_CALLBACK_URL?.trim();
|
||||||
const input: Record<string, unknown> = {
|
const input = buildRuxaTaskInput({
|
||||||
prompt,
|
prompt,
|
||||||
output_format: outputFormat,
|
imageUrls,
|
||||||
};
|
aspectRatio,
|
||||||
if (isNanoBanana2) {
|
resolution,
|
||||||
input.size = aspectRatio || "1:1";
|
outputFormat,
|
||||||
} else {
|
modelConfig: options.imageGenConfig,
|
||||||
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;
|
|
||||||
}
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
input,
|
input,
|
||||||
model: activeModelId,
|
model: activeModelId,
|
||||||
...(callbackUrl ? { callBackUrl: callbackUrl } : {}),
|
...(callbackUrl ? { callback_url: callbackUrl } : {}),
|
||||||
};
|
};
|
||||||
if (activeModelId !== requestedModelId) {
|
console.log("[Ruxa Image] Creating task:", {
|
||||||
console.log("[Ruxa Image] Remapped modelId for provider compatibility:", {
|
model: activeModelId,
|
||||||
requestedModelId,
|
resolution: input.resolution || null,
|
||||||
activeModelId,
|
aspectRatio: input.aspect_ratio || input.size || null,
|
||||||
});
|
outputFormat,
|
||||||
}
|
imageInputCount: imageUrls.length,
|
||||||
|
});
|
||||||
const createResponse = await fetch(createEndpoint, {
|
const createResponse = await fetch(createEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -494,6 +533,7 @@ export function createRuxaImage({ apiKey, baseURL }: RuxaImageConfig) {
|
|||||||
doStream: async function* (options: {
|
doStream: async function* (options: {
|
||||||
messages: Array<{ role: string; content: any }>;
|
messages: Array<{ role: string; content: any }>;
|
||||||
imageParams?: RuxaImageParams;
|
imageParams?: RuxaImageParams;
|
||||||
|
imageGenConfig?: RuxaImageModelConfig | null;
|
||||||
overrideModelId?: string;
|
overrideModelId?: string;
|
||||||
}) {
|
}) {
|
||||||
const result = await this.doGenerate(options);
|
const result = await this.doGenerate(options);
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
export const CANVAS_TOOLBAR_CONFIG_SETTING_KEY = "canvas_toolbar_config";
|
export const CANVAS_TOOLBAR_CONFIG_SETTING_KEY = "canvas_toolbar_config";
|
||||||
|
export const DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT = 7;
|
||||||
|
export const MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT = 3;
|
||||||
|
export const MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT = 12;
|
||||||
|
|
||||||
export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
||||||
{
|
{
|
||||||
@ -716,6 +719,7 @@ export type CanvasToolbarItemConfig = {
|
|||||||
|
|
||||||
export type CanvasToolbarConfig = {
|
export type CanvasToolbarConfig = {
|
||||||
items: Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
items: Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
||||||
|
selectedImageInlineActionCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ITEM_BY_KEY = DEFAULT_CANVAS_TOOLBAR_ITEMS.reduce(
|
const DEFAULT_ITEM_BY_KEY = DEFAULT_CANVAS_TOOLBAR_ITEMS.reduce(
|
||||||
@ -732,12 +736,29 @@ const DEFAULT_ITEM_BY_KEY = DEFAULT_CANVAS_TOOLBAR_ITEMS.reduce(
|
|||||||
|
|
||||||
export const defaultCanvasToolbarConfig: CanvasToolbarConfig = {
|
export const defaultCanvasToolbarConfig: CanvasToolbarConfig = {
|
||||||
items: DEFAULT_ITEM_BY_KEY,
|
items: DEFAULT_ITEM_BY_KEY,
|
||||||
|
selectedImageInlineActionCount: DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeString(value: unknown, maxLength: number) {
|
function normalizeString(value: unknown, maxLength: number) {
|
||||||
return String(value || "").trim().slice(0, maxLength);
|
return String(value || "").trim().slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInteger(
|
||||||
|
value: unknown,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number
|
||||||
|
) {
|
||||||
|
const parsed =
|
||||||
|
typeof value === "number"
|
||||||
|
? value
|
||||||
|
: typeof value === "string"
|
||||||
|
? Number(value)
|
||||||
|
: Number.NaN;
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCanvasToolbarConfig(
|
export function normalizeCanvasToolbarConfig(
|
||||||
value: unknown
|
value: unknown
|
||||||
): CanvasToolbarConfig {
|
): CanvasToolbarConfig {
|
||||||
@ -769,7 +790,21 @@ export function normalizeCanvasToolbarConfig(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items };
|
return {
|
||||||
|
items,
|
||||||
|
selectedImageInlineActionCount: normalizeInteger(
|
||||||
|
raw.selectedImageInlineActionCount,
|
||||||
|
DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
|
MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT,
|
||||||
|
MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedImageInlineActionCount(
|
||||||
|
config: CanvasToolbarConfig | null | undefined
|
||||||
|
): number {
|
||||||
|
return normalizeCanvasToolbarConfig(config).selectedImageInlineActionCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultCanvasToolbarItem(
|
export function getDefaultCanvasToolbarItem(
|
||||||
|
|||||||
@ -587,6 +587,7 @@ export async function generateCanvasImage(
|
|||||||
...imageParams,
|
...imageParams,
|
||||||
imageCount: batchImageCount,
|
imageCount: batchImageCount,
|
||||||
},
|
},
|
||||||
|
imageGenConfig: selectedModel.model.imageGenConfig || null,
|
||||||
});
|
});
|
||||||
return result.text || "";
|
return result.text || "";
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ const MAX_STRING_LENGTH = 240;
|
|||||||
|
|
||||||
const SENSITIVE_KEY_PATTERN =
|
const SENSITIVE_KEY_PATTERN =
|
||||||
/(authorization|cookie|password|secret|token|apikey|api_key|accesskey|access_key|clientsecret|client_secret|credential|prompt|session|csrf|code)/i;
|
/(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 DATA_URL_PATTERN = /^data:[^;]+;base64,/i;
|
||||||
const EMBEDDED_DATA_URL_PATTERN = /data:[^;,\s]+;base64,[A-Za-z0-9+/=_-]+/gi;
|
const EMBEDDED_DATA_URL_PATTERN = /data:[^;,\s]+;base64,[A-Za-z0-9+/=_-]+/gi;
|
||||||
const EMBEDDED_URL_PATTERN = /https?:\/\/[^\s"'<>),]+/gi;
|
const EMBEDDED_URL_PATTERN = /https?:\/\/[^\s"'<>),]+/gi;
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
"clean:artifacts": "node scripts/clean-local-artifacts.mjs",
|
"clean:artifacts": "node scripts/clean-local-artifacts.mjs",
|
||||||
"smoke:routes": "node scripts/smoke-routes.mjs",
|
"smoke:routes": "node scripts/smoke-routes.mjs",
|
||||||
"verify:release": "node scripts/verify-release.mjs",
|
"verify:release": "node scripts/verify-release.mjs",
|
||||||
|
"verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts",
|
||||||
"backfill:agent-preview-images": "pnpm exec tsx scripts/backfill-agent-workflow-preview-images.ts",
|
"backfill:agent-preview-images": "pnpm exec tsx scripts/backfill-agent-workflow-preview-images.ts",
|
||||||
"rewrite:agent-preview-image-urls": "pnpm exec tsx scripts/rewrite-agent-workflow-preview-image-urls.ts",
|
"rewrite:agent-preview-image-urls": "pnpm exec tsx scripts/rewrite-agent-workflow-preview-image-urls.ts",
|
||||||
"format": "pnpm exec ultracite fix",
|
"format": "pnpm exec ultracite fix",
|
||||||
|
|||||||
208
scripts/verify-ruxa-image-models.ts
Normal file
208
scripts/verify-ruxa-image-models.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { config as loadEnv } from "dotenv";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db } from "../lib/db";
|
||||||
|
import { aiModel, aiProvider } from "../lib/db/schema";
|
||||||
|
|
||||||
|
loadEnv({ path: ".env.local" });
|
||||||
|
|
||||||
|
type ModelExpectation = {
|
||||||
|
modelId: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPECTED_MODELS: ModelExpectation[] = [
|
||||||
|
{ modelId: "google/nano-banana-2", displayName: "nano-banana-2「专线」" },
|
||||||
|
{ modelId: "google/nano-banana-pro", displayName: "nano-banana-pro「专线」" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
providerId: string;
|
||||||
|
providerName: string;
|
||||||
|
providerType: string;
|
||||||
|
modelId: string;
|
||||||
|
displayName: string;
|
||||||
|
supportsImageGen: boolean | null;
|
||||||
|
supportsVision: boolean | null;
|
||||||
|
isEnabled: boolean | null;
|
||||||
|
isHidden: boolean | null;
|
||||||
|
healthStatus: string | null;
|
||||||
|
imageGenConfig: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getConfigFlag(
|
||||||
|
config: Record<string, unknown> | null,
|
||||||
|
key: string
|
||||||
|
): boolean | null {
|
||||||
|
const raw = config?.[key];
|
||||||
|
return typeof raw === "boolean" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
providerId: aiProvider.id,
|
||||||
|
providerName: aiProvider.name,
|
||||||
|
providerType: aiProvider.type,
|
||||||
|
modelId: aiModel.modelId,
|
||||||
|
displayName: aiModel.displayName,
|
||||||
|
supportsImageGen: aiModel.supportsImageGen,
|
||||||
|
supportsVision: aiModel.supportsVision,
|
||||||
|
isEnabled: aiModel.isEnabled,
|
||||||
|
isHidden: aiModel.isHidden,
|
||||||
|
healthStatus: aiModel.healthStatus,
|
||||||
|
imageGenConfig: aiModel.imageGenConfig,
|
||||||
|
})
|
||||||
|
.from(aiModel)
|
||||||
|
.innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiProvider.type, "ruxa-image"),
|
||||||
|
sql`LOWER(${aiProvider.name}) LIKE ${"%ruxa%"}`,
|
||||||
|
sql`LOWER(${aiModel.modelId}) IN (${"google/nano-banana-2"}, ${"google/nano-banana-pro"})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const found = new Map<string, Row[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const bucket = found.get(row.providerId) ?? [];
|
||||||
|
bucket.push(row);
|
||||||
|
found.set(row.providerId, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = [...found.entries()].map(([providerId, models]) => ({
|
||||||
|
providerId,
|
||||||
|
providerName: models[0]?.providerName ?? "",
|
||||||
|
providerType: models[0]?.providerType ?? "",
|
||||||
|
modelCount: models.length,
|
||||||
|
models,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
|
const expectByModelId = new Map(
|
||||||
|
EXPECTED_MODELS.map((entry) => [entry.modelId, entry.displayName])
|
||||||
|
);
|
||||||
|
const expectedSet = new Set(expectByModelId.keys());
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!expectedSet.has(row.modelId)) {
|
||||||
|
failures.push(`Unexpected model present in RUXA image provider: ${row.modelId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedDisplayName = expectByModelId.get(row.modelId);
|
||||||
|
if (expectedDisplayName && row.displayName !== expectedDisplayName) {
|
||||||
|
failures.push(
|
||||||
|
`${row.providerName} ${row.modelId}: displayName mismatch (expected "${expectedDisplayName}", got "${row.displayName}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config =
|
||||||
|
row.imageGenConfig &&
|
||||||
|
typeof row.imageGenConfig === "object" &&
|
||||||
|
!Array.isArray(row.imageGenConfig)
|
||||||
|
? (row.imageGenConfig as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (row.supportsImageGen !== true) {
|
||||||
|
failures.push(`${row.providerName} ${row.modelId}: supportsImageGen is not true`);
|
||||||
|
}
|
||||||
|
if (row.supportsVision !== true) {
|
||||||
|
failures.push(`${row.providerName} ${row.modelId}: supportsVision is not true`);
|
||||||
|
}
|
||||||
|
if (row.isEnabled !== true) {
|
||||||
|
failures.push(`${row.providerName} ${row.modelId}: isEnabled is not true`);
|
||||||
|
}
|
||||||
|
if (row.isHidden === true) {
|
||||||
|
failures.push(`${row.providerName} ${row.modelId}: isHidden should be false`);
|
||||||
|
}
|
||||||
|
if (row.healthStatus !== "healthy") {
|
||||||
|
failures.push(
|
||||||
|
`${row.providerName} ${row.modelId}: healthStatus is ${row.healthStatus ?? "null"}, expected healthy`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getConfigFlag(config, "supportsSize") !== true) {
|
||||||
|
failures.push(`${row.providerName} ${row.modelId}: imageGenConfig.supportsSize should be true`);
|
||||||
|
}
|
||||||
|
if (getConfigFlag(config, "supports1K") !== true) {
|
||||||
|
failures.push(
|
||||||
|
`${row.providerName} ${row.modelId}: imageGenConfig.supports1K should be true`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (getConfigFlag(config, "supports2K") !== true) {
|
||||||
|
failures.push(
|
||||||
|
`${row.providerName} ${row.modelId}: imageGenConfig.supports2K should be true`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (getConfigFlag(config, "supports4K") !== true) {
|
||||||
|
failures.push(
|
||||||
|
`${row.providerName} ${row.modelId}: imageGenConfig.supports4K should be true`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const providerModels = provider.models.map((item) => item.modelId);
|
||||||
|
for (const expected of EXPECTED_MODELS) {
|
||||||
|
if (!providerModels.includes(expected.modelId)) {
|
||||||
|
failures.push(
|
||||||
|
`${provider.providerName}: missing required model ${expected.modelId} (${expected.displayName})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerSummary = providers
|
||||||
|
.sort((a, b) => a.providerName.localeCompare(b.providerName))
|
||||||
|
.map((provider) => {
|
||||||
|
return {
|
||||||
|
providerName: provider.providerName,
|
||||||
|
providerType: provider.providerType,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
hasAllTargets: EXPECTED_MODELS.every((expected) =>
|
||||||
|
provider.models.some((row) => row.modelId === expected.modelId)
|
||||||
|
),
|
||||||
|
modelRows: provider.models
|
||||||
|
.sort((a, b) => a.modelId.localeCompare(b.modelId))
|
||||||
|
.map((row) => ({
|
||||||
|
modelId: row.modelId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
isEnabled: row.isEnabled,
|
||||||
|
healthStatus: row.healthStatus,
|
||||||
|
imageSize4K: getConfigFlag(
|
||||||
|
row.imageGenConfig &&
|
||||||
|
typeof row.imageGenConfig === "object" &&
|
||||||
|
!Array.isArray(row.imageGenConfig)
|
||||||
|
? (row.imageGenConfig as Record<string, unknown>)
|
||||||
|
: null,
|
||||||
|
"supports4K"
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
expected: EXPECTED_MODELS,
|
||||||
|
providerSummary,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error("RUXA image model check failed:");
|
||||||
|
failures.forEach((item) => console.error(`- ${item}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("RUXA image model check passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("RUXA image model verification failed to execute:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -122,7 +122,10 @@ interface CanvasStore {
|
|||||||
|
|
||||||
// 节点操作
|
// 节点操作
|
||||||
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
|
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;
|
deleteNode: (id: string) => void;
|
||||||
deleteSelectedNodes: () => void;
|
deleteSelectedNodes: () => void;
|
||||||
addSuppressedMediaKeys: (keys: string[]) => void;
|
addSuppressedMediaKeys: (keys: string[]) => void;
|
||||||
@ -230,82 +233,97 @@ export const useCanvasStore = create<CanvasStore>()(
|
|||||||
|
|
||||||
// 节点操作
|
// 节点操作
|
||||||
addNode: (nodeData) => {
|
addNode: (nodeData) => {
|
||||||
const imageMetadata =
|
const imageMetadata =
|
||||||
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
|
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
|
||||||
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
|
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const shouldPreserveImagePosition =
|
const shouldPreserveImagePosition =
|
||||||
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
|
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
|
||||||
|
|
||||||
const resolveImagePosition = (
|
//通用避让算法:对所有节点类型生效(除 image 的 placementMode兼容情况外)
|
||||||
basePosition: Position,
|
// 历史原因:早期只对 image 类型做避让,导致 world3d/model3d 等重复添加会叠到一起。
|
||||||
size: Size,
|
// 现在统一避让,image 的 allowOverlap/overlay 元数据仍可跳过避让以保持向后兼容。
|
||||||
nodes: CanvasNode[]
|
const resolveAutoPosition = (
|
||||||
): Position => {
|
basePosition: Position,
|
||||||
if (nodeData.type !== 'image' || shouldPreserveImagePosition) return basePosition;
|
size: Size,
|
||||||
const padding = 20;
|
nodes: CanvasNode[],
|
||||||
const width = size?.width || 300;
|
nodeType: NodeType
|
||||||
const height = size?.height || 300;
|
): Position => {
|
||||||
const stepX = width + padding;
|
// image 类型若标记了"允许重叠",保持原坐标以兼容既有行为
|
||||||
const stepY = height + padding;
|
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 => {
|
const overlaps = (pos: Position) => nodes.some(n => {
|
||||||
if (n.type !== 'image') return false;
|
if (n.type !== nodeType) return false;
|
||||||
const nSize = n.size || { width: 300, height: 300 };
|
const nSize = n.size || { width:300, height:300 };
|
||||||
return !(
|
return !(
|
||||||
pos.x + width + padding <= n.position.x ||
|
pos.x + width + padding <= n.position.x ||
|
||||||
pos.x >= n.position.x + nSize.width + padding ||
|
pos.x >= n.position.x + nSize.width + padding ||
|
||||||
pos.y + height + padding <= n.position.y ||
|
pos.y + height + padding <= n.position.y ||
|
||||||
pos.y >= n.position.y + nSize.height + padding
|
pos.y >= n.position.y + nSize.height + padding
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!overlaps(basePosition)) return basePosition;
|
if (!overlaps(basePosition)) return basePosition;
|
||||||
|
|
||||||
const maxRings = 20;
|
const maxRings =20;
|
||||||
for (let ring = 1; ring <= maxRings; ring++) {
|
for (let ring =1; ring <= maxRings; ring++) {
|
||||||
for (let dx = -ring; dx <= ring; dx++) {
|
for (let dx = -ring; dx <= ring; dx++) {
|
||||||
for (let dy = -ring; dy <= ring; dy++) {
|
for (let dy = -ring; dy <= ring; dy++) {
|
||||||
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
||||||
const candidate = {
|
const candidate = {
|
||||||
x: basePosition.x + dx * stepX,
|
x: basePosition.x + dx * stepX,
|
||||||
y: basePosition.y + dy * stepY,
|
y: basePosition.y + dy * stepY,
|
||||||
};
|
};
|
||||||
if (!overlaps(candidate)) {
|
if (!overlaps(candidate)) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return basePosition;
|
return basePosition;
|
||||||
};
|
};
|
||||||
|
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const basePosition = nodeData.position || { x: 0, y: 0 };
|
const basePosition = nodeData.position || { x:0, y:0 };
|
||||||
const nodeSize = nodeData.size || { width: 300, height: 300 };
|
const nodeSize = nodeData.size || { width:300, height:300 };
|
||||||
const finalPosition = resolveImagePosition(basePosition, nodeSize, get().nodes);
|
const finalPosition = resolveAutoPosition(basePosition, nodeSize, get().nodes, nodeData.type);
|
||||||
const node: CanvasNode = {
|
const node: CanvasNode = {
|
||||||
...nodeData,
|
...nodeData,
|
||||||
id,
|
id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
position: finalPosition,
|
position: finalPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.nodes.push(node);
|
state.nodes.push(node);
|
||||||
if (node.type === 'image') {
|
if (node.type === 'image') {
|
||||||
state.lastAddedNodeId = id;
|
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) => {
|
updateNode: (id, updates) => set((state) => {
|
||||||
const index = state.nodes.findIndex(n => n.id === id);
|
const index = state.nodes.findIndex(n => n.id === id);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user