Your Name c16862bd90
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix agent workflow prompt state sync
2026-03-26 19:00:37 +08:00

2428 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useLayoutEffect, useState, useCallback, use, useRef, useMemo } from "react";
import { useRouter, useParams, useSearchParams } from "next/navigation";
import useSWR from "swr";
import {
AlertCircle,
ArrowLeft,
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
MousePointer,
Hand,
Plus,
ImagePlus,
Type,
Loader2,
MessageCircle,
Download,
Upload,
Wand2,
Grid3X3,
Settings,
Pencil,
PenTool,
Box,
Layers,
FolderOpen,
Crosshair,
Scan,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import { canvasService, imageGenerationService } from "@/services/canvas";
import {
AgentWorkflowPanelNew,
type AgentWorkflowInputs,
} from "@/components/agents/AgentWorkflowPanelNew";
import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew";
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew";
// 深色主题组件 V2 (新版布局)
import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2";
import {
AgentWorkflowBrandStorePanel,
AgentWorkflowPanelDarkV2,
} from "@/components/agents/AgentWorkflowPanelDarkV2";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet";
import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn, fetcher } from "@/lib/utils";
import {
defaultAgentWorkflowUiConfig,
flattenVisibleAgentWorkflowSteps,
normalizeAgentWorkflowUiConfig,
sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config";
import {
createEmptyAgentWorkflowMaterialSelections,
normalizeAgentWorkflowMaterialSelections,
} from "@/lib/agents/workflow-material-config";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import {
createEmptyAgentModeWorkspaceState,
normalizeAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectPersistedState,
createEmptyAgentWorkflowPersistedState,
createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config";
import { agentProjectService } from "@/services/agent-projects";
import type {
AgentPreset,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
AgentGenerationProjectWorkspaceState,
AgentModeWorkspaceState,
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
AgentWorkflowPersistedState,
AgentWorkflowSelectedBrandModel,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepKey,
} from "@/types/agent";
interface PageProps {
params: Promise<{ id: string }>;
}
// 图片尺寸预设
const SIZE_PRESETS = [
{ label: "1:1", value: "1:1", width: 1024, height: 1024 },
{ label: "3:2", value: "3:2", width: 1536, height: 1024 },
{ label: "2:3", value: "2:3", width: 1024, height: 1536 },
{ label: "4:3", value: "4:3", width: 1365, height: 1024 },
{ label: "3:4", value: "3:4", width: 1024, height: 1365 },
{ label: "16:9", value: "16:9", width: 1820, height: 1024 },
{ label: "9:16", value: "9:16", width: 1024, height: 1820 },
];
function hasMeaningfulWorkflowState(
workflowState: AgentWorkflowPersistedState | null | undefined
) {
if (!workflowState) return false;
const inputs = workflowState.inputs;
const hasInputs = Boolean(
String(inputs?.primarySku || "").trim() ||
String(inputs?.secondarySku || "").trim() ||
String(inputs?.brand || "").trim() ||
String(inputs?.store || "").trim() ||
String(inputs?.priceBand || "").trim()
);
const hasSelections = Object.values(workflowState.selections || {}).some((value) =>
Boolean(String(value || "").trim())
);
const hasBrandModel = Boolean(String(workflowState.selectedBrandModel?.imageUrl || "").trim());
const hasPreset = Boolean(String(workflowState.selectedAgentPresetId || "").trim());
return hasInputs || hasSelections || hasBrandModel || hasPreset;
}
const FLOATING_PANEL_WIDTH = 460;
const FLOATING_PANEL_INSET = 16;
const DOCKED_PANEL_INSET = 0;
const FLOATING_UI_GAP = 16;
const FLOATING_BOTTOM_INSET = 20;
function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
const normalized = String(projectId || "").trim().toLowerCase().replace(/-/g, "");
if (!/^[0-9a-f]{32}$/.test(normalized)) {
return projectId;
}
const scopeSuffix = scope === "agent-mode" ? "a93e17a60001" : "a93e17a60000";
const scoped = `${normalized.slice(0, 20)}${scopeSuffix}`;
return [
scoped.slice(0, 8),
scoped.slice(8, 12),
scoped.slice(12, 16),
scoped.slice(16, 20),
scoped.slice(20, 32),
].join("-");
}
function buildDefaultAgentProjectTitle() {
const now = new Date();
const month = `${now.getMonth() + 1}`.padStart(2, "0");
const day = `${now.getDate()}`.padStart(2, "0");
const hour = `${now.getHours()}`.padStart(2, "0");
const minute = `${now.getMinutes()}`.padStart(2, "0");
return `生图项目 ${month}-${day} ${hour}:${minute}`;
}
function buildSaveAsAgentProjectTitle(currentTitle?: string | null) {
const normalized = String(currentTitle || "").trim();
if (!normalized) {
return `${buildDefaultAgentProjectTitle()} 副本`;
}
if (normalized.endsWith("副本")) {
return normalized;
}
return `${normalized} 副本`;
}
function areStringArraysEqual(left: string[], right: string[]) {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
return Array.from(
new Set(
(urls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
}
function areResultCollectionsEqual(
left: AgentGenerationProjectResultCollections,
right: AgentGenerationProjectResultCollections
) {
return (
areStringArraysEqual(
normalizeResultCollectionUrls(left.scene_generation),
normalizeResultCollectionUrls(right.scene_generation)
) &&
areStringArraysEqual(
normalizeResultCollectionUrls(left.detail_generation),
normalizeResultCollectionUrls(right.detail_generation)
) &&
areStringArraysEqual(
normalizeResultCollectionUrls(left.detail_replacement),
normalizeResultCollectionUrls(right.detail_replacement)
) &&
areStringArraysEqual(
normalizeResultCollectionUrls(left.sku_replacement),
normalizeResultCollectionUrls(right.sku_replacement)
)
);
}
function getFirstResultUrlFromCollections(
collections: AgentGenerationProjectResultCollections
) {
return (
normalizeResultCollectionUrls(collections.scene_generation)[0] ||
normalizeResultCollectionUrls(collections.detail_generation)[0] ||
normalizeResultCollectionUrls(collections.detail_replacement)[0] ||
normalizeResultCollectionUrls(collections.sku_replacement)[0] ||
null
);
}
export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
const searchParams = useSearchParams();
const id = (routeParams?.id as string) || use(params).id;
const router = useRouter();
const mode = searchParams.get("mode");
const shouldOpenBatchProcessing = searchParams.get("batch") === "1";
const goHome = useCallback(() => {
if (typeof window !== "undefined") {
window.location.assign("/");
}
}, []);
// Store state
const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const canUndo = useCanvasStore(canvasSelectors.canUndo);
const canRedo = useCanvasStore(canvasSelectors.canRedo);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const nodes = useCanvasStore(canvasSelectors.nodes);
// Store actions
const setProject = useCanvasStore((state) => state.setProject);
const setProjectTitle = useCanvasStore((state) => state.setProjectTitle);
const setTool = useCanvasStore((state) => state.setTool);
const zoomIn = useCanvasStore((state) => state.zoomIn);
const zoomOut = useCanvasStore((state) => state.zoomOut);
const undo = useCanvasStore((state) => state.undo);
const redo = useCanvasStore((state) => state.redo);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const clearCanvas = useCanvasStore((state) => state.clearCanvas);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const addNode = useCanvasStore((state) => state.addNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const updateNode = useCanvasStore((state) => state.updateNode);
const setGenerating = useCanvasStore((state) => state.setGenerating);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction);
// Local state
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
const [viewportWidth, setViewportWidth] = useState(0);
const [workflowInputs, setWorkflowInputs] = useState<AgentWorkflowInputs>({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
priceBand: "",
});
const [workflowMaterials, setWorkflowMaterials] = useState<AgentWorkflowMaterialSelections>(
() => createEmptyAgentWorkflowMaterialSelections()
);
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
const [selectedBrandModel, setSelectedBrandModel] =
useState<AgentWorkflowSelectedBrandModel | null>(null);
const workflowInputsRef = useRef(workflowInputs);
const workflowMaterialsRef = useRef(workflowMaterials);
const workflowSelectionsRef = useRef(workflowSelections);
const selectedBrandModelRef = useRef(selectedBrandModel);
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState<string | null>(null);
const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow");
const [agentModeState, setAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const [agentWorkspaceState, setAgentWorkspaceState] =
useState<AgentGenerationProjectWorkspaceState>(
() => createEmptyAgentGenerationProjectWorkspaceState()
);
const [agentProjectTitle, setAgentProjectTitle] = useState("未命名生图项目");
const [agentProjectThumbnail, setAgentProjectThumbnail] = useState<string | null>(null);
const [agentProjectResultCollections, setAgentProjectResultCollections] =
useState<AgentGenerationProjectResultCollections>({});
const [agentProjectDeletedResultCollections, setAgentProjectDeletedResultCollections] =
useState<AgentGenerationProjectResultCollections>({});
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<string | null>(null);
const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false);
const [isAgentProjectSheetOpen, setIsAgentProjectSheetOpen] = useState(false);
const [isAgentProjectMenuOpen, setIsAgentProjectMenuOpen] = useState(false);
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
"pending" | "project" | "local" | "empty"
>("pending");
const requestedAgentProjectId = searchParams.get("agentProjectId");
const agentProjectId = requestedAgentProjectId ? requestedAgentProjectId.trim() : "";
const hasActiveAgentProject = Boolean(agentProjectId);
const agentModeChatId = useMemo(
() => buildScopedCanvasChatId(agentProjectId || id, "agent-mode"),
[agentProjectId, id]
);
const { data: siteSettingsData } = useSWR<{
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
agentEnabled?: boolean;
canvasAgentModeEnabled?: boolean;
realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentWorkflowBrandModelConfig?: unknown;
agentWorkflowUiConfig?: {
steps?: Array<{
key: AgentWorkflowStepKey;
title: string;
description?: string | null;
enabled?: boolean;
sortOrder?: number;
options?: Array<{
key: string;
buttonName: string;
description?: string | null;
previewImageUrl?: string | null;
enabled?: boolean;
sortOrder?: number;
promptFragment?: string | null;
}>;
}>;
};
}>("/api/site-settings", fetcher);
// 生成配置
const [prompt, setPrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [aspectRatio, setAspectRatio] = useState("auto");
const [imageCount, setImageCount] = useState(1);
const previousNodeIdsRef = useRef<Set<string>>(new Set());
const previousBrandForModelRef = useRef("");
const autoSelectedBrandModelBrandRef = useRef("");
const autoTitleAttemptedRef = useRef<Set<string>>(new Set());
const autoTitleInFlightRef = useRef<string | null>(null);
const autoThumbnailSyncedRef = useRef<string | null>(null);
const lastLoadedCanvasIdRef = useRef<string>("");
const requestedAgentMode = mode === "agent";
const isAgentWorkspaceEnabled = siteSettingsData?.agentEnabled !== false;
const isCanvasAgentModeEnabled = siteSettingsData?.canvasAgentModeEnabled !== false;
const isAgentMode =
isAgentWorkspaceEnabled && requestedAgentMode;
const hasLoadedCurrentAgentProject =
!requestedAgentMode || !agentProjectId || loadedAgentProjectId === agentProjectId;
const resolvedWorkflowUiConfig = useMemo(
() => normalizeAgentWorkflowUiConfig(siteSettingsData?.agentWorkflowUiConfig),
[siteSettingsData?.agentWorkflowUiConfig]
);
const sanitizedWorkflowSelections = useMemo(
() =>
sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: workflowSelections,
}),
[resolvedWorkflowUiConfig, workflowSelections]
);
const resolvedBrandModelConfig = useMemo(
() => normalizeAgentWorkflowBrandModelConfig(siteSettingsData?.agentWorkflowBrandModelConfig),
[siteSettingsData?.agentWorkflowBrandModelConfig]
);
// 获取选中的图片节点
const selectedImageNode = selectedNodeIds.length === 1
? nodes.find(n => n.id === selectedNodeIds[0] && n.type === 'image')
: null;
const resolvedPanelWidth = viewportWidth > 0
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
: FLOATING_PANEL_WIDTH;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP;
const updateCanvasSearchParams = useCallback(
(updates: Record<string, string | null>) => {
const nextParams = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "") {
nextParams.delete(key);
} else {
nextParams.set(key, value);
}
});
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `/canvas/${id}?${nextQuery}` : `/canvas/${id}`);
},
[id, router, searchParams]
);
const handleToggleAgentMode = useCallback(() => {
if (isAgentMode) {
updateCanvasSearchParams({
mode: null,
agentPresetId: null,
agentProjectId: null,
});
return;
}
updateCanvasSearchParams({
mode: "agent",
agentPresetId: null,
agentProjectId: agentProjectId || null,
});
setIsPanelOpen(true);
setPanelMode("chat");
}, [agentProjectId, isAgentMode, updateCanvasSearchParams]);
const buildCurrentWorkflowPersistedState = useCallback((): AgentWorkflowPersistedState => {
return {
inputs: {
primarySku: workflowInputs.primarySku.trim(),
secondarySku: workflowInputs.secondarySku.trim(),
brand: workflowInputs.brand.trim(),
store: workflowInputs.store.trim(),
priceBand: workflowInputs.priceBand.trim(),
},
materials: workflowMaterials,
selections: sanitizedWorkflowSelections,
selectedBrandModel,
selectedAgentPresetId: selectedAgentPresetId || null,
currentStage: currentAgentStage,
agentModeState,
};
}, [
agentModeState,
currentAgentStage,
selectedAgentPresetId,
selectedBrandModel,
workflowInputs,
workflowMaterials,
sanitizedWorkflowSelections,
]);
const currentWorkflowPersistedStateRef = useRef<AgentWorkflowPersistedState>(
buildCurrentWorkflowPersistedState()
);
useEffect(() => {
currentWorkflowPersistedStateRef.current = buildCurrentWorkflowPersistedState();
}, [buildCurrentWorkflowPersistedState]);
// 加载画布数据
useLayoutEffect(() => {
console.log('[Canvas] Loading project:', id);
const loadProject = async () => {
const preservedWorkflowState = currentWorkflowPersistedStateRef.current;
const shouldPreserveCurrentWorkflowState =
requestedAgentMode &&
lastLoadedCanvasIdRef.current === id &&
hasMeaningfulWorkflowState(preservedWorkflowState);
setIsLoading(true);
setWorkflowHydrationSource("pending");
if (!shouldPreserveCurrentWorkflowState) {
setWorkflowInputs({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
priceBand: "",
});
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
}
setAgentModeState(createEmptyAgentModeWorkspaceState());
setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState());
setAgentProjectTitle("未命名生图项目");
setAgentProjectThumbnail(null);
setAgentProjectResultCollections({});
setAgentProjectDeletedResultCollections({});
setLoadedAgentProjectId(null);
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
const result = await canvasService.getProject(id);
if (result.success && result.data) {
useCanvasStore.getState().setProject(id, result.data.title);
if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) {
setWorkflowInputs({
primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(),
secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(),
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
priceBand: String(persistedWorkflow.inputs?.priceBand || "").trim(),
});
setWorkflowMaterials(
normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)
);
setWorkflowSelections(persistedWorkflow.selections || {});
setSelectedBrandModel(
persistedWorkflow.selectedBrandModel
? {
...persistedWorkflow.selectedBrandModel,
brandName: String(persistedWorkflow.selectedBrandModel.brandName || "").trim(),
id: String(persistedWorkflow.selectedBrandModel.id || "").trim(),
imageUrl: String(persistedWorkflow.selectedBrandModel.imageUrl || "").trim(),
assetId:
String(persistedWorkflow.selectedBrandModel.assetId || "").trim() || null,
displayName:
String(persistedWorkflow.selectedBrandModel.displayName || "").trim() || null,
mimeType:
String(persistedWorkflow.selectedBrandModel.mimeType || "").trim() || null,
sortOrder: Number.isFinite(
Number(persistedWorkflow.selectedBrandModel.sortOrder)
)
? Math.max(
0,
Math.min(
999,
Math.round(Number(persistedWorkflow.selectedBrandModel.sortOrder))
)
)
: 0,
}
: null
);
setSelectedAgentPresetId(
String(persistedWorkflow.selectedAgentPresetId || "").trim() || null
);
setCurrentAgentStage(
persistedWorkflow.currentStage === "mode" ? "mode" : "workflow"
);
setAgentModeState(
normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState)
);
setWorkflowHydrationSource("project");
} else if (requestedAgentMode && shouldPreserveCurrentWorkflowState) {
setWorkflowHydrationSource("project");
} else {
setWorkflowHydrationSource("empty");
}
} else {
setWorkflowHydrationSource("empty");
}
} else {
// 画布不存在时,创建一个新画布
console.log('[Canvas] 画布不存在,创建新画布:', id);
useCanvasStore.getState().setProject(id, requestedAgentMode ? '智能体工作台' : '未命名画布');
setWorkflowHydrationSource("empty");
if (!requestedAgentMode) {
// 保存新画布到数据库
try {
await canvasService.saveCanvasState(id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select' });
} catch (error) {
console.error('[Canvas] 创建新画布失败:', error);
}
}
}
setIsLoading(false);
lastLoadedCanvasIdRef.current = id;
};
loadProject();
}, [id, requestedAgentMode]);
useEffect(() => {
const updateViewportWidth = () => setViewportWidth(window.innerWidth);
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
useEffect(() => {
if (isAgentMode) {
setIsPanelOpen(true);
setPanelMode("chat");
}
}, [isAgentMode]);
useEffect(() => {
if (shouldOpenBatchProcessing) {
setSceneFusionDialogOpen(true);
}
}, [shouldOpenBatchProcessing]);
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
const buildPersistedCanvasData = useCallback(() => {
return {
...getCanvasState(),
};
}, [
getCanvasState,
]);
const buildPersistedAgentProjectState = useCallback(() => {
const workflowState: AgentWorkflowPersistedState = {
inputs: {
primarySku: workflowInputs.primarySku.trim(),
secondarySku: workflowInputs.secondarySku.trim(),
brand: workflowInputs.brand.trim(),
store: workflowInputs.store.trim(),
priceBand: workflowInputs.priceBand.trim(),
},
materials: workflowMaterials,
selections: sanitizedWorkflowSelections,
selectedBrandModel,
selectedAgentPresetId: selectedAgentPresetId || null,
currentStage: currentAgentStage,
agentModeState,
};
return {
workflowState,
workspaceState: agentWorkspaceState,
resultCollections: {
scene_generation: normalizeResultCollectionUrls(
agentProjectResultCollections.scene_generation
),
detail_generation: normalizeResultCollectionUrls(
agentProjectResultCollections.detail_generation
),
detail_replacement: normalizeResultCollectionUrls(
agentProjectResultCollections.detail_replacement
),
sku_replacement: normalizeResultCollectionUrls(
agentProjectResultCollections.sku_replacement
),
},
deletedResultCollections: {
scene_generation: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.scene_generation
),
detail_generation: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.detail_generation
),
detail_replacement: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.detail_replacement
),
sku_replacement: normalizeResultCollectionUrls(
agentProjectDeletedResultCollections.sku_replacement
),
},
resultImageUrls: Array.from(
new Set([
...normalizeResultCollectionUrls(agentProjectResultCollections.scene_generation),
...normalizeResultCollectionUrls(agentProjectResultCollections.detail_generation),
...normalizeResultCollectionUrls(agentProjectResultCollections.detail_replacement),
...normalizeResultCollectionUrls(agentProjectResultCollections.sku_replacement),
])
),
};
}, [
agentModeState,
agentProjectDeletedResultCollections,
agentProjectResultCollections,
agentWorkspaceState,
currentAgentStage,
selectedAgentPresetId,
selectedBrandModel,
workflowInputs,
workflowMaterials,
sanitizedWorkflowSelections,
]);
const persistedAgentProjectStateRef = useRef(buildPersistedAgentProjectState());
useEffect(() => {
persistedAgentProjectStateRef.current = buildPersistedAgentProjectState();
}, [buildPersistedAgentProjectState]);
useEffect(() => {
if (isAgentMode) return;
if (typeof window === "undefined") return;
if (workflowHydrationSource !== "empty") return;
try {
const raw = window.localStorage.getItem(workflowStorageKey);
if (!raw) {
setWorkflowHydrationSource("local");
return;
}
const parsed = JSON.parse(raw) as AgentWorkflowPersistedState;
setWorkflowInputs({
primarySku: String(parsed.inputs?.primarySku || "").trim(),
secondarySku: String(parsed.inputs?.secondarySku || "").trim(),
brand: String(parsed.inputs?.brand || "").trim(),
store: String(parsed.inputs?.store || "").trim(),
priceBand: String(parsed.inputs?.priceBand || "").trim(),
});
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials));
setWorkflowSelections(parsed.selections || {});
setSelectedBrandModel(
parsed.selectedBrandModel
? {
...parsed.selectedBrandModel,
brandName: String(parsed.selectedBrandModel.brandName || "").trim(),
id: String(parsed.selectedBrandModel.id || "").trim(),
imageUrl: String(parsed.selectedBrandModel.imageUrl || "").trim(),
assetId: String(parsed.selectedBrandModel.assetId || "").trim() || null,
displayName: String(parsed.selectedBrandModel.displayName || "").trim() || null,
mimeType: String(parsed.selectedBrandModel.mimeType || "").trim() || null,
sortOrder: Number.isFinite(Number(parsed.selectedBrandModel.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(parsed.selectedBrandModel.sortOrder))))
: 0,
}
: null
);
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
setAgentModeState(normalizeAgentModeWorkspaceState(parsed.agentModeState));
} catch (error) {
console.warn("[Canvas] Failed to hydrate workflow state from localStorage:", error);
} finally {
setWorkflowHydrationSource("local");
}
}, [isAgentMode, workflowHydrationSource, workflowStorageKey]);
useEffect(() => {
if (isAgentMode) return;
if (typeof window === "undefined" || workflowHydrationSource === "pending") return;
try {
window.localStorage.setItem(
workflowStorageKey,
JSON.stringify({
inputs: workflowInputs,
materials: workflowMaterials,
selections: sanitizedWorkflowSelections,
selectedBrandModel,
selectedAgentPresetId,
currentAgentStage,
agentModeState,
})
);
} catch (error) {
console.warn("[Canvas] Failed to persist workflow state:", error);
}
}, [
isAgentMode,
workflowHydrationSource,
workflowInputs,
workflowMaterials,
sanitizedWorkflowSelections,
workflowStorageKey,
selectedBrandModel,
selectedAgentPresetId,
currentAgentStage,
agentModeState,
]);
const persistAgentProject = useCallback(
async (
reason: "autosave" | "manual" | "switch",
overrides?: { title?: string | null }
) => {
if (!isAgentMode || !agentProjectId) return false;
const nextTitle =
String(overrides?.title ?? agentProjectTitle ?? "").trim() ||
buildDefaultAgentProjectTitle();
const result = await agentProjectService.updateProject(agentProjectId, {
title: nextTitle,
thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(),
});
if (!result.success) {
console.error(`[Agent Project] ${reason} save failed:`, result.error);
return false;
}
if (result.data) {
setAgentProjectTitle(result.data.title || buildDefaultAgentProjectTitle());
setAgentProjectThumbnail(result.data.thumbnail || null);
}
return true;
},
[
agentProjectId,
agentProjectThumbnail,
agentProjectTitle,
buildPersistedAgentProjectState,
isAgentMode,
]
);
useEffect(() => {
if (!requestedAgentMode || !agentProjectId) return;
let cancelled = false;
const loadAgentProject = async () => {
setIsAgentProjectMenuOpen(false);
setIsAgentProjectLoading(true);
setLoadedAgentProjectId(null);
try {
const result = await agentProjectService.getProject(agentProjectId);
if (!result.success || !result.data) {
throw new Error(result.error || "加载生图项目失败");
}
if (cancelled) return;
const persistedState = normalizeAgentGenerationProjectPersistedState(
result.data.projectState
);
const persistedWorkflow =
persistedState.workflowState || createEmptyAgentWorkflowPersistedState();
setWorkflowInputs({
primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(),
secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(),
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
priceBand: String(persistedWorkflow.inputs?.priceBand || "").trim(),
});
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials));
setWorkflowSelections(persistedWorkflow.selections || {});
setSelectedBrandModel(persistedWorkflow.selectedBrandModel || null);
setSelectedAgentPresetId(String(persistedWorkflow.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(persistedWorkflow.currentStage === "mode" ? "mode" : "workflow");
setAgentModeState(normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState));
setAgentWorkspaceState(
persistedState.workspaceState || createEmptyAgentGenerationProjectWorkspaceState()
);
setAgentProjectTitle(result.data.title || "未命名生图项目");
setAgentProjectThumbnail(result.data.thumbnail || null);
setAgentProjectResultCollections(persistedState.resultCollections || {});
setAgentProjectDeletedResultCollections(
persistedState.deletedResultCollections || {}
);
setLoadedAgentProjectId(agentProjectId);
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载生图项目失败");
}
} finally {
if (!cancelled) {
setIsAgentProjectLoading(false);
}
}
};
void loadAgentProject();
return () => {
cancelled = true;
};
}, [agentProjectId, requestedAgentMode]);
useEffect(() => {
if (isAgentProjectSheetOpen) {
setIsAgentProjectMenuOpen(false);
}
}, [isAgentProjectSheetOpen]);
useEffect(() => {
if (
!isAgentMode ||
!agentProjectId ||
isAgentProjectLoading ||
!hasLoadedCurrentAgentProject
) {
return;
}
const timer = window.setTimeout(async () => {
setIsAgentProjectSaving(true);
try {
await persistAgentProject("autosave");
} finally {
setIsAgentProjectSaving(false);
}
}, 2500);
return () => window.clearTimeout(timer);
}, [
agentModeState,
agentProjectId,
agentProjectResultCollections,
agentProjectThumbnail,
agentProjectTitle,
agentWorkspaceState,
currentAgentStage,
isAgentMode,
isAgentProjectLoading,
hasLoadedCurrentAgentProject,
persistAgentProject,
selectedAgentPresetId,
selectedBrandModel,
workflowInputs,
workflowMaterials,
workflowSelections,
]);
const handleSaveAsAgentProject = useCallback(async () => {
const suggestedTitle = buildSaveAsAgentProjectTitle(agentProjectTitle);
const promptedTitle = window.prompt("请输入另存为的新项目名称", suggestedTitle);
if (promptedTitle === null) {
return;
}
const nextTitle = promptedTitle.trim();
if (!nextTitle) {
toast.error("项目名称不能为空");
return;
}
setIsAgentProjectSaving(true);
try {
if (agentProjectId) {
await persistAgentProject("switch");
}
const result = await agentProjectService.createProject({
title: nextTitle,
thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(),
});
if (!result.success || !result.data) {
toast.error(result.error || "另存为新项目失败");
return;
}
toast.success("已另存为新项目");
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
});
} finally {
setIsAgentProjectSaving(false);
}
}, [
agentProjectId,
agentProjectThumbnail,
agentProjectTitle,
buildPersistedAgentProjectState,
persistAgentProject,
updateCanvasSearchParams,
]);
const handleRenameAgentProject = useCallback(
async (targetProjectId: string, currentTitle?: string | null) => {
const suggestedTitle =
String(currentTitle || "").trim() || "未命名生图项目";
const promptedTitle = window.prompt("请输入新的项目名称", suggestedTitle);
if (promptedTitle === null) {
return;
}
const nextTitle = promptedTitle.trim();
if (!nextTitle) {
toast.error("项目名称不能为空");
return;
}
const result = await agentProjectService.updateProject(targetProjectId, {
title: nextTitle,
});
if (!result.success || !result.data) {
toast.error(result.error || "更新生图项目名称失败");
return;
}
if (targetProjectId === agentProjectId) {
setAgentProjectTitle(result.data.title || nextTitle);
}
toast.success("项目名称已更新");
},
[agentProjectId]
);
const handleRenameCurrentAgentProject = useCallback(async () => {
if (!agentProjectId) {
toast.error("当前还没有可重命名的项目");
return;
}
await handleRenameAgentProject(agentProjectId, agentProjectTitle);
}, [agentProjectId, agentProjectTitle, handleRenameAgentProject]);
const handleCreateAgentProject = useCallback(async () => {
if (agentProjectId) {
await persistAgentProject("switch");
}
const result = await agentProjectService.createProject({
title: buildDefaultAgentProjectTitle(),
projectState: createEmptyAgentGenerationProjectPersistedState(),
});
if (!result.success || !result.data) {
toast.error(result.error || "创建生图项目失败");
return;
}
toast.success("已新建空白项目");
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
});
}, [
agentProjectId,
persistAgentProject,
updateCanvasSearchParams,
]);
const handleSelectAgentProject = useCallback(
async (nextProjectId: string) => {
if (!nextProjectId || nextProjectId === agentProjectId) return;
if (agentProjectId) {
await persistAgentProject("switch");
}
updateCanvasSearchParams({
mode: "agent",
agentProjectId: nextProjectId,
});
},
[agentProjectId, persistAgentProject, updateCanvasSearchParams]
);
const handleDeleteAgentProject = useCallback(
async (targetProjectId: string) => {
const result = await agentProjectService.deleteProject(targetProjectId);
if (!result.success) {
toast.error(result.error || "删除生图项目失败");
return;
}
if (targetProjectId === agentProjectId) {
const listResult = await agentProjectService.getProjects();
const nextProject = listResult.success
? listResult.data?.find((project) => project.id !== targetProjectId) || null
: null;
if (nextProject) {
updateCanvasSearchParams({
mode: "agent",
agentProjectId: nextProject.id,
});
} else {
updateCanvasSearchParams({
mode: "agent",
agentProjectId: null,
});
setIsAgentProjectSheetOpen(true);
}
}
toast.success("生图项目已删除");
},
[agentProjectId, updateCanvasSearchParams]
);
const handleAgentResultPreviewUrlsChange = useCallback(
(modeType: AgentGenerationResultModeType, urls: string[]) => {
const normalizedUrls = normalizeResultCollectionUrls(urls);
setAgentProjectResultCollections((current) => {
const nextCollections: AgentGenerationProjectResultCollections = {
...current,
[modeType]: normalizedUrls,
};
return areResultCollectionsEqual(current, nextCollections) ? current : nextCollections;
});
setAgentProjectThumbnail((current) => {
const nextThumbnail =
normalizedUrls[0] ||
getFirstResultUrlFromCollections({
...agentProjectResultCollections,
[modeType]: normalizedUrls,
});
return current === nextThumbnail ? current : nextThumbnail;
});
},
[agentProjectResultCollections]
);
const handleAgentResultImageDelete = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
return;
}
const nextCollections: AgentGenerationProjectResultCollections = {
...agentProjectResultCollections,
[modeType]: normalizeResultCollectionUrls(
(agentProjectResultCollections[modeType] || []).filter(
(url) => url !== normalizedUrl
)
),
};
const nextDeletedCollections: AgentGenerationProjectResultCollections = {
...agentProjectDeletedResultCollections,
[modeType]: normalizeResultCollectionUrls([
...(agentProjectDeletedResultCollections[modeType] || []),
normalizedUrl,
]),
};
setAgentProjectResultCollections((current) =>
areResultCollectionsEqual(current, nextCollections) ? current : nextCollections
);
setAgentProjectDeletedResultCollections((current) =>
areResultCollectionsEqual(current, nextDeletedCollections)
? current
: nextDeletedCollections
);
setAgentProjectThumbnail((current) => {
const nextThumbnail =
current === normalizedUrl
? getFirstResultUrlFromCollections(nextCollections)
: current;
return current === nextThumbnail ? current : nextThumbnail;
});
},
[agentProjectDeletedResultCollections, agentProjectResultCollections]
);
useEffect(() => {
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
if (!normalizedBrand) {
previousBrandForModelRef.current = "";
autoSelectedBrandModelBrandRef.current = "";
if (selectedBrandModel) {
setSelectedBrandModel(null);
}
return;
}
const brandChanged = previousBrandForModelRef.current !== normalizedBrand;
if (brandChanged) {
previousBrandForModelRef.current = normalizedBrand;
autoSelectedBrandModelBrandRef.current = "";
}
if (selectedBrandModel && selectedBrandModel.brandName.toLowerCase() !== normalizedBrand) {
setSelectedBrandModel(null);
return;
}
if (selectedBrandModel) {
return;
}
if (autoSelectedBrandModelBrandRef.current === normalizedBrand) {
return;
}
const matchedEntry =
resolvedBrandModelConfig.entries.find(
(entry) => entry.brandName.trim().toLowerCase() === normalizedBrand
) || null;
const defaultBrandModel = matchedEntry?.images[0] || null;
if (!defaultBrandModel) {
return;
}
autoSelectedBrandModelBrandRef.current = normalizedBrand;
setSelectedBrandModel({
...defaultBrandModel,
brandName: matchedEntry?.brandName || workflowInputs.brand.trim(),
});
}, [resolvedBrandModelConfig.entries, selectedBrandModel, workflowInputs.brand]);
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(id, canvasData);
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
}
return result.success;
},
[buildPersistedCanvasData, id]
);
// 自动保存逻辑
useEffect(() => {
if (isLoading || isAgentMode) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
try {
await persistCanvasState("autosave");
} catch (error) {
console.error("Auto save failed:", error);
}
setIsSaving(false);
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
useEffect(() => {
if (isLoading || isAgentMode) {
previousNodeIdsRef.current = new Set(nodes.map((node) => node.id));
return;
}
const previousIds = previousNodeIdsRef.current;
const currentIds = new Set(nodes.map((node) => node.id));
const removedIds: string[] = [];
previousIds.forEach((nodeId) => {
if (!currentIds.has(nodeId)) {
removedIds.push(nodeId);
}
});
previousNodeIdsRef.current = currentIds;
if (removedIds.length > 0) {
const persistAfterDelete = async () => {
setIsSaving(true);
try {
await persistCanvasState("delete");
} catch (error) {
console.error("Delete immediate save failed:", error);
}
setIsSaving(false);
};
void persistAfterDelete();
}
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
useEffect(() => {
if (isLoading || isAgentMode) return;
const handleBeforeUnload = () => {
try {
const canvasData = buildPersistedCanvasData();
void fetch(`/api/canvas/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ canvasData }),
keepalive: true,
});
} catch (error) {
console.error("[Canvas] beforeunload save failed:", error);
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handleBeforeUnload);
};
}, [buildPersistedCanvasData, id, isAgentMode, isLoading]);
// 智能命名与缩略图逻辑
useEffect(() => {
if (isAgentMode) return;
const hasDefaultTitle =
projectTitle === "未命名画布" || projectTitle === "新画布";
if (!hasDefaultTitle || nodes.length === 0) return;
const imageNode = nodes.find((n) => n.type === "image" && (n.data as any)?.src);
const imageSrc = (imageNode?.data as any)?.src;
if (!imageSrc || typeof imageSrc !== "string") return;
const autoTitleKey = `${id}:${imageSrc}`;
if (
autoTitleAttemptedRef.current.has(autoTitleKey) ||
autoTitleInFlightRef.current === autoTitleKey
) {
return;
}
const autoTitleAndThumbnail = async () => {
autoTitleAttemptedRef.current.add(autoTitleKey);
autoTitleInFlightRef.current = autoTitleKey;
try {
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
await canvasService.updateProject(id, { thumbnail: imageSrc });
autoThumbnailSyncedRef.current = autoTitleKey;
}
const res = await fetch("/api/canvas/auto-title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: imageSrc }),
});
if (!res.ok) {
throw new Error(`Auto title request failed: ${res.status}`);
}
const data = await res.json();
if (data.title) {
setProjectTitle(data.title);
await canvasService.updateProject(id, { title: data.title });
toast.success(`画布已自动命名为:${data.title}`);
}
} catch (error) {
console.error("Auto title failed:", error);
} finally {
if (autoTitleInFlightRef.current === autoTitleKey) {
autoTitleInFlightRef.current = null;
}
}
};
// 使用防抖,避免频繁触发
const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作
return () => clearTimeout(timer);
}, [id, isAgentMode, nodes, projectTitle, setProjectTitle]);
// 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => {
setIsSaving(true);
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(id, canvasData);
if (result.success) {
toast.success("保存成功");
} else {
toast.error(result.error || "保存失败");
}
setIsSaving(false);
}, [buildPersistedCanvasData, id]);
// 更新标题
const handleUpdateTitle = async () => {
if (!editTitle.trim() || editTitle === projectTitle) {
setIsEditingTitle(false);
return;
}
const result = await canvasService.updateProject(id, { title: editTitle.trim() });
if (result.success) {
setProjectTitle(editTitle.trim());
} else {
toast.error(result.error || "更新标题失败");
}
setIsEditingTitle(false);
};
// 添加文本节点
const handleAddTextNode = () => {
addNode({
type: 'text',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 200, height: 100 },
data: { content: '双击编辑文本' },
connections: [],
});
};
// 添加图片节点
const handleAddImageNode = () => {
addNode({
type: 'image',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 300, height: 300 },
data: {
src: '',
status: 'pending',
title: '新图片',
prompt: '',
},
connections: [],
});
};
const handleBatchAddToCanvas = useCallback((imageUrls: string[]) => {
if (!imageUrls || imageUrls.length === 0) {
toast.error("没有可添加到画布的图片");
return;
}
const columns = Math.min(4, imageUrls.length);
const startX = 120;
const startY = 120;
const createdNodeIds: string[] = [];
for (const [index, imageUrl] of imageUrls.entries()) {
const x = startX + (index % columns) * 320;
const y = startY + Math.floor(index / columns) * 320;
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: 300, height: 300 },
data: {
src: imageUrl,
status: "completed",
title: `批量结果-${index + 1}`,
prompt: "批量处理结果",
},
connections: [],
});
createdNodeIds.push(nodeId);
}
if (createdNodeIds.length > 0) {
selectNodes(createdNodeIds);
}
}, [addNode, selectNodes]);
// 处理图片操作(通过事件分发到选中图片节点执行)
const handleAction = (action: string) => {
if (!selectedImageNode) {
toast.error("请先选择一张图片");
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("canvas:image-action", {
detail: {
nodeId: selectedImageNode.id,
action,
},
}),
);
}
};
// AI 生成图片
const handleGenerate = async () => {
if (!prompt.trim()) {
toast.error("请输入提示词");
return;
}
setGenerating(true);
// 先创建占位节点(显示生成中状态)
const placeholderNodeIds: string[] = [];
for (let i = 0; i < imageCount; i++) {
const nodeId = addNode({
type: 'image',
position: {
x: 100 + (i % 3) * 320,
y: 100 + Math.floor(i / 3) * 320
},
size: { width: 300, height: 300 },
data: {
src: '',
status: 'generating',
title: prompt.slice(0, 24) || '生成中',
prompt: prompt,
},
connections: [],
});
placeholderNodeIds.push(nodeId);
}
try {
// 调用 AI 图片生成 API
const result = await imageGenerationService.generateImage({
prompt,
negativePrompt,
aspectRatio,
imageCount,
});
if (result.success && result.data?.images) {
// 更新节点为生成的图片
result.data.images.forEach((image, index) => {
if (placeholderNodeIds[index]) {
updateNode(placeholderNodeIds[index], {
data: {
src: image.originalUrl || image.thumbnailUrl,
status: 'completed',
title: `${prompt.slice(0, 20) || '生成图片'}-${index + 1}`,
prompt: prompt,
},
});
}
});
toast.success(`成功生成 ${result.data.images.length} 张图片`);
setPrompt(""); // 清空提示词
} else {
// 生成失败,更新节点状态
placeholderNodeIds.forEach(nodeId => {
updateNode(nodeId, {
data: {
src: '',
status: 'failed',
title: '生成失败',
prompt: prompt,
},
});
});
toast.error(result.error || "图片生成失败");
}
} catch (error) {
console.error("AI 图片生成失败:", error);
// 生成失败,更新节点状态
placeholderNodeIds.forEach(nodeId => {
updateNode(nodeId, {
data: {
src: '',
status: 'failed',
title: '生成失败',
prompt: prompt,
},
});
});
toast.error("图片生成失败,请重试");
} finally {
setGenerating(false);
}
};
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素)
const target = e.target as HTMLElement;
if (target instanceof HTMLInputElement) {
const inputType = String(target.type || "").toLowerCase();
const isTextInput =
inputType === "text" ||
inputType === "search" ||
inputType === "url" ||
inputType === "email" ||
inputType === "password" ||
inputType === "number";
if (isTextInput) {
return;
}
}
if (
target instanceof HTMLTextAreaElement ||
target.isContentEditable ||
target.closest('[contenteditable="true"]')
) {
return;
}
// Ctrl/Cmd + S 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
// Ctrl/Cmd + A:当已选中图片时,选中画布内全部图片
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
const { nodes: currentNodes, selectedNodeIds: currentSelectedNodeIds } = useCanvasStore.getState();
const selectedHasImage = currentSelectedNodeIds.some((selectedId) =>
currentNodes.some((node) => node.id === selectedId && node.type === "image")
);
if (selectedHasImage) {
const allImageNodeIds = currentNodes
.filter((node) => node.type === "image" && Boolean((node.data as any)?.src))
.map((node) => node.id);
if (allImageNodeIds.length > 0) {
e.preventDefault();
selectNodes(allImageNodeIds);
}
}
}
// Ctrl/Cmd + Z 撤销
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl/Cmd + Shift + Z 重做
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
e.preventDefault();
redo();
}
// V 选择工具
if (e.key === 'v' && !e.ctrlKey && !e.metaKey) {
setTool('select');
}
// H 平移工具
if (e.key === 'h' && !e.ctrlKey && !e.metaKey) {
setTool('pan');
}
// Delete 删除选中
if (e.key === 'Delete' || e.key === 'Backspace') {
useCanvasStore.getState().deleteSelectedNodes();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave, nodes, redo, selectNodes, selectedNodeIds, setTool, undo]);
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const prevHtmlOverscrollX = html.style.overscrollBehaviorX;
const prevBodyOverscrollX = body.style.overscrollBehaviorX;
html.style.overscrollBehaviorX = "none";
body.style.overscrollBehaviorX = "none";
const preventHistorySwipe = (event: WheelEvent) => {
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
if (event.cancelable) {
event.preventDefault();
}
};
window.addEventListener("wheel", preventHistorySwipe, { passive: false });
return () => {
window.removeEventListener("wheel", preventHistorySwipe);
html.style.overscrollBehaviorX = prevHtmlOverscrollX;
body.style.overscrollBehaviorX = prevBodyOverscrollX;
};
}, []);
useEffect(() => {
const currentSerialized = JSON.stringify(workflowSelections);
const sanitizedSerialized = JSON.stringify(sanitizedWorkflowSelections);
if (currentSerialized === sanitizedSerialized) {
return;
}
workflowSelectionsRef.current = sanitizedWorkflowSelections;
setWorkflowSelections(sanitizedWorkflowSelections);
}, [sanitizedWorkflowSelections, workflowSelections]);
const workflowContext = useMemo<AgentWorkflowComposerContext | null>(() => {
return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputs,
materials: workflowMaterials,
selectedBrandModel,
selections: sanitizedWorkflowSelections,
});
}, [
resolvedWorkflowUiConfig,
selectedBrandModel,
sanitizedWorkflowSelections,
workflowInputs,
workflowMaterials,
]);
useEffect(() => {
workflowInputsRef.current = workflowInputs;
}, [workflowInputs]);
useEffect(() => {
workflowMaterialsRef.current = workflowMaterials;
}, [workflowMaterials]);
useEffect(() => {
workflowSelectionsRef.current = sanitizedWorkflowSelections;
}, [sanitizedWorkflowSelections]);
useEffect(() => {
selectedBrandModelRef.current = selectedBrandModel;
}, [selectedBrandModel]);
const handleWorkflowInputsChange = useCallback((next: AgentWorkflowInputs) => {
workflowInputsRef.current = next;
setWorkflowInputs(next);
}, []);
const handleWorkflowMaterialsChange = useCallback((next: AgentWorkflowMaterialSelections) => {
workflowMaterialsRef.current = next;
setWorkflowMaterials(next);
}, []);
const handleWorkflowSelectionsChange = useCallback(
(next: Partial<Record<AgentWorkflowStepKey, string>>) => {
const normalizedCurrentSelections = sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: workflowSelectionsRef.current,
});
const normalizedNextSelections = sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: next,
});
const orderedStepKeys = flattenVisibleAgentWorkflowSteps(
(resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig).steps,
normalizedCurrentSelections
).map(({ step }) => step.key);
const earliestChangedIndex = orderedStepKeys.findIndex((stepKey) => {
return String(normalizedCurrentSelections[stepKey] || "") !== String(normalizedNextSelections[stepKey] || "");
});
let clearedSelections = { ...normalizedNextSelections };
if (earliestChangedIndex >= 0) {
orderedStepKeys.slice(earliestChangedIndex + 1).forEach((stepKey) => {
delete clearedSelections[stepKey];
});
}
clearedSelections = sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: clearedSelections,
});
workflowSelectionsRef.current = clearedSelections;
setWorkflowSelections(clearedSelections);
},
[resolvedWorkflowUiConfig]
);
const handleSelectedBrandModelChange = useCallback(
(next: AgentWorkflowSelectedBrandModel | null) => {
selectedBrandModelRef.current = next;
setSelectedBrandModel(next);
},
[]
);
const getLatestWorkflowContext = useCallback(() => {
return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputsRef.current,
materials: workflowMaterialsRef.current,
selectedBrandModel: selectedBrandModelRef.current,
selections: workflowSelectionsRef.current,
});
}, [resolvedWorkflowUiConfig]);
const { data: selectedAgentPresetResponse } = useSWR<{
success?: boolean;
data?: AgentPreset;
}>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher);
const selectedAgentPresetData = useMemo(
() =>
selectedAgentPresetResponse?.success && selectedAgentPresetResponse.data
? selectedAgentPresetResponse.data
: null,
[selectedAgentPresetResponse]
);
const selectedAgentPresetModeConfig = useMemo(
() => normalizeAgentPresetModeConfig(selectedAgentPresetData?.defaultParams),
[selectedAgentPresetData?.defaultParams]
);
const materialProductSelectionLimit = useMemo(() => {
if (selectedAgentPresetData?.type !== "image") {
return null;
}
if (selectedAgentPresetModeConfig.modeType === "scene_generation") {
return selectedAgentPresetModeConfig.scene.productImageSlotCount;
}
if (selectedAgentPresetModeConfig.modeType === "detail_replacement") {
return selectedAgentPresetModeConfig.detail.productImageSlotCount;
}
if (selectedAgentPresetModeConfig.modeType === "sku_replacement") {
return selectedAgentPresetModeConfig.sku.productImageSlotCount;
}
return 2;
}, [selectedAgentPresetData?.type, selectedAgentPresetModeConfig]);
const workflowProgress = useMemo(
() =>
getAgentWorkflowProgress({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: sanitizedWorkflowSelections,
}),
[resolvedWorkflowUiConfig, sanitizedWorkflowSelections]
);
const resolvedAgentProjectTitle =
hasActiveAgentProject
? String(agentProjectTitle || "").trim() || "未命名生图项目"
: "未选择项目";
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-muted/30">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
);
}
const brandEntry = (
<div className="absolute top-4 left-4 z-20">
<Button
variant="ghost"
onClick={goHome}
className="h-10 gap-2 rounded-none border-0 bg-transparent px-0 shadow-none hover:bg-transparent"
>
{(siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark) ? (
<img
src={siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark || ""}
alt={siteSettingsData?.siteName || "返回首页"}
className="h-7 w-7 object-contain"
draggable={false}
/>
) : (
<Grid3X3 className="h-5 w-5" />
)}
<span className="max-w-[180px] truncate text-base font-semibold text-foreground">
{siteSettingsData?.siteName || "返回首页"}
</span>
</Button>
</div>
);
const agentDisabledNotice =
requestedAgentMode && !isAgentWorkspaceEnabled ? (
<div className="absolute top-4 left-1/2 z-20 -translate-x-1/2">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/80 dark:text-amber-200">
<AlertCircle className="h-4 w-4" />
<span>智能体功能未开启,请联系管理员在系统设置中开启。</span>
</div>
</div>
) : null;
const agentProjectNavSlot = isAgentMode ? (
<div className="flex items-center gap-2">
<div className="hidden h-9 min-w-0 max-w-[180px] items-center gap-2 rounded-[12px] border border-white/10 bg-white/[0.03] px-2.5 sm:flex">
{isAgentProjectLoading || isAgentProjectSaving ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-emerald-300" />
) : (
<span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400 shadow-[0_0_0_3px_rgba(16,185,129,0.12)]" />
)}
<span className="truncate text-[13px] font-medium text-white" title={resolvedAgentProjectTitle}>
{resolvedAgentProjectTitle}
</span>
</div>
<DropdownMenu open={isAgentProjectMenuOpen} onOpenChange={setIsAgentProjectMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
disabled={isAgentProjectLoading}
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
{isAgentProjectSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderOpen className="h-4 w-4" />
)}
项目操作
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 border-white/10 bg-[#0b0d0f] text-white"
>
<DropdownMenuItem
disabled={isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
setIsAgentProjectMenuOpen(false);
void handleCreateAgentProject();
}}
>
<Plus className="mr-2 h-4 w-4" />
新建项目
</DropdownMenuItem>
<DropdownMenuItem
disabled={isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
setIsAgentProjectMenuOpen(false);
void handleSaveAsAgentProject();
}}
>
<Save className="mr-2 h-4 w-4" />
另存为新项目
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasActiveAgentProject || isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
setIsAgentProjectMenuOpen(false);
void handleRenameCurrentAgentProject();
}}
>
<Pencil className="mr-2 h-4 w-4" />
重命名当前项目
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-white/10" />
<DropdownMenuItem
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
setIsAgentProjectMenuOpen(false);
setIsAgentProjectSheetOpen(true);
}}
>
<Grid3X3 className="mr-2 h-4 w-4" />
项目列表
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AgentProjectListSheet
open={isAgentProjectSheetOpen}
onOpenChange={setIsAgentProjectSheetOpen}
currentProjectId={agentProjectId || null}
onCreateProject={handleCreateAgentProject}
onSelectProject={handleSelectAgentProject}
onDeleteProject={handleDeleteAgentProject}
onRenameProject={handleRenameAgentProject}
/>
</div>
) : null;
const agentProjectSelectionScreen = isAgentMode && !hasActiveAgentProject ? (
<div className="min-h-screen bg-[#050607] text-white">
<AgentCanvasTopNavDark
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
rightSlot={agentProjectNavSlot}
activeKey="agent"
/>
<main className="flex min-h-screen items-center justify-center px-6 pt-24 pb-8">
<div className="w-full max-w-[860px] rounded-[36px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04)_0%,rgba(255,255,255,0.02)_100%)] p-8 shadow-[0_24px_80px_rgba(0,0,0,0.35)] md:p-10">
<div className="mx-auto max-w-[620px] text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-emerald-400/20 bg-emerald-500/[0.10] text-emerald-200">
<FolderOpen className="h-8 w-8" />
</div>
<h1 className="mt-6 text-3xl font-semibold tracking-[0.02em] text-white md:text-4xl">
先选择一个生图项目
</h1>
<p className="mt-4 text-sm leading-7 text-white/55 md:text-base">
打开历史项目可以继续沿用之前的生图结果、模式和工作流。只有在你明确点击新建项目时,系统才会创建一个全新的智能体项目。
</p>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-2">
<button
type="button"
onClick={() => setIsAgentProjectSheetOpen(true)}
className="rounded-[28px] border border-white/10 bg-black/30 p-5 text-left transition-colors hover:border-white/18 hover:bg-white/[0.04]"
>
<div className="flex items-center gap-3 text-emerald-200">
<Grid3X3 className="h-5 w-5" />
<span className="text-sm font-medium">打开历史项目</span>
</div>
<div className="mt-3 text-sm leading-6 text-white/55">
从项目列表里继续昨天或今天的智能体项目,避免生成结果分散在多个空项目中。
</div>
</button>
<button
type="button"
onClick={() => {
void handleCreateAgentProject();
}}
className="rounded-[28px] border border-emerald-400/20 bg-emerald-500/[0.10] p-5 text-left transition-colors hover:border-emerald-300/35 hover:bg-emerald-500/[0.16]"
>
<div className="flex items-center gap-3 text-emerald-100">
<Plus className="h-5 w-5" />
<span className="text-sm font-medium">新建空白项目</span>
</div>
<div className="mt-3 text-sm leading-6 text-emerald-50/70">
仅在你要开始一条全新的场景生图流程时再创建,避免项目列表里自动冒出一堆无效项目。
</div>
</button>
</div>
</div>
</main>
</div>
) : null;
if (isAgentMode) {
if (!hasActiveAgentProject) {
return agentProjectSelectionScreen;
}
return (
<AgentGenerationWorkspaceDarkV2
chatId={agentModeChatId}
projectId={agentProjectId}
selectedAgentPresetId={isAgentWorkspaceEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext}
getLatestWorkflowContext={getLatestWorkflowContext}
agentModeState={agentModeState}
onAgentModeStateChange={setAgentModeState}
workspaceState={agentWorkspaceState}
resultCollections={agentProjectResultCollections}
deletedResultCollections={agentProjectDeletedResultCollections}
onWorkspaceStateChange={setAgentWorkspaceState}
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
onDeleteResultImage={handleAgentResultImageDelete}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig}
topNavRightSlot={agentProjectNavSlot}
syncGeneratedResultsToCanvas={false}
onOpenCanvasEditor={() => {
updateCanvasSearchParams({
mode: null,
});
}}
onClearPreset={() => setSelectedAgentPresetId(null)}
brandStorePanel={
<AgentWorkflowBrandStorePanel
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
theme="dark"
/>
}
workflowStylePanel={
<AgentWorkflowPanelDarkV2
embedded
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
materials={workflowMaterials}
onMaterialsChange={handleWorkflowMaterialsChange}
selections={sanitizedWorkflowSelections}
onSelectionsChange={handleWorkflowSelectionsChange}
showBrandStoreSection={false}
/>
}
// 嵌入的智能体选择面板
presetPanel={
<AgentPresetPanelDarkV2
embedded
embeddedEntityLabel="生图模式"
selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId}
/>
}
brandModelPanel={
<AgentWorkflowBrandModelPicker
brand={workflowInputs.brand}
selectedBrandModel={selectedBrandModel}
onSelectBrandModel={handleSelectedBrandModelChange}
theme="dark"
/>
}
materialSelectionPanel={
<AgentWorkflowMaterialSelectionPanel
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
materials={workflowMaterials}
onMaterialsChange={handleWorkflowMaterialsChange}
productSelectionLimit={materialProductSelectionLimit}
disabled={!selectedAgentPresetId}
theme="dark"
/>
}
/>
);
}
return (
<TooltipProvider>
<div className="relative h-dvh w-full overflow-hidden overscroll-none bg-background">
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div className="absolute inset-0 z-0" key={`workspace-${id}`} data-canvas-stage="true">
<CanvasWorkspace occlusionRight={panelOcclusionRight} />
</div>
{brandEntry}
{agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && (
<div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}>
<Button
variant="ghost"
onClick={() => setIsPanelOpen(true)}
className="h-10 rounded-full border bg-background/90 px-3 text-sm font-medium text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
>
<MessageCircle className="mr-1.5 h-4 w-4" />
对话
</Button>
</div>
)}
{/* 4. Floating Sidebar (Right) */}
<div className={cn(
"absolute z-10 transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}
data-canvas-right-panel="true"
style={{
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}>
<div className="h-full w-full border-l bg-white dark:bg-zinc-950 overflow-hidden flex flex-col">
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
<CanvasChatPanel
chatId={`canvas-${id}`}
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')}
selectedAgentPresetId={isCanvasAgentModeEnabled ? selectedAgentPresetId : null}
chatScope="canvas"
agentEnabled={isCanvasAgentModeEnabled}
/>
</div>
) : (
// 文件列表
<CanvasFileListPanel
currentId={id}
onClose={() => setPanelMode('chat')}
/>
)}
</div>
</div>
{/* 5. Floating Toolbar (Bottom) */}
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: `${FLOATING_BOTTOM_INSET}px` }}
data-canvas-floating-toolbar="true"
>
<div
className="flex items-center gap-1 rounded-[24px] border bg-white px-2.5 py-1.5 shadow-2xl dark:bg-zinc-950"
style={{ marginLeft: `${floatingToolbarOffset}px` }}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === "select" ? "secondary" : "ghost"}
size="icon"
onClick={() => setTool("select")}
className={cn("h-9 w-9 rounded-xl", tool === "select" && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">选择 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === "pan" ? "secondary" : "ghost"}
size="icon"
onClick={() => setTool("pan")}
className={cn("h-9 w-9 rounded-xl", tool === "pan" && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">平移 (H)</TooltipContent>
</Tooltip>
{isAgentWorkspaceEnabled ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isAgentMode ? "secondary" : "ghost"}
size="icon"
onClick={handleToggleAgentMode}
className={cn(
"h-9 w-9 rounded-xl",
isAgentMode && "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
<Wand2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">智能体模式</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setSceneFusionDialogOpen(true)}
className="h-9 w-9 rounded-xl"
>
<Layers className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">批量处理</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAddImageNode}
className="h-9 w-9 rounded-xl"
>
<ImagePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加图片</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAddTextNode}
className="h-9 w-9 rounded-xl"
>
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加文本</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!selectedImageNode}
onClick={() => {
if (!selectedImageNode) {
toast.info("请先选中一张图片");
return;
}
setPickerMode(true, selectedImageNode.id);
toast.info("点击图片中的物品进行拾取");
}}
className="h-9 w-9 rounded-xl"
>
<Crosshair className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{selectedImageNode ? "识物" : "先选中图片后识物"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!selectedImageNode}
onClick={() => {
if (!selectedImageNode) {
toast.info("请先选中一张图片");
return;
}
requestImageNodeAction(selectedImageNode.id, "box-cutout");
}}
className="h-9 w-9 rounded-xl"
>
<Scan className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{selectedImageNode ? "框选抠图" : "先选中图片后框选抠图"}
</TooltipContent>
</Tooltip>
{siteSettingsData?.realtimeCanvasEnabled === true &&
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/realtime`)}
className="h-9 w-9 rounded-xl"
>
<PenTool className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">实时画布</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/model3d`)}
className="h-9 w-9 rounded-xl"
>
<Box className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">3D生成</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/canvas?new=1')}
className="h-9 w-9 rounded-xl"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">新建画布</TooltipContent>
</Tooltip>
</div>
</div>
{/* 6. Bottom Left Compact Controls */}
<div
className="absolute z-20"
style={{ bottom: `${FLOATING_BOTTOM_INSET + 4}px`, left: `${compactControlsLeft + 8}px` }}
>
<div className="flex items-center gap-1 text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={undo}
disabled={!canUndo}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<Undo2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={redo}
disabled={!canRedo}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<Redo2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">重做</TooltipContent>
</Tooltip>
<div className="mx-1 h-4 w-px bg-border/70" />
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<span className="w-10 text-center text-[10px] tabular-nums font-medium text-muted-foreground">
{Math.round(viewport.zoom * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 7. 物品拾取模式覆盖层 */}
<ObjectPickerOverlay />
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
onBatchAddToCanvas={handleBatchAddToCanvas}
/>
</div>
</TooltipProvider>
);
}