diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx index 52c721f..1f81c24 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx @@ -14,14 +14,18 @@ import { MarkerType, Position, ReactFlow, + ViewportPortal, useEdgesState, useNodesState, type Connection, type ConnectionLineComponentProps, type Edge, + type EdgeChange, type EdgeProps, type FinalConnectionState, type Node, + type NodeChange, + type OnNodeDrag, type NodeProps, type ReactFlowInstance, } from "@xyflow/react" @@ -31,6 +35,8 @@ import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, + Redo2Icon, + Undo2Icon, } from "lucide-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -49,17 +55,24 @@ import { cn } from "@/lib/utils" import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin" import { applyAutoInputMappings, + calculateWorkflowHelperLines, + createWorkflowHistory, createWorkflowNodeFromSpec, fromApiDefinition, getAvailableVariables, getNodeSpec, getRequiredInputs, + pushWorkflowHistory, + redoWorkflowHistory, toApiDefinition, + undoWorkflowHistory, validateWorkflowDraft, type WorkflowVariableRef, type WorkflowVariableSelector, type WorkflowEditorEdge, type WorkflowEditorNode, + type WorkflowHistory, + type WorkflowHelperLine, } from "./workflow-utils" import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel" import { VariableSelector } from "./variable-selector" @@ -81,6 +94,10 @@ type WorkflowNodeData = Record & { type WorkflowFlowNode = Node type WorkflowFlowEdge = Edge +type WorkflowEditorSnapshot = { + nodes: WorkflowFlowNode[] + edges: WorkflowFlowEdge[] +} type WorkflowEdgeCondition = NonNullable["condition"] type WorkflowEdgeRenderData = WorkflowEditorEdge["data"] & { active?: boolean @@ -193,6 +210,7 @@ export function WorkflowEditor({ const [nodeLibraryWidth, setNodeLibraryWidth] = useState(260) const [nodeLibraryResizing, setNodeLibraryResizing] = useState(false) const [pendingNodeDrag, setPendingNodeDrag] = useState(null) + const [helperLines, setHelperLines] = useState({}) const [propertyPanelNode, setPropertyPanelNode] = useState(null) const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [propertyPanelEdge, setPropertyPanelEdge] = useState(null) @@ -200,10 +218,16 @@ export function WorkflowEditor({ const editorRef = useRef(null) const canvasRef = useRef(null) const pendingNodeDragRef = useRef(null) + const historyRef = useRef>(createWorkflowHistory()) + const dragStartSnapshotRef = useRef(null) const suppressNextClickRef = useRef(false) const nodeLibraryAnimationTimerRef = useRef(null) const propertyPanelAnimationTimerRef = useRef(null) const draft = useMemo(() => toDraft(nodes, edges), [nodes, edges]) + const [historyAvailability, setHistoryAvailability] = useState({ + canUndo: false, + canRedo: false, + }) const validation = useMemo( () => validateWorkflowDraft(draft, nodeSpecs), [draft, nodeSpecs] @@ -229,10 +253,6 @@ export function WorkflowEditor({ onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition) }, [draft, onDefinitionChange]) - useEffect(() => { - onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition) - }, [draft, onDefinitionChange]) - useEffect(() => { return () => { if (nodeLibraryAnimationTimerRef.current !== null) { @@ -304,11 +324,103 @@ export function WorkflowEditor({ }, 220) }, []) + const syncHistoryAvailability = useCallback(() => { + setHistoryAvailability({ + canUndo: historyRef.current.past.length > 0, + canRedo: historyRef.current.future.length > 0, + }) + }, []) + + const getCurrentSnapshot = useCallback((): WorkflowEditorSnapshot => ({ + nodes, + edges, + }), [edges, nodes]) + + const pushSnapshotToHistory = useCallback( + (snapshot: WorkflowEditorSnapshot) => { + historyRef.current = pushWorkflowHistory(historyRef.current, snapshot) + syncHistoryAvailability() + }, + [syncHistoryAvailability] + ) + + const pushCurrentSnapshotToHistory = useCallback(() => { + pushSnapshotToHistory(getCurrentSnapshot()) + }, [getCurrentSnapshot, pushSnapshotToHistory]) + + const applySnapshot = useCallback( + (snapshot: WorkflowEditorSnapshot) => { + setNodes(snapshot.nodes) + setEdges(snapshot.edges) + setHelperLines({}) + setSelectedEdgeId((current) => + current && snapshot.edges.some((edge) => edge.id === current) ? current : null + ) + setPropertyPanelNode((current) => + current ? snapshot.nodes.find((node) => node.id === current.id) ?? null : null + ) + setPropertyPanelEdge((current) => + current ? snapshot.edges.find((edge) => edge.id === current.id) ?? null : null + ) + }, + [setEdges, setNodes] + ) + + const undoWorkflowEdit = useCallback(() => { + const result = undoWorkflowHistory(historyRef.current, getCurrentSnapshot()) + if (!result) { + return + } + historyRef.current = result.history + applySnapshot(result.snapshot) + syncHistoryAvailability() + }, [applySnapshot, getCurrentSnapshot, syncHistoryAvailability]) + + const redoWorkflowEdit = useCallback(() => { + const result = redoWorkflowHistory(historyRef.current, getCurrentSnapshot()) + if (!result) { + return + } + historyRef.current = result.history + applySnapshot(result.snapshot) + syncHistoryAvailability() + }, [applySnapshot, getCurrentSnapshot, syncHistoryAvailability]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.metaKey && !event.ctrlKey) { + return + } + if (isEditableKeyboardTarget(event.target)) { + return + } + const key = event.key.toLowerCase() + if (key === "z" && event.shiftKey) { + event.preventDefault() + redoWorkflowEdit() + return + } + if (key === "y") { + event.preventDefault() + redoWorkflowEdit() + return + } + if (key === "z") { + event.preventDefault() + undoWorkflowEdit() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [redoWorkflowEdit, undoWorkflowEdit]) + const onConnect = useCallback( (connection: Connection) => { if (!connection.source || !connection.target) { return } + pushCurrentSnapshotToHistory() const edge = { ...connection, id: uniqueEdgeId(edges, connection.source, connection.target), @@ -338,7 +450,7 @@ export function WorkflowEditor({ }) }) }, - [edges, nodeSpecs, setEdges, setNodes] + [edges, nodeSpecs, pushCurrentSnapshotToHistory, setEdges, setNodes] ) const connectToNode = useCallback( @@ -382,7 +494,73 @@ export function WorkflowEditor({ [connectToNode] ) + const onWorkflowNodesChange = useCallback( + (changes: NodeChange[]) => { + if (changes.some((change) => change.type === "remove")) { + pushCurrentSnapshotToHistory() + } + onNodesChange(changes) + }, + [onNodesChange, pushCurrentSnapshotToHistory] + ) + + const onWorkflowEdgesChange = useCallback( + (changes: EdgeChange[]) => { + if (changes.some((change) => change.type === "remove")) { + pushCurrentSnapshotToHistory() + } + onEdgesChange(changes) + }, + [onEdgesChange, pushCurrentSnapshotToHistory] + ) + + const onNodeDragStart = useCallback>(() => { + dragStartSnapshotRef.current = getCurrentSnapshot() + }, [getCurrentSnapshot]) + + const onNodeDrag = useCallback>( + (_event, node) => { + const nextHelperLines = calculateWorkflowHelperLines(nodes, node) + setHelperLines({ + horizontal: nextHelperLines.horizontal, + vertical: nextHelperLines.vertical, + }) + if ( + nextHelperLines.position.x === node.position.x && + nextHelperLines.position.y === node.position.y + ) { + return + } + setNodes((current) => + current.map((item) => + item.id === node.id + ? { + ...item, + position: nextHelperLines.position, + } + : item + ) + ) + }, + [nodes, setNodes] + ) + + const onNodeDragStop = useCallback>((_event, node) => { + setHelperLines({}) + const startSnapshot = dragStartSnapshotRef.current + dragStartSnapshotRef.current = null + const startNode = startSnapshot?.nodes.find((item) => item.id === node.id) + if ( + startSnapshot && + startNode && + (startNode.position.x !== node.position.x || startNode.position.y !== node.position.y) + ) { + pushSnapshotToHistory(startSnapshot) + } + }, [pushSnapshotToHistory]) + const addNode = (spec: AIWorkflowNodeSpec) => { + pushCurrentSnapshotToHistory() setNodes((current) => { const node = createWorkflowNodeFromSpec( spec, @@ -403,6 +581,7 @@ export function WorkflowEditor({ const addNodeAfter = useCallback( (sourceNodeId: string, spec: AIWorkflowNodeSpec) => { + pushCurrentSnapshotToHistory() setNodes((current) => { const sourceNode = current.find((node) => node.id === sourceNodeId) const nextPosition = sourceNode @@ -427,7 +606,7 @@ export function WorkflowEditor({ return [...current, nextNode] }) }, - [setEdges, setNodes] + [pushCurrentSnapshotToHistory, setEdges, setNodes] ) const renderedNodes = useMemo( @@ -452,6 +631,7 @@ export function WorkflowEditor({ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { return false } + pushCurrentSnapshotToHistory() const position = flowInstance.screenToFlowPosition({ x, y }) setNodes((current) => [ ...current, @@ -459,7 +639,7 @@ export function WorkflowEditor({ ]) return true }, - [flowInstance, setNodes] + [flowInstance, pushCurrentSnapshotToHistory, setNodes] ) const onNodePointerDown = (event: React.PointerEvent, spec: AIWorkflowNodeSpec) => { @@ -510,6 +690,7 @@ export function WorkflowEditor({ } const updateNodeData = (nodeId: string, data: WorkflowNodeData) => { + pushCurrentSnapshotToHistory() const nextData = { ...data, label: data.name ?? data.nodeType ?? nodeId, @@ -561,6 +742,7 @@ export function WorkflowEditor({ ) const updateEdgeCondition = (edgeId: string, condition?: WorkflowEdgeCondition) => { + pushCurrentSnapshotToHistory() const updateEdge = (edge: WorkflowFlowEdge) => ({ ...edge, label: condition ? "条件" : undefined, @@ -721,10 +903,13 @@ export function WorkflowEditor({ connectionMode={ConnectionMode.Loose} connectionRadius={34} connectOnClick - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} + onNodesChange={onWorkflowNodesChange} + onEdgesChange={onWorkflowEdgesChange} onConnect={onConnect} onConnectEnd={onConnectEnd} + onNodeDragStart={onNodeDragStart} + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragStop} onInit={setFlowInstance} onNodeClick={(event, node) => { event.stopPropagation() @@ -754,8 +939,17 @@ export function WorkflowEditor({ className="!bottom-4 !left-4 overflow-hidden !rounded-xl !border !border-border/70 !bg-background/95 !shadow-lg" showInteractive={false} /> + - +
+ + +
{propertyPanelNode || propertyPanelEdge ? (