mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 18:15:33 +00:00
(feat)workflows: add undo/redo
This commit is contained in:
1 parent
08f758b06b
commit
3356ca19a9
2 files changed
+281
-31
No files matched your search
@@ -11,8 +11,10 @@ import {
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Redo2,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -31,6 +33,7 @@ import ReactFlow, {
|
||||
Node,
|
||||
NodeChange,
|
||||
NodeTypes,
|
||||
Panel,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from 'reactflow';
|
||||
@@ -66,6 +69,7 @@ import { Agent } from '../types';
|
||||
import { ConditionCase, WorkflowNode } from '../types/workflow';
|
||||
import MobileBlocker from './components/MobileBlocker';
|
||||
import PromptTextArea from './components/PromptTextArea';
|
||||
import { useUndoRedo, WorkflowSnapshot } from './hooks/useUndoRedo';
|
||||
import {
|
||||
AgentNode,
|
||||
ConditionNode,
|
||||
@@ -116,6 +120,13 @@ function validateJsonSchemaConfig(schema: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
}
|
||||
|
||||
function createEmptyWorkflowAgent(): Agent {
|
||||
return {
|
||||
id: '',
|
||||
@@ -390,6 +401,36 @@ function WorkflowBuilderInner() {
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
|
||||
const handleHistoryRestore = useCallback(
|
||||
(snapshot: WorkflowSnapshot) => {
|
||||
setAgentJsonSchemaDrafts({});
|
||||
setAgentJsonSchemaErrors({});
|
||||
if (!selectedNode) return;
|
||||
const restoredNode = snapshot.nodes.find((n) => n.id === selectedNode.id);
|
||||
if (restoredNode) {
|
||||
setSelectedNode(restoredNode);
|
||||
} else {
|
||||
setSelectedNode(null);
|
||||
setShowNodeConfig(false);
|
||||
}
|
||||
},
|
||||
[selectedNode],
|
||||
);
|
||||
|
||||
const { takeSnapshot, undo, redo, clearHistory, canUndo, canRedo } =
|
||||
useUndoRedo({
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
onRestore: handleHistoryRestore,
|
||||
});
|
||||
|
||||
const snapshotBeforeCanvasChange = useCallback(
|
||||
() => takeSnapshot(),
|
||||
[takeSnapshot],
|
||||
);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) =>
|
||||
setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
@@ -404,19 +445,21 @@ function WorkflowBuilderInner() {
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
const exists = edges.some(
|
||||
(e) =>
|
||||
e.source === params.source &&
|
||||
e.sourceHandle === params.sourceHandle &&
|
||||
e.target === params.target &&
|
||||
e.targetHandle === params.targetHandle,
|
||||
);
|
||||
if (exists) return;
|
||||
|
||||
takeSnapshot();
|
||||
|
||||
const targetNode = nodes.find((n) => n.id === params.target);
|
||||
const isEndNode = targetNode?.type === 'end';
|
||||
|
||||
setEdges((eds) => {
|
||||
const exists = eds.some(
|
||||
(e) =>
|
||||
e.source === params.source &&
|
||||
e.sourceHandle === params.sourceHandle &&
|
||||
e.target === params.target &&
|
||||
e.targetHandle === params.targetHandle,
|
||||
);
|
||||
if (exists) return eds;
|
||||
|
||||
const targetNode = nodes.find((n) => n.id === params.target);
|
||||
const isEndNode = targetNode?.type === 'end';
|
||||
|
||||
const filtered = eds.filter(
|
||||
(e) =>
|
||||
!(
|
||||
@@ -433,12 +476,16 @@ function WorkflowBuilderInner() {
|
||||
return addEdge(params, filtered);
|
||||
});
|
||||
},
|
||||
[nodes],
|
||||
[nodes, edges, takeSnapshot],
|
||||
);
|
||||
|
||||
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
|
||||
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
||||
}, []);
|
||||
const onEdgeClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
takeSnapshot();
|
||||
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
||||
},
|
||||
[takeSnapshot],
|
||||
);
|
||||
|
||||
const handleNodeDragStart = useCallback(
|
||||
(e: React.DragEvent, nodeType: string) => {
|
||||
@@ -474,6 +521,8 @@ function WorkflowBuilderInner() {
|
||||
const type = event.dataTransfer.getData('application/reactflow');
|
||||
if (!type) return;
|
||||
|
||||
takeSnapshot();
|
||||
|
||||
const position = reactFlowInstance.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
@@ -522,7 +571,7 @@ function WorkflowBuilderInner() {
|
||||
|
||||
setNodes((nds) => nds.concat(baseNode));
|
||||
},
|
||||
[reactFlowInstance, availableModels, defaultAgentModelId],
|
||||
[reactFlowInstance, availableModels, defaultAgentModelId, takeSnapshot],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
@@ -535,6 +584,7 @@ function WorkflowBuilderInner() {
|
||||
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
if (!selectedNode || selectedNode.type === 'start') return;
|
||||
takeSnapshot();
|
||||
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
|
||||
setEdges((eds) =>
|
||||
eds.filter(
|
||||
@@ -555,11 +605,15 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
setSelectedNode(null);
|
||||
setShowNodeConfig(false);
|
||||
}, [selectedNode]);
|
||||
}, [selectedNode, takeSnapshot]);
|
||||
|
||||
const handleUpdateNodeData = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
(data: Record<string, unknown>, options?: { snapshot?: boolean }) => {
|
||||
if (!selectedNode) return;
|
||||
if (options?.snapshot !== false) {
|
||||
// Group per node so a burst of keystrokes becomes one undo step
|
||||
takeSnapshot(`node-data:${selectedNode.id}`);
|
||||
}
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === selectedNode.id ? { ...n, data: { ...n.data, ...data } } : n,
|
||||
@@ -569,7 +623,7 @@ function WorkflowBuilderInner() {
|
||||
prev ? { ...prev, data: { ...prev.data, ...data } } : null,
|
||||
);
|
||||
},
|
||||
[selectedNode],
|
||||
[selectedNode, takeSnapshot],
|
||||
);
|
||||
|
||||
const handleAgentJsonSchemaChange = useCallback(
|
||||
@@ -672,7 +726,22 @@ function WorkflowBuilderInner() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' && selectedNode) {
|
||||
const isEditable = isEditableTarget(e.target);
|
||||
if ((e.ctrlKey || e.metaKey) && !isEditable) {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) redo();
|
||||
else undo();
|
||||
return;
|
||||
}
|
||||
if (key === 'y') {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Delete' && selectedNode && !isEditable) {
|
||||
handleDeleteNode();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
@@ -682,7 +751,7 @@ function WorkflowBuilderInner() {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedNode, handleDeleteNode]);
|
||||
}, [selectedNode, handleDeleteNode, undo, redo]);
|
||||
|
||||
const handlePanelBackdropClick = useCallback(() => {
|
||||
setShowNodeConfig(false);
|
||||
@@ -741,15 +810,18 @@ function WorkflowBuilderInner() {
|
||||
if (!defaultAgentModelId) return;
|
||||
if (selectedNode.data.config?.model_id) return;
|
||||
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
...(selectedNode.data.config || {}),
|
||||
model_id: defaultAgentModelId,
|
||||
llm_name:
|
||||
availableModels.find((model) => model.id === defaultAgentModelId)
|
||||
?.provider || '',
|
||||
handleUpdateNodeData(
|
||||
{
|
||||
config: {
|
||||
...(selectedNode.data.config || {}),
|
||||
model_id: defaultAgentModelId,
|
||||
llm_name:
|
||||
availableModels.find((model) => model.id === defaultAgentModelId)
|
||||
?.provider || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
{ snapshot: false },
|
||||
);
|
||||
}, [
|
||||
selectedNode,
|
||||
defaultAgentModelId,
|
||||
@@ -865,6 +937,7 @@ function WorkflowBuilderInner() {
|
||||
setAgentJsonSchemaErrors({});
|
||||
setNodes(mappedNodes);
|
||||
setEdges(mappedEdges);
|
||||
clearHistory();
|
||||
setSavedWorkflowSignature(
|
||||
JSON.stringify(
|
||||
createWorkflowPayload(
|
||||
@@ -887,7 +960,7 @@ function WorkflowBuilderInner() {
|
||||
}
|
||||
};
|
||||
loadWorkflow();
|
||||
}, [workflowId, reactFlowInstance, token]);
|
||||
}, [workflowId, reactFlowInstance, token, clearHistory]);
|
||||
|
||||
const validateWorkflow = useCallback((): string[] => {
|
||||
const errors: string[] = [];
|
||||
@@ -1742,12 +1815,43 @@ function WorkflowBuilderInner() {
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDragStart={snapshotBeforeCanvasChange}
|
||||
onSelectionDragStart={snapshotBeforeCanvasChange}
|
||||
onNodesDelete={snapshotBeforeCanvasChange}
|
||||
onEdgesDelete={snapshotBeforeCanvasChange}
|
||||
nodeTypes={nodeTypes}
|
||||
nodeDragThreshold={1}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<Panel position="top-left" className="flex gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
aria-label="Undo"
|
||||
className="bg-card"
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
aria-label="Redo"
|
||||
className="bg-card"
|
||||
>
|
||||
<Redo2 size={16} />
|
||||
</Button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{showNodeConfig && selectedNode && (
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Edge, Node } from 'reactflow';
|
||||
|
||||
export type WorkflowSnapshot = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
type UseUndoRedoOptions = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
setNodes: Dispatch<SetStateAction<Node[]>>;
|
||||
setEdges: Dispatch<SetStateAction<Edge[]>>;
|
||||
onRestore?: (snapshot: WorkflowSnapshot) => void;
|
||||
maxHistorySize?: number;
|
||||
};
|
||||
|
||||
const SNAPSHOT_GROUP_WINDOW_MS = 1000;
|
||||
|
||||
// Selection is not part of history: restored elements keep whatever
|
||||
// selection the user has right now instead of the flags baked into the
|
||||
// snapshot when it was taken.
|
||||
function withCurrentSelection<T extends { id: string; selected?: boolean }>(
|
||||
restored: T[],
|
||||
current: T[],
|
||||
): T[] {
|
||||
const selectedIds = new Set(
|
||||
current.filter((el) => el.selected).map((el) => el.id),
|
||||
);
|
||||
return restored.map((el) =>
|
||||
Boolean(el.selected) === selectedIds.has(el.id)
|
||||
? el
|
||||
: { ...el, selected: selectedIds.has(el.id) },
|
||||
);
|
||||
}
|
||||
|
||||
export function useUndoRedo({
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
onRestore,
|
||||
maxHistorySize = 100,
|
||||
}: UseUndoRedoOptions) {
|
||||
const [past, setPast] = useState<WorkflowSnapshot[]>([]);
|
||||
const [future, setFuture] = useState<WorkflowSnapshot[]>([]);
|
||||
|
||||
const stateRef = useRef<WorkflowSnapshot>({ nodes, edges });
|
||||
stateRef.current = { nodes, edges };
|
||||
const pastRef = useRef(past);
|
||||
pastRef.current = past;
|
||||
const futureRef = useRef(future);
|
||||
futureRef.current = future;
|
||||
const onRestoreRef = useRef(onRestore);
|
||||
onRestoreRef.current = onRestore;
|
||||
|
||||
// Consecutive calls sharing a group key (e.g. keystrokes editing one
|
||||
// node's config) collapse into a single history entry while each call
|
||||
// stays inside the group window.
|
||||
const lastGroupKeyRef = useRef<string | null>(null);
|
||||
const lastGroupTimeRef = useRef(0);
|
||||
// One user action can request several snapshots in the same event tick
|
||||
// (deleting a node also deletes its edges); only the first one counts.
|
||||
const tickGuardRef = useRef(false);
|
||||
|
||||
const takeSnapshot = useCallback(
|
||||
(groupKey?: string) => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
groupKey &&
|
||||
groupKey === lastGroupKeyRef.current &&
|
||||
now - lastGroupTimeRef.current < SNAPSHOT_GROUP_WINDOW_MS
|
||||
) {
|
||||
lastGroupTimeRef.current = now;
|
||||
return;
|
||||
}
|
||||
lastGroupKeyRef.current = groupKey ?? null;
|
||||
lastGroupTimeRef.current = now;
|
||||
|
||||
if (tickGuardRef.current) return;
|
||||
tickGuardRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
tickGuardRef.current = false;
|
||||
});
|
||||
|
||||
const snapshot: WorkflowSnapshot = {
|
||||
nodes: stateRef.current.nodes,
|
||||
edges: stateRef.current.edges,
|
||||
};
|
||||
setPast((prev) =>
|
||||
prev.length >= maxHistorySize
|
||||
? [...prev.slice(prev.length - maxHistorySize + 1), snapshot]
|
||||
: [...prev, snapshot],
|
||||
);
|
||||
setFuture([]);
|
||||
},
|
||||
[maxHistorySize],
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const previous = pastRef.current[pastRef.current.length - 1];
|
||||
if (!previous) return;
|
||||
const current: WorkflowSnapshot = {
|
||||
nodes: stateRef.current.nodes,
|
||||
edges: stateRef.current.edges,
|
||||
};
|
||||
lastGroupKeyRef.current = null;
|
||||
setPast(pastRef.current.slice(0, -1));
|
||||
setFuture([...futureRef.current, current]);
|
||||
setNodes(withCurrentSelection(previous.nodes, current.nodes));
|
||||
setEdges(withCurrentSelection(previous.edges, current.edges));
|
||||
onRestoreRef.current?.(previous);
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const next = futureRef.current[futureRef.current.length - 1];
|
||||
if (!next) return;
|
||||
const current: WorkflowSnapshot = {
|
||||
nodes: stateRef.current.nodes,
|
||||
edges: stateRef.current.edges,
|
||||
};
|
||||
lastGroupKeyRef.current = null;
|
||||
setFuture(futureRef.current.slice(0, -1));
|
||||
setPast([...pastRef.current, current]);
|
||||
setNodes(withCurrentSelection(next.nodes, current.nodes));
|
||||
setEdges(withCurrentSelection(next.edges, current.edges));
|
||||
onRestoreRef.current?.(next);
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
lastGroupKeyRef.current = null;
|
||||
setPast([]);
|
||||
setFuture([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
takeSnapshot,
|
||||
undo,
|
||||
redo,
|
||||
clearHistory,
|
||||
canUndo: past.length > 0,
|
||||
canRedo: future.length > 0,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user