feat: add canvas ai wash quick panel
This commit is contained in:
parent
b12d61b371
commit
9da001e3ee
@ -3439,6 +3439,10 @@ export function RealtimeCanvasDialog({
|
||||
"h-9 w-9 rounded-[16px] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
|
||||
const compactToolButtonActiveClass =
|
||||
"border-[#111111] bg-[#111111] text-white shadow-[0_10px_20px_rgba(0,0,0,0.12)] hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:hover:bg-white/90 dark:hover:text-[#111111]";
|
||||
const modeSwitchIconSlotClass =
|
||||
"inline-flex h-4 w-4 shrink-0 items-center justify-center leading-none [&>*]:!h-3.5 [&>*]:!w-3.5 [&>img]:object-contain";
|
||||
const modeSwitchIconClass =
|
||||
"h-3.5 w-3.5 max-h-3.5 max-w-3.5 object-contain transition group-hover:opacity-100";
|
||||
const currentModeLabel =
|
||||
brushMode === "select"
|
||||
? "鼠标"
|
||||
@ -3513,12 +3517,14 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("realtime")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "realtime"}
|
||||
className="h-3.5 w-3.5 transition group-hover:opacity-100"
|
||||
fallback={Paintbrush}
|
||||
item={realtimeModeRealtimeItem}
|
||||
/>
|
||||
<span className={modeSwitchIconSlotClass}>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "realtime"}
|
||||
className={modeSwitchIconClass}
|
||||
fallback={Paintbrush}
|
||||
item={realtimeModeRealtimeItem}
|
||||
/>
|
||||
</span>
|
||||
<span className="max-w-[9rem] truncate">
|
||||
{realtimeModeRealtimeItem.label}
|
||||
</span>
|
||||
@ -3534,12 +3540,14 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("enhance-pro")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "enhance-pro"}
|
||||
className="h-3.5 w-3.5 transition group-hover:opacity-100"
|
||||
fallback={Wand2}
|
||||
item={realtimeModeEnhanceItem}
|
||||
/>
|
||||
<span className={modeSwitchIconSlotClass}>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "enhance-pro"}
|
||||
className={modeSwitchIconClass}
|
||||
fallback={Wand2}
|
||||
item={realtimeModeEnhanceItem}
|
||||
/>
|
||||
</span>
|
||||
<span className="max-w-[9rem] truncate">
|
||||
{realtimeModeEnhanceItem.label}
|
||||
</span>
|
||||
@ -3556,12 +3564,14 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("ai-wash")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "ai-wash"}
|
||||
className="h-3.5 w-3.5 transition group-hover:opacity-100"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashModeIconUrl}
|
||||
/>
|
||||
<span className={modeSwitchIconSlotClass}>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "ai-wash"}
|
||||
className={modeSwitchIconClass}
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashModeIconUrl}
|
||||
/>
|
||||
</span>
|
||||
<span className="max-w-[9rem] truncate">{aiWashModeLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@ -132,6 +132,13 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@ -167,6 +174,18 @@ import {
|
||||
isCanvasToolbarItemEnabled,
|
||||
type CanvasToolbarConfig,
|
||||
} from "@/lib/canvas-toolbar-config";
|
||||
import {
|
||||
AI_WASH_CONTROL_ORDER,
|
||||
AI_WASH_DEFAULT_MODE_LABEL,
|
||||
defaultAiWashConfig,
|
||||
getEnabledAiWashPresets,
|
||||
normalizeAiWashConfig,
|
||||
type AiWashConfig,
|
||||
type AiWashControl,
|
||||
type AiWashControlId,
|
||||
type AiWashControlLevel,
|
||||
type AiWashPreset,
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type {
|
||||
@ -222,6 +241,7 @@ interface LayerSplitBounds {
|
||||
}
|
||||
|
||||
const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||||
"ai-wash",
|
||||
"rmbg",
|
||||
"watermark",
|
||||
"enhance",
|
||||
@ -235,6 +255,41 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||||
"generate-3d-world",
|
||||
]);
|
||||
|
||||
type AiWashResultStatus =
|
||||
| "idle"
|
||||
| "submitting"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
type AiWashModelOption = {
|
||||
id: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
providerName?: string;
|
||||
providerType?: string;
|
||||
isDefault?: boolean;
|
||||
unitCost: number;
|
||||
creditDiscountLabel?: string | null;
|
||||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||||
availabilityLabel?: string | null;
|
||||
selectable?: boolean;
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
|
||||
type AiWashConfigResponse = {
|
||||
success?: boolean;
|
||||
config?: AiWashConfig;
|
||||
lockedModels?: AiWashModelOption[];
|
||||
};
|
||||
|
||||
type AiWashModelOptionsResponse = {
|
||||
success?: boolean;
|
||||
defaultModelId?: string | null;
|
||||
models?: AiWashModelOption[];
|
||||
};
|
||||
|
||||
type AngleEditPanelMode = "subject" | "camera";
|
||||
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
||||
type CropInteractionState = {
|
||||
@ -449,6 +504,8 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
|
||||
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
|
||||
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
|
||||
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const AI_WASH_TASK_POLL_LIMIT = 80;
|
||||
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
|
||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
|
||||
);
|
||||
@ -459,6 +516,118 @@ const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
function getAiWashPresetControl(
|
||||
preset: AiWashPreset | null | undefined,
|
||||
controlId: AiWashControlId
|
||||
): AiWashControl | null {
|
||||
if (!preset) return null;
|
||||
return preset.controls.find((control) => control.id === controlId) || null;
|
||||
}
|
||||
|
||||
function getAiWashControlLevelByIndex(
|
||||
control: AiWashControl | null,
|
||||
selectedIndex: number
|
||||
): AiWashControlLevel | null {
|
||||
if (!control || control.levels.length === 0) return null;
|
||||
const clampedIndex = Math.max(
|
||||
0,
|
||||
Math.min(control.levels.length - 1, Math.floor(selectedIndex))
|
||||
);
|
||||
return control.levels[clampedIndex] || control.levels[0] || null;
|
||||
}
|
||||
|
||||
function composeAiWashQuickPrompt(params: {
|
||||
preset: AiWashPreset;
|
||||
selectedLevelIndexByControl: Record<string, number>;
|
||||
}) {
|
||||
const promptParts = [params.preset.prompt.trim()].filter(Boolean);
|
||||
|
||||
for (const controlId of AI_WASH_CONTROL_ORDER) {
|
||||
const level = getAiWashControlLevelByIndex(
|
||||
getAiWashPresetControl(params.preset, controlId),
|
||||
params.selectedLevelIndexByControl[controlId] ?? 0
|
||||
);
|
||||
const fragment = String(level?.promptFragment || "").trim();
|
||||
if (fragment) {
|
||||
promptParts.push(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
return promptParts.join("\n");
|
||||
}
|
||||
|
||||
function resolveAiWashQuickAdminModelId(params: {
|
||||
preset: AiWashPreset;
|
||||
selectedLevelIndexByControl: Record<string, number>;
|
||||
}) {
|
||||
for (const controlId of AI_WASH_CONTROL_ORDER) {
|
||||
const level = getAiWashControlLevelByIndex(
|
||||
getAiWashPresetControl(params.preset, controlId),
|
||||
params.selectedLevelIndexByControl[controlId] ?? 0
|
||||
);
|
||||
const levelModelId = String(level?.modelId || "").trim();
|
||||
if (levelModelId) return levelModelId;
|
||||
}
|
||||
return String(params.preset.modelId || "").trim();
|
||||
}
|
||||
|
||||
function getAiWashStatusLabel(status?: AiWashResultStatus) {
|
||||
switch (status) {
|
||||
case "submitting":
|
||||
return "提交中";
|
||||
case "running":
|
||||
return "生成中";
|
||||
case "succeeded":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "idle":
|
||||
default:
|
||||
return "待生成";
|
||||
}
|
||||
}
|
||||
|
||||
async function pollAiWashQuickTask(taskId: string) {
|
||||
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
|
||||
const response = await fetch(
|
||||
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.success !== true || !payload?.task) {
|
||||
throw new Error(payload?.error || "查询 AI洗图任务失败");
|
||||
}
|
||||
const task = payload.task as {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
result?: {
|
||||
images?: Array<{ thumbnailUrl?: string; originalUrl?: string }>;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
prompt?: string;
|
||||
} | null;
|
||||
};
|
||||
const status = String(task.status || "").toLowerCase();
|
||||
if (status === "succeeded" || status === "completed") {
|
||||
const image = task.result?.images?.[0];
|
||||
if (!image?.originalUrl) {
|
||||
throw new Error("AI洗图已完成,但没有拿到结果图片");
|
||||
}
|
||||
return {
|
||||
imageUrl: image.originalUrl,
|
||||
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
|
||||
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
|
||||
prompt: String(task.result?.prompt || "").trim(),
|
||||
};
|
||||
}
|
||||
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||||
throw new Error(task.error || "AI洗图任务失败");
|
||||
}
|
||||
await sleep(AI_WASH_TASK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error("AI洗图任务轮询超时,请稍后重试");
|
||||
}
|
||||
|
||||
const isTerminal3DFailureStatus = (status?: string | null) =>
|
||||
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
|
||||
|
||||
@ -850,6 +1019,30 @@ export function ImageNode({
|
||||
? routeParams.id
|
||||
: "";
|
||||
const data = node.data as ImageNodeData;
|
||||
const { data: aiWashConfigPayload, isLoading: aiWashConfigLoading } =
|
||||
useSWR<AiWashConfigResponse>("/api/ai-wash/config", fetcher);
|
||||
const aiWashQuickConfig = useMemo(
|
||||
() =>
|
||||
normalizeAiWashConfig(
|
||||
aiWashConfigPayload?.config || defaultAiWashConfig()
|
||||
),
|
||||
[aiWashConfigPayload?.config]
|
||||
);
|
||||
const aiWashQuickPresets = useMemo(
|
||||
() => getEnabledAiWashPresets(aiWashQuickConfig).filter((preset) => preset.enabled),
|
||||
[aiWashQuickConfig]
|
||||
);
|
||||
const imageAiWashQuickEnabled =
|
||||
aiWashQuickConfig.enabled && aiWashQuickPresets.length > 0;
|
||||
const aiWashQuickModeLabel =
|
||||
aiWashQuickConfig.modeLabel || AI_WASH_DEFAULT_MODE_LABEL;
|
||||
const {
|
||||
data: aiWashModelOptionsPayload,
|
||||
isLoading: aiWashModelOptionsLoading,
|
||||
} = useSWR<AiWashModelOptionsResponse>(
|
||||
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
|
||||
fetcher
|
||||
);
|
||||
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
|
||||
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
|
||||
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
|
||||
@ -1029,6 +1222,7 @@ export function ImageNode({
|
||||
imageMultiAngleEnabled ||
|
||||
imageGenerate3dModelEnabled ||
|
||||
imageGenerate3dWorldEnabled ||
|
||||
imageAiWashQuickEnabled ||
|
||||
imageLayerSplitEnabled;
|
||||
const hasSelectedImageSecondaryToolbarActions =
|
||||
hasSelectedImageMoreMenu || imageDownloadEnabled;
|
||||
@ -1162,6 +1356,15 @@ export function ImageNode({
|
||||
const [isAddingToChat, setIsAddingToChat] = useState(false);
|
||||
const [isCompareMode, setIsCompareMode] = useState(false);
|
||||
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
|
||||
const [aiWashQuickPanelOpen, setAiWashQuickPanelOpen] = useState(false);
|
||||
const [aiWashQuickPresetId, setAiWashQuickPresetId] = useState("");
|
||||
const [aiWashQuickModelId, setAiWashQuickModelId] = useState("");
|
||||
const [aiWashQuickLevels, setAiWashQuickLevels] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false);
|
||||
const [aiWashQuickStatus, setAiWashQuickStatus] =
|
||||
useState<AiWashResultStatus>("idle");
|
||||
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
|
||||
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({
|
||||
width: 0,
|
||||
@ -1207,12 +1410,128 @@ export function ImageNode({
|
||||
const hasActiveImageAdjustments = !isDefaultImageAdjustments(
|
||||
activeImageAdjustments
|
||||
);
|
||||
const selectedAiWashQuickPreset = useMemo(
|
||||
() =>
|
||||
aiWashQuickPresets.find((preset) => preset.id === aiWashQuickPresetId) ||
|
||||
aiWashQuickPresets.find((preset) => preset.isDefault) ||
|
||||
aiWashQuickPresets[0] ||
|
||||
null,
|
||||
[aiWashQuickPresetId, aiWashQuickPresets]
|
||||
);
|
||||
const aiWashQuickControls = useMemo(
|
||||
() =>
|
||||
AI_WASH_CONTROL_ORDER.map((controlId) =>
|
||||
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
|
||||
).filter(Boolean) as AiWashControl[],
|
||||
[selectedAiWashQuickPreset]
|
||||
);
|
||||
const aiWashQuickSelectableModels = useMemo(
|
||||
() => aiWashModelOptionsPayload?.models || [],
|
||||
[aiWashModelOptionsPayload?.models]
|
||||
);
|
||||
const aiWashQuickLockedModels = useMemo(
|
||||
() => aiWashConfigPayload?.lockedModels || [],
|
||||
[aiWashConfigPayload?.lockedModels]
|
||||
);
|
||||
const aiWashQuickModelOptionByRef = useMemo(() => {
|
||||
const map = new Map<string, AiWashModelOption>();
|
||||
[...aiWashQuickLockedModels, ...aiWashQuickSelectableModels].forEach((model) => {
|
||||
map.set(model.id, model);
|
||||
map.set(model.modelId, model);
|
||||
});
|
||||
return map;
|
||||
}, [aiWashQuickLockedModels, aiWashQuickSelectableModels]);
|
||||
const aiWashQuickAdminModelRef = useMemo(
|
||||
() =>
|
||||
selectedAiWashQuickPreset
|
||||
? resolveAiWashQuickAdminModelId({
|
||||
preset: selectedAiWashQuickPreset,
|
||||
selectedLevelIndexByControl: aiWashQuickLevels,
|
||||
})
|
||||
: "",
|
||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||
);
|
||||
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
|
||||
const effectiveAiWashQuickModelRef =
|
||||
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
||||
const selectedAiWashQuickModel = useMemo(
|
||||
() =>
|
||||
aiWashQuickModelOptionByRef.get(effectiveAiWashQuickModelRef) ||
|
||||
aiWashQuickModelOptionByRef.get(aiWashQuickModelId) ||
|
||||
null,
|
||||
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
||||
);
|
||||
const aiWashQuickPrompt = useMemo(
|
||||
() =>
|
||||
selectedAiWashQuickPreset
|
||||
? composeAiWashQuickPrompt({
|
||||
preset: selectedAiWashQuickPreset,
|
||||
selectedLevelIndexByControl: aiWashQuickLevels,
|
||||
})
|
||||
: "",
|
||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchProcessingEnabled) {
|
||||
setSceneFusionDialogOpen(false);
|
||||
}
|
||||
}, [batchProcessingEnabled]);
|
||||
useEffect(() => {
|
||||
if (aiWashQuickPresets.length === 0) {
|
||||
setAiWashQuickPresetId("");
|
||||
return;
|
||||
}
|
||||
setAiWashQuickPresetId((current) => {
|
||||
if (aiWashQuickPresets.some((preset) => preset.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return (
|
||||
aiWashQuickPresets.find((preset) => preset.isDefault)?.id ||
|
||||
aiWashQuickPresets[0]?.id ||
|
||||
""
|
||||
);
|
||||
});
|
||||
}, [aiWashQuickPresets]);
|
||||
useEffect(() => {
|
||||
if (!selectedAiWashQuickPreset) {
|
||||
setAiWashQuickLevels({});
|
||||
return;
|
||||
}
|
||||
setAiWashQuickLevels(
|
||||
AI_WASH_CONTROL_ORDER.reduce<Record<string, number>>(
|
||||
(accumulator, controlId) => {
|
||||
accumulator[controlId] =
|
||||
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
|
||||
?.defaultLevel ?? 0;
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
setAiWashQuickStatus("idle");
|
||||
}, [selectedAiWashQuickPreset]);
|
||||
useEffect(() => {
|
||||
const fallbackModelId =
|
||||
String(aiWashModelOptionsPayload?.defaultModelId || "").trim() ||
|
||||
aiWashQuickSelectableModels.find((model) => model.isDefault)?.id ||
|
||||
aiWashQuickSelectableModels[0]?.id ||
|
||||
"";
|
||||
setAiWashQuickModelId((current) => {
|
||||
if (
|
||||
current &&
|
||||
aiWashQuickSelectableModels.some((model) => model.id === current)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return fallbackModelId;
|
||||
});
|
||||
}, [aiWashModelOptionsPayload?.defaultModelId, aiWashQuickSelectableModels]);
|
||||
useEffect(() => {
|
||||
if (!imageAiWashQuickEnabled || !isSelected) {
|
||||
setAiWashQuickPanelOpen(false);
|
||||
}
|
||||
}, [imageAiWashQuickEnabled, isSelected]);
|
||||
useEffect(() => {
|
||||
if (!imageAdjustPanelOpen) return;
|
||||
setImageAdjustmentsDraft(storedImageAdjustments);
|
||||
@ -4013,6 +4332,44 @@ export function ImageNode({
|
||||
return { top: 0, left: 0, maxWidth: 480 };
|
||||
};
|
||||
|
||||
const getAiWashQuickPanelPosition = (
|
||||
anchor: { top: number; left: number; maxWidth: number }
|
||||
) => {
|
||||
if (typeof window === "undefined") {
|
||||
return { top: 0, left: 0, width: 360, maxHeight: 520 };
|
||||
}
|
||||
|
||||
const panelWidth = 360;
|
||||
const panelHeight = 540;
|
||||
const gap = 10;
|
||||
const toolbarHeight = Math.max(
|
||||
42,
|
||||
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
|
||||
);
|
||||
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
|
||||
getFloatingViewportBounds(panelHeight);
|
||||
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
|
||||
|
||||
let top = anchor.top + toolbarHeight + gap;
|
||||
if (top + panelHeight > safeBottom) {
|
||||
top = anchor.top - panelHeight - gap;
|
||||
}
|
||||
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 {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: Math.max(260, Math.min(panelHeight, safeBottom - top)),
|
||||
};
|
||||
};
|
||||
|
||||
const getBoxCutoutToolbarPosition = () => {
|
||||
if (containerRef.current && typeof window !== "undefined") {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
@ -5204,6 +5561,195 @@ export function ImageNode({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitAiWashQuick = useCallback(async () => {
|
||||
if (!data.src) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
}
|
||||
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src)) {
|
||||
toast.error("图片仍在本地,上传完成后再进行 AI洗图");
|
||||
return;
|
||||
}
|
||||
if (!aiWashQuickConfig.enabled) {
|
||||
toast.error("AI洗图当前未开启,请先在系统设置中启用");
|
||||
return;
|
||||
}
|
||||
if (!selectedAiWashQuickPreset) {
|
||||
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
||||
return;
|
||||
}
|
||||
if (!effectiveAiWashQuickModelRef) {
|
||||
toast.error("请先选择一个可用的生图模型");
|
||||
return;
|
||||
}
|
||||
if (isProcessing || isSubmittingAiWashQuick) {
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
|
||||
setImageAdjustPanelOpen(false);
|
||||
setCropPanelOpen(false);
|
||||
setCropInteraction(null);
|
||||
setFlipRotatePanelOpen(false);
|
||||
setAnglePanelOpen(false);
|
||||
setTextEditPanelOpen(false);
|
||||
setNotePanelOpen(false);
|
||||
resetBoxCutoutState();
|
||||
resetLocalEditState();
|
||||
setIsProcessing(true);
|
||||
setIsSubmittingAiWashQuick(true);
|
||||
setAiWashQuickStatus("submitting");
|
||||
|
||||
const actionName = aiWashQuickModeLabel || "AI洗图";
|
||||
const resultTitle = `${displayTitle}-${selectedAiWashQuickPreset.title}`;
|
||||
const newNodeId = addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 20,
|
||||
y: node.position.y,
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: `${actionName}处理中...`,
|
||||
title: resultTitle,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
},
|
||||
},
|
||||
connections: [node.id],
|
||||
});
|
||||
selectNode(newNodeId);
|
||||
|
||||
try {
|
||||
const submitResponse = await fetch("/api/ai-wash/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: aiWashQuickPrompt,
|
||||
referenceImage: data.src,
|
||||
selectedModelId: effectiveAiWashQuickModelRef,
|
||||
imageSize: aiWashQuickConfig.imageSize,
|
||||
promptExtend: aiWashQuickConfig.promptExtend,
|
||||
title: selectedAiWashQuickPreset.title,
|
||||
}),
|
||||
});
|
||||
const submitPayload = await submitResponse.json().catch(() => ({}));
|
||||
|
||||
if (
|
||||
!submitResponse.ok ||
|
||||
submitPayload?.success !== true ||
|
||||
!submitPayload?.taskId
|
||||
) {
|
||||
throw new Error(submitPayload?.error || "AI洗图提交失败");
|
||||
}
|
||||
|
||||
setAiWashQuickStatus("running");
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: aiWashQuickPrompt || `${actionName}处理中...`,
|
||||
title: resultTitle,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
taskId: submitPayload.taskId,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
aiWashImageSize: aiWashQuickConfig.imageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completed = await pollAiWashQuickTask(submitPayload.taskId);
|
||||
const resolvedModelLabel =
|
||||
completed.modelId ||
|
||||
selectedAiWashQuickModel?.displayName ||
|
||||
selectedAiWashQuickModel?.modelId ||
|
||||
effectiveAiWashQuickModelRef;
|
||||
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: completed.imageUrl,
|
||||
status: "completed",
|
||||
prompt: completed.prompt || aiWashQuickPrompt || `${actionName}结果`,
|
||||
title: resultTitle,
|
||||
originalSrc: data.src,
|
||||
model: resolvedModelLabel,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
taskId: submitPayload.taskId,
|
||||
thumbnailUrl: completed.thumbnailUrl,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
aiWashImageSize: aiWashQuickConfig.imageSize,
|
||||
modelId: resolvedModelLabel,
|
||||
},
|
||||
},
|
||||
});
|
||||
setAiWashQuickStatus("succeeded");
|
||||
setAiWashQuickPanelOpen(false);
|
||||
toast.success("AI洗图结果已生成到画布");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "AI洗图失败";
|
||||
console.error("[ImageNode] AI洗图快捷生成失败:", error);
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "failed",
|
||||
prompt: `${actionName}失败`,
|
||||
title: `${resultTitle}-失败`,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
error: message,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
},
|
||||
},
|
||||
});
|
||||
setAiWashQuickStatus("failed");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setIsSubmittingAiWashQuick(false);
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
aiWashQuickConfig.enabled,
|
||||
aiWashQuickConfig.imageSize,
|
||||
aiWashQuickConfig.promptExtend,
|
||||
aiWashQuickLevels,
|
||||
aiWashQuickModeLabel,
|
||||
aiWashQuickPrompt,
|
||||
data.src,
|
||||
displayTitle,
|
||||
effectiveAiWashQuickModelRef,
|
||||
isProcessing,
|
||||
isSubmittingAiWashQuick,
|
||||
node,
|
||||
resetBoxCutoutState,
|
||||
resetLocalEditState,
|
||||
selectNode,
|
||||
selectedAiWashQuickModel?.displayName,
|
||||
selectedAiWashQuickModel?.modelId,
|
||||
selectedAiWashQuickPreset,
|
||||
updateNode,
|
||||
]);
|
||||
|
||||
// 处理图片编辑操作 - 集成我们现有的 API
|
||||
const handleAction = async (action: string) => {
|
||||
if (!data.src) {
|
||||
@ -5221,6 +5767,20 @@ export function ImageNode({
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
if (action === "ai-wash") {
|
||||
setCropPanelOpen(false);
|
||||
setCropInteraction(null);
|
||||
setImageAdjustPanelOpen(false);
|
||||
setFlipRotatePanelOpen(false);
|
||||
setAnglePanelOpen(false);
|
||||
setTextEditPanelOpen(false);
|
||||
setNotePanelOpen(false);
|
||||
resetBoxCutoutState();
|
||||
resetLocalEditState();
|
||||
setAiWashQuickPanelOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
setAiWashQuickPanelOpen(false);
|
||||
if (action !== "local-edit") {
|
||||
resetLocalEditState();
|
||||
}
|
||||
@ -6897,6 +7457,7 @@ export function ImageNode({
|
||||
const cropPanelPosition = getCropPanelPosition();
|
||||
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
|
||||
const toolbarPosition = getToolbarPosition();
|
||||
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition(toolbarPosition);
|
||||
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
||||
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
||||
const anglePanelPosition = getAnglePanelPosition();
|
||||
@ -8076,6 +8637,252 @@ export function ImageNode({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{aiWashQuickPanelOpen &&
|
||||
imageAiWashQuickEnabled &&
|
||||
isSelected &&
|
||||
!isMultiSelected &&
|
||||
data.src &&
|
||||
!isEditingTitle &&
|
||||
!sceneFusionDialogOpen &&
|
||||
!promptDialogOpen &&
|
||||
!threedGenerationDialogMode &&
|
||||
!notePanelOpen &&
|
||||
!boxCutoutMode &&
|
||||
!localEditMode &&
|
||||
!cropPanelOpen &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<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"
|
||||
data-canvas-no-drag="true"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
top: aiWashQuickPanelPosition.top,
|
||||
left: aiWashQuickPanelPosition.left,
|
||||
width: aiWashQuickPanelPosition.width,
|
||||
maxHeight: aiWashQuickPanelPosition.maxHeight,
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<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">
|
||||
<CanvasToolbarIcon
|
||||
className="h-5 w-5"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-[16px] tracking-[-0.03em]">
|
||||
{aiWashQuickModeLabel}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] text-slate-500 dark:text-white/52">
|
||||
选中图片快捷洗图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
onClick={() => setAiWashQuickPanelOpen(false)}
|
||||
title="关闭"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载 AI洗图 配置...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<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="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[14px] font-semibold">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
渲染目标
|
||||
</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">
|
||||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{aiWashQuickPresets.map((preset) => {
|
||||
const isActive = selectedAiWashQuickPreset?.id === preset.id;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
|
||||
isActive
|
||||
? "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"
|
||||
: "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"
|
||||
)}
|
||||
key={preset.id}
|
||||
onClick={() => {
|
||||
setAiWashQuickPresetId(preset.id);
|
||||
setAiWashQuickStatus("idle");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate font-semibold text-[13px]">
|
||||
{preset.title}
|
||||
</div>
|
||||
{preset.description ? (
|
||||
<div
|
||||
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 className="rounded-[22px] border border-slate-200 bg-white p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="mb-2 text-[14px] font-semibold">参数设置</div>
|
||||
{shouldShowAiWashQuickModelSelector ? (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<Label className="text-[12px] text-slate-500 dark:text-white/54">
|
||||
生图模型
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={setAiWashQuickModelId}
|
||||
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">
|
||||
<SelectValue placeholder="选择生图模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||
{aiWashQuickSelectableModels.map((model) => (
|
||||
<SelectItem
|
||||
disabled={model.selectable === false}
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-500 text-xs dark:text-white/52">
|
||||
{model.unitCost > 0
|
||||
? `${model.unitCost} 积分/张`
|
||||
: "未计费"}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
|
||||
{selectedAiWashQuickModel
|
||||
? `当前模型:${selectedAiWashQuickModel.displayName} · ${
|
||||
selectedAiWashQuickModel.unitCost > 0
|
||||
? `${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||
: "未设置积分"
|
||||
}`
|
||||
: aiWashModelOptionsLoading
|
||||
? "正在加载可选模型..."
|
||||
: "没有可选模型"}
|
||||
</div>
|
||||
</div>
|
||||
) : 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">
|
||||
管理员已锁定模型:{selectedAiWashQuickModel.displayName}
|
||||
{selectedAiWashQuickModel.unitCost > 0
|
||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{aiWashQuickControls.map((control) => {
|
||||
const selectedIndex =
|
||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||
const activeLevel =
|
||||
getAiWashControlLevelByIndex(control, selectedIndex) ||
|
||||
control.levels[0] ||
|
||||
null;
|
||||
return (
|
||||
<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]"
|
||||
key={control.id}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[13px] font-semibold">
|
||||
{control.label}
|
||||
</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">
|
||||
{activeLevel?.label || selectedIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
className="w-full"
|
||||
max={Math.max(0, control.levels.length - 1)}
|
||||
min={0}
|
||||
onValueChange={([next]) =>
|
||||
setAiWashQuickLevels((current) => ({
|
||||
...current,
|
||||
[control.id]: Math.round(next ?? 0),
|
||||
}))
|
||||
}
|
||||
rangeClassName="bg-slate-950 dark:bg-white"
|
||||
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)]"
|
||||
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
|
||||
value={[selectedIndex]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiWashQuickPrompt ? (
|
||||
<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
|
||||
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"
|
||||
disabled={
|
||||
isSubmittingAiWashQuick ||
|
||||
!selectedAiWashQuickPreset ||
|
||||
!effectiveAiWashQuickModelRef
|
||||
}
|
||||
onClick={() => void handleSubmitAiWashQuick()}
|
||||
type="button"
|
||||
>
|
||||
{isSubmittingAiWashQuick ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSubmittingAiWashQuick
|
||||
? "正在 AI洗图"
|
||||
: `开始 ${aiWashQuickModeLabel}${
|
||||
selectedAiWashQuickModel?.unitCost
|
||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||||
: ""
|
||||
}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{isSelected &&
|
||||
!isMultiSelected &&
|
||||
data.src &&
|
||||
@ -8221,6 +9028,33 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageAiWashQuickEnabled ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||||
aiWashQuickPanelOpen
|
||||
? "bg-slate-950 text-white hover:bg-slate-900 hover:text-white dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100"
|
||||
: "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
)}
|
||||
disabled={isProcessing || isSubmittingAiWashQuick}
|
||||
onClick={() => handleAction("ai-wash")}
|
||||
title={aiWashQuickModeLabel}
|
||||
variant="ghost"
|
||||
>
|
||||
{isSubmittingAiWashQuick ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
active={aiWashQuickPanelOpen}
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
/>
|
||||
)}
|
||||
{aiWashQuickModeLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageRemoveWatermarkEnabled ? (
|
||||
<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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user