mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-07 22:33:57 -05:00
Merge branch 'dev' into ntindle/reddit-rewrite
This commit is contained in:
@@ -69,6 +69,7 @@
|
|||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
"dexie": "4.2.1",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"elliptic": "6.6.1",
|
"elliptic": "6.6.1",
|
||||||
"embla-carousel-react": "8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
|
|||||||
8
autogpt_platform/frontend/pnpm-lock.yaml
generated
8
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -131,6 +131,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: 4.1.0
|
specifier: 4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
dexie:
|
||||||
|
specifier: 4.2.1
|
||||||
|
version: 4.2.1
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: 17.2.3
|
specifier: 17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
@@ -4428,6 +4431,9 @@ packages:
|
|||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||||
|
|
||||||
|
dexie@4.2.1:
|
||||||
|
resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
|
||||||
|
|
||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
@@ -12323,6 +12329,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
dexie@4.2.1: {}
|
||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
diffie-hellman@5.0.3:
|
diffie-hellman@5.0.3:
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export const BuilderActionButton = ({
|
|||||||
"border border-zinc-200",
|
"border border-zinc-200",
|
||||||
"shadow-[inset_0_3px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
|
"shadow-[inset_0_3px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_2px_4px_0_rgba(0,0,0,0.4)]",
|
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_2px_4px_0_rgba(0,0,0,0.4)]",
|
||||||
"hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]",
|
"hover:border-none hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]",
|
||||||
"active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
|
"active:border-none active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||||
"transition-all duration-150",
|
"transition-all duration-150",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
|||||||
targetState={publishState}
|
targetState={publishState}
|
||||||
onStateChange={handleStateChange}
|
onStateChange={handleStateChange}
|
||||||
preSelectedAgentId={flowID || undefined}
|
preSelectedAgentId={flowID || undefined}
|
||||||
|
showTrigger={false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { ClockCounterClockwiseIcon, XIcon } from "@phosphor-icons/react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatTimeAgo } from "@/lib/utils/time";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
|
import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface DraftRecoveryPopupProps {
|
||||||
|
isInitialLoadComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraftRecoveryPopup({
|
||||||
|
isInitialLoadComplete,
|
||||||
|
}: DraftRecoveryPopupProps) {
|
||||||
|
const { isOpen, popupRef, nodeCount, edgeCount, savedAt, onLoad, onDiscard } =
|
||||||
|
useDraftRecoveryPopup(isInitialLoadComplete);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
ref={popupRef}
|
||||||
|
className={cn("absolute left-1/2 top-4 z-50")}
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
x: "-50%",
|
||||||
|
y: "-150%",
|
||||||
|
scale: 0.5,
|
||||||
|
filter: "blur(20px)",
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
x: "-50%",
|
||||||
|
y: "0%",
|
||||||
|
scale: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
y: "-150%",
|
||||||
|
scale: 0.5,
|
||||||
|
filter: "blur(20px)",
|
||||||
|
transition: { duration: 0.4, type: "spring", bounce: 0.2 },
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.2, type: "spring", bounce: 0.2 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-xlarge border border-amber-200 bg-amber-50 px-4 py-3 shadow-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||||
|
<ClockCounterClockwiseIcon className="h-5 w-5" weight="fill" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Text
|
||||||
|
variant="small-medium"
|
||||||
|
className="text-amber-900 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
Unsaved changes found
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
{nodeCount} block{nodeCount !== 1 ? "s" : ""}, {edgeCount}{" "}
|
||||||
|
connection
|
||||||
|
{edgeCount !== 1 ? "s" : ""} •{" "}
|
||||||
|
{formatTimeAgo(new Date(savedAt).toISOString())}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2 flex items-center gap-2">
|
||||||
|
<Tooltip delayDuration={10}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onLoad}
|
||||||
|
className="aspect-square min-w-0 p-1.5"
|
||||||
|
>
|
||||||
|
<ClockCounterClockwiseIcon size={20} weight="fill" />
|
||||||
|
<span className="sr-only">Restore changes</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Restore changes</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip delayDuration={10}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={onDiscard}
|
||||||
|
aria-label="Discard changes"
|
||||||
|
className="aspect-square min-w-0 p-1.5"
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
<span className="sr-only">Discard changes</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Discard changes</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useDraftManager } from "../FlowEditor/Flow/useDraftManager";
|
||||||
|
|
||||||
|
export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => {
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRecoveryOpen: isOpen,
|
||||||
|
savedAt,
|
||||||
|
nodeCount,
|
||||||
|
edgeCount,
|
||||||
|
loadDraft: onLoad,
|
||||||
|
discardDraft: onDiscard,
|
||||||
|
} = useDraftManager(isInitialLoadComplete);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
popupRef.current &&
|
||||||
|
!popupRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
onDiscard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, onDiscard]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onDiscard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onDiscard]);
|
||||||
|
return {
|
||||||
|
popupRef,
|
||||||
|
isOpen,
|
||||||
|
nodeCount,
|
||||||
|
edgeCount,
|
||||||
|
savedAt,
|
||||||
|
onLoad,
|
||||||
|
onDiscard,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import CustomEdge from "../edges/CustomEdge";
|
|||||||
import { useFlow } from "./useFlow";
|
import { useFlow } from "./useFlow";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useNodeStore } from "../../../stores/nodeStore";
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
import { useMemo, useEffect, useCallback } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||||
import { useFlowRealtime } from "./useFlowRealtime";
|
import { useFlowRealtime } from "./useFlowRealtime";
|
||||||
@@ -21,6 +21,7 @@ import { okData } from "@/app/api/helpers";
|
|||||||
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
|
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
|
||||||
import { resolveCollisions } from "./helpers/resolve-collision";
|
import { resolveCollisions } from "./helpers/resolve-collision";
|
||||||
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
||||||
|
import { DraftRecoveryPopup } from "../../DraftRecoveryDialog/DraftRecoveryPopup";
|
||||||
|
|
||||||
export const Flow = () => {
|
export const Flow = () => {
|
||||||
const [{ flowID, flowExecutionID }] = useQueryStates({
|
const [{ flowID, flowExecutionID }] = useQueryStates({
|
||||||
@@ -60,26 +61,22 @@ export const Flow = () => {
|
|||||||
}, [setNodes, nodes]);
|
}, [setNodes, nodes]);
|
||||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||||
|
|
||||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
// for loading purpose
|
||||||
const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
|
const {
|
||||||
useFlow();
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
isFlowContentLoading,
|
||||||
|
isInitialLoadComplete,
|
||||||
|
isLocked,
|
||||||
|
setIsLocked,
|
||||||
|
} = useFlow();
|
||||||
|
|
||||||
// This hook is used for websocket realtime updates.
|
// This hook is used for websocket realtime updates.
|
||||||
useFlowRealtime();
|
useFlowRealtime();
|
||||||
|
|
||||||
// Copy/paste functionality
|
// Copy/paste functionality
|
||||||
const handleCopyPaste = useCopyPaste();
|
useCopyPaste();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
handleCopyPaste(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [handleCopyPaste]);
|
|
||||||
const isGraphRunning = useGraphStore(
|
const isGraphRunning = useGraphStore(
|
||||||
useShallow((state) => state.isGraphRunning),
|
useShallow((state) => state.isGraphRunning),
|
||||||
);
|
);
|
||||||
@@ -115,6 +112,7 @@ export const Flow = () => {
|
|||||||
className="right-2 top-32 p-2"
|
className="right-2 top-32 p-2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<DraftRecoveryPopup isInitialLoadComplete={isInitialLoadComplete} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
{/* TODO: Need to update it in future - also do not send executionId as prop - rather use useQueryState inside the component */}
|
{/* TODO: Need to update it in future - also do not send executionId as prop - rather use useQueryState inside the component */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { useNodeStore } from "../../../stores/nodeStore";
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
@@ -151,5 +151,16 @@ export function useCopyPaste() {
|
|||||||
[getViewport, toast],
|
[getViewport, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
handleCopyPaste(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleCopyPaste]);
|
||||||
|
|
||||||
return handleCopyPaste;
|
return handleCopyPaste;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
|
||||||
|
import {
|
||||||
|
draftService,
|
||||||
|
getTempFlowId,
|
||||||
|
getOrCreateTempFlowId,
|
||||||
|
DraftData,
|
||||||
|
} from "@/services/builder-draft/draft-service";
|
||||||
|
import { BuilderDraft } from "@/lib/dexie/db";
|
||||||
|
import { cleanNodes, cleanEdges } from "@/lib/dexie/draft-utils";
|
||||||
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
|
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||||
|
import { useGraphStore } from "../../../stores/graphStore";
|
||||||
|
import { useHistoryStore } from "../../../stores/historyStore";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
|
||||||
|
const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds
|
||||||
|
|
||||||
|
interface DraftRecoveryState {
|
||||||
|
isOpen: boolean;
|
||||||
|
draft: BuilderDraft | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidated hook for draft persistence and recovery
|
||||||
|
* - Auto-saves builder state every 15 seconds
|
||||||
|
* - Saves on beforeunload event
|
||||||
|
* - Checks for and manages unsaved drafts on load
|
||||||
|
*/
|
||||||
|
export function useDraftManager(isInitialLoadComplete: boolean) {
|
||||||
|
const [state, setState] = useState<DraftRecoveryState>({
|
||||||
|
isOpen: false,
|
||||||
|
draft: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ flowID, flowVersion }] = useQueryStates({
|
||||||
|
flowID: parseAsString,
|
||||||
|
flowVersion: parseAsInteger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastSavedStateRef = useRef<DraftData | null>(null);
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isDirtyRef = useRef(false);
|
||||||
|
const hasCheckedForDraft = useRef(false);
|
||||||
|
|
||||||
|
const getEffectiveFlowId = useCallback((): string => {
|
||||||
|
return flowID || getOrCreateTempFlowId();
|
||||||
|
}, [flowID]);
|
||||||
|
|
||||||
|
const getCurrentState = useCallback((): DraftData => {
|
||||||
|
const nodes = useNodeStore.getState().nodes;
|
||||||
|
const edges = useEdgeStore.getState().edges;
|
||||||
|
const nodeCounter = useNodeStore.getState().nodeCounter;
|
||||||
|
const graphStore = useGraphStore.getState();
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
graphSchemas: {
|
||||||
|
input: graphStore.inputSchema,
|
||||||
|
credentials: graphStore.credentialsInputSchema,
|
||||||
|
output: graphStore.outputSchema,
|
||||||
|
},
|
||||||
|
nodeCounter,
|
||||||
|
flowVersion: flowVersion ?? undefined,
|
||||||
|
};
|
||||||
|
}, [flowVersion]);
|
||||||
|
|
||||||
|
const cleanStateForComparison = useCallback((stateData: DraftData) => {
|
||||||
|
return {
|
||||||
|
nodes: cleanNodes(stateData.nodes),
|
||||||
|
edges: cleanEdges(stateData.edges),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasChanges = useCallback((): boolean => {
|
||||||
|
const currentState = getCurrentState();
|
||||||
|
|
||||||
|
if (!lastSavedStateRef.current) {
|
||||||
|
return currentState.nodes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentClean = cleanStateForComparison(currentState);
|
||||||
|
const lastClean = cleanStateForComparison(lastSavedStateRef.current);
|
||||||
|
|
||||||
|
return !isEqual(currentClean, lastClean);
|
||||||
|
}, [getCurrentState, cleanStateForComparison]);
|
||||||
|
|
||||||
|
const saveDraft = useCallback(async () => {
|
||||||
|
const effectiveFlowId = getEffectiveFlowId();
|
||||||
|
const currentState = getCurrentState();
|
||||||
|
|
||||||
|
if (currentState.nodes.length === 0 && currentState.edges.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await draftService.saveDraft(effectiveFlowId, currentState);
|
||||||
|
lastSavedStateRef.current = currentState;
|
||||||
|
isDirtyRef.current = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DraftPersistence] Failed to save draft:", error);
|
||||||
|
}
|
||||||
|
}, [getEffectiveFlowId, getCurrentState, hasChanges]);
|
||||||
|
|
||||||
|
const scheduleSave = useCallback(() => {
|
||||||
|
isDirtyRef.current = true;
|
||||||
|
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveDraft();
|
||||||
|
}, AUTO_SAVE_INTERVAL_MS);
|
||||||
|
}, [saveDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribeNodes = useNodeStore.subscribe((storeState, prevState) => {
|
||||||
|
if (storeState.nodes !== prevState.nodes) {
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeEdges = useEdgeStore.subscribe((storeState, prevState) => {
|
||||||
|
if (storeState.edges !== prevState.edges) {
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeNodes();
|
||||||
|
unsubscribeEdges();
|
||||||
|
};
|
||||||
|
}, [scheduleSave]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (isDirtyRef.current) {
|
||||||
|
const effectiveFlowId = getEffectiveFlowId();
|
||||||
|
const currentState = getCurrentState();
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentState.nodes.length === 0 &&
|
||||||
|
currentState.edges.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draftService.saveDraft(effectiveFlowId, currentState).catch(() => {
|
||||||
|
// Ignore errors on unload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [getEffectiveFlowId, getCurrentState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (isDirtyRef.current) {
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [saveDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftService.cleanupExpired().catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"[DraftPersistence] Failed to cleanup expired drafts:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkForDraft = useCallback(async () => {
|
||||||
|
const effectiveFlowId = flowID || getTempFlowId();
|
||||||
|
|
||||||
|
if (!effectiveFlowId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const draft = await draftService.loadDraft(effectiveFlowId);
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNodes = useNodeStore.getState().nodes;
|
||||||
|
const currentEdges = useEdgeStore.getState().edges;
|
||||||
|
|
||||||
|
const isDifferent = draftService.isDraftDifferent(
|
||||||
|
draft,
|
||||||
|
currentNodes,
|
||||||
|
currentEdges,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) {
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
draft,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await draftService.deleteDraft(effectiveFlowId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DraftRecovery] Failed to check for draft:", error);
|
||||||
|
}
|
||||||
|
}, [flowID]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialLoadComplete && !hasCheckedForDraft.current) {
|
||||||
|
hasCheckedForDraft.current = true;
|
||||||
|
checkForDraft();
|
||||||
|
}
|
||||||
|
}, [isInitialLoadComplete, checkForDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasCheckedForDraft.current = false;
|
||||||
|
setState({
|
||||||
|
isOpen: false,
|
||||||
|
draft: null,
|
||||||
|
});
|
||||||
|
}, [flowID]);
|
||||||
|
|
||||||
|
const loadDraft = useCallback(async () => {
|
||||||
|
if (!state.draft) return;
|
||||||
|
|
||||||
|
const { draft } = state;
|
||||||
|
|
||||||
|
try {
|
||||||
|
useNodeStore.getState().setNodes(draft.nodes);
|
||||||
|
useEdgeStore.getState().setEdges(draft.edges);
|
||||||
|
|
||||||
|
// Restore nodeCounter to prevent ID conflicts when adding new nodes
|
||||||
|
if (draft.nodeCounter !== undefined) {
|
||||||
|
useNodeStore.setState({ nodeCounter: draft.nodeCounter });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.graphSchemas) {
|
||||||
|
useGraphStore
|
||||||
|
.getState()
|
||||||
|
.setGraphSchemas(
|
||||||
|
draft.graphSchemas.input as Record<string, unknown> | null,
|
||||||
|
draft.graphSchemas.credentials as Record<string, unknown> | null,
|
||||||
|
draft.graphSchemas.output as Record<string, unknown> | null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
useHistoryStore.getState().initializeHistory();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
await draftService.deleteDraft(draft.id);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isOpen: false,
|
||||||
|
draft: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DraftRecovery] Failed to load draft:", error);
|
||||||
|
}
|
||||||
|
}, [state.draft]);
|
||||||
|
|
||||||
|
const discardDraft = useCallback(async () => {
|
||||||
|
if (!state.draft) {
|
||||||
|
setState({ isOpen: false, draft: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await draftService.deleteDraft(state.draft.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DraftRecovery] Failed to discard draft:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ isOpen: false, draft: null });
|
||||||
|
}, [state.draft]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Recovery popup props
|
||||||
|
isRecoveryOpen: state.isOpen,
|
||||||
|
savedAt: state.draft?.savedAt ?? 0,
|
||||||
|
nodeCount: state.draft?.nodes.length ?? 0,
|
||||||
|
edgeCount: state.draft?.edges.length ?? 0,
|
||||||
|
loadDraft,
|
||||||
|
discardDraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
|
|||||||
export const useFlow = () => {
|
export const useFlow = () => {
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const [hasAutoFramed, setHasAutoFramed] = useState(false);
|
const [hasAutoFramed, setHasAutoFramed] = useState(false);
|
||||||
|
const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false);
|
||||||
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
||||||
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
||||||
const updateNodeStatus = useNodeStore(
|
const updateNodeStatus = useNodeStore(
|
||||||
@@ -174,11 +175,23 @@ export const useFlow = () => {
|
|||||||
if (customNodes.length > 0 && graph?.links) {
|
if (customNodes.length > 0 && graph?.links) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
useHistoryStore.getState().initializeHistory();
|
useHistoryStore.getState().initializeHistory();
|
||||||
|
// Mark initial load as complete after history is initialized
|
||||||
|
setIsInitialLoadComplete(true);
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [customNodes, graph?.links]);
|
}, [customNodes, graph?.links]);
|
||||||
|
|
||||||
|
// Also mark as complete for new flows (no flowID) after a short delay
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flowID && !isGraphLoading && !isBlocksLoading) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsInitialLoadComplete(true);
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [flowID, isGraphLoading, isBlocksLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
useNodeStore.getState().setNodes([]);
|
useNodeStore.getState().setNodes([]);
|
||||||
@@ -217,6 +230,7 @@ export const useFlow = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasAutoFramed(false);
|
setHasAutoFramed(false);
|
||||||
|
setIsInitialLoadComplete(false);
|
||||||
}, [flowID, flowVersion]);
|
}, [flowID, flowVersion]);
|
||||||
|
|
||||||
// Drag and drop block from block menu
|
// Drag and drop block from block menu
|
||||||
@@ -253,6 +267,7 @@ export const useFlow = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isFlowContentLoading: isGraphLoading || isBlocksLoading,
|
isFlowContentLoading: isGraphLoading || isBlocksLoading,
|
||||||
|
isInitialLoadComplete,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
isLocked,
|
isLocked,
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import { useEdgeStore } from "../stores/edgeStore";
|
|||||||
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
|
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
|
||||||
import { useGraphStore } from "../stores/graphStore";
|
import { useGraphStore } from "../stores/graphStore";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import {
|
||||||
|
draftService,
|
||||||
|
clearTempFlowId,
|
||||||
|
getTempFlowId,
|
||||||
|
} from "@/services/builder-draft/draft-service";
|
||||||
|
|
||||||
export type SaveGraphOptions = {
|
export type SaveGraphOptions = {
|
||||||
showToast?: boolean;
|
showToast?: boolean;
|
||||||
@@ -52,12 +57,19 @@ export const useSaveGraph = ({
|
|||||||
const { mutateAsync: createNewGraph, isPending: isCreating } =
|
const { mutateAsync: createNewGraph, isPending: isCreating } =
|
||||||
usePostV1CreateNewGraph({
|
usePostV1CreateNewGraph({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: (response) => {
|
onSuccess: async (response) => {
|
||||||
const data = response.data as GraphModel;
|
const data = response.data as GraphModel;
|
||||||
setQueryStates({
|
setQueryStates({
|
||||||
flowID: data.id,
|
flowID: data.id,
|
||||||
flowVersion: data.version,
|
flowVersion: data.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tempFlowId = getTempFlowId();
|
||||||
|
if (tempFlowId) {
|
||||||
|
await draftService.deleteDraft(tempFlowId);
|
||||||
|
clearTempFlowId();
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess?.(data);
|
onSuccess?.(data);
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast({
|
toast({
|
||||||
@@ -82,12 +94,18 @@ export const useSaveGraph = ({
|
|||||||
const { mutateAsync: updateGraph, isPending: isUpdating } =
|
const { mutateAsync: updateGraph, isPending: isUpdating } =
|
||||||
usePutV1UpdateGraphVersion({
|
usePutV1UpdateGraphVersion({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: (response) => {
|
onSuccess: async (response) => {
|
||||||
const data = response.data as GraphModel;
|
const data = response.data as GraphModel;
|
||||||
setQueryStates({
|
setQueryStates({
|
||||||
flowID: data.id,
|
flowID: data.id,
|
||||||
flowVersion: data.version,
|
flowVersion: data.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear the draft for this flow after successful save
|
||||||
|
if (data.id) {
|
||||||
|
await draftService.deleteDraft(data.id);
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess?.(data);
|
onSuccess?.(data);
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function PublishAgentModal({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
preSelectedAgentId,
|
preSelectedAgentId,
|
||||||
preSelectedAgentVersion,
|
preSelectedAgentVersion,
|
||||||
|
showTrigger = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
// State
|
// State
|
||||||
@@ -121,9 +122,11 @@ export function PublishAgentModal({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Trigger>
|
{showTrigger && (
|
||||||
{trigger || <Button size="small">Publish Agent</Button>}
|
<Dialog.Trigger>
|
||||||
</Dialog.Trigger>
|
{trigger || <Button size="small">Publish Agent</Button>}
|
||||||
|
</Dialog.Trigger>
|
||||||
|
)}
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<div data-testid="publish-agent-modal">{renderContent()}</div>
|
<div data-testid="publish-agent-modal">{renderContent()}</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface Props {
|
|||||||
onStateChange?: (state: PublishState) => void;
|
onStateChange?: (state: PublishState) => void;
|
||||||
preSelectedAgentId?: string;
|
preSelectedAgentId?: string;
|
||||||
preSelectedAgentVersion?: number;
|
preSelectedAgentVersion?: number;
|
||||||
|
showTrigger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePublishAgentModal({
|
export function usePublishAgentModal({
|
||||||
|
|||||||
46
autogpt_platform/frontend/src/lib/dexie/db.ts
Normal file
46
autogpt_platform/frontend/src/lib/dexie/db.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Dexie, { type EntityTable } from "dexie";
|
||||||
|
import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||||
|
import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge";
|
||||||
|
|
||||||
|
// 24 hrs expiry
|
||||||
|
export const DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface BuilderDraft {
|
||||||
|
id: string;
|
||||||
|
nodes: CustomNode[];
|
||||||
|
edges: CustomEdge[];
|
||||||
|
graphSchemas: {
|
||||||
|
input: Record<string, unknown> | null;
|
||||||
|
credentials: Record<string, unknown> | null;
|
||||||
|
output: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
nodeCounter: number;
|
||||||
|
savedAt: number;
|
||||||
|
flowVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuilderDatabase extends Dexie {
|
||||||
|
drafts!: EntityTable<BuilderDraft, "id">;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("AutoGPTBuilderDB");
|
||||||
|
|
||||||
|
this.version(1).stores({
|
||||||
|
drafts: "id, savedAt",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton database instance
|
||||||
|
export const db = new BuilderDatabase();
|
||||||
|
|
||||||
|
export async function cleanupExpiredDrafts(): Promise<number> {
|
||||||
|
const expiryThreshold = Date.now() - DRAFT_EXPIRY_MS;
|
||||||
|
|
||||||
|
const deletedCount = await db.drafts
|
||||||
|
.where("savedAt")
|
||||||
|
.below(expiryThreshold)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
33
autogpt_platform/frontend/src/lib/dexie/draft-utils.ts
Normal file
33
autogpt_platform/frontend/src/lib/dexie/draft-utils.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||||
|
import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge";
|
||||||
|
|
||||||
|
export function cleanNode(node: CustomNode) {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
position: node.position,
|
||||||
|
data: {
|
||||||
|
hardcodedValues: node.data.hardcodedValues,
|
||||||
|
title: node.data.title,
|
||||||
|
block_id: node.data.block_id,
|
||||||
|
metadata: node.data.metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanEdge(edge: CustomEdge) {
|
||||||
|
return {
|
||||||
|
id: edge.id,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
sourceHandle: edge.sourceHandle,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanNodes(nodes: CustomNode[]) {
|
||||||
|
return nodes.map(cleanNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanEdges(edges: CustomEdge[]) {
|
||||||
|
return edges.map(cleanEdge);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
BuilderDraft,
|
||||||
|
DRAFT_EXPIRY_MS,
|
||||||
|
cleanupExpiredDrafts,
|
||||||
|
} from "../../lib/dexie/db";
|
||||||
|
import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||||
|
import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge";
|
||||||
|
import { cleanNodes, cleanEdges } from "../../lib/dexie/draft-utils";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import { environment } from "@/services/environment";
|
||||||
|
|
||||||
|
const SESSION_TEMP_ID_KEY = "builder_temp_flow_id";
|
||||||
|
|
||||||
|
export function getOrCreateTempFlowId(): string {
|
||||||
|
if (environment.isServerSide()) {
|
||||||
|
return `temp_${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempId = sessionStorage.getItem(SESSION_TEMP_ID_KEY);
|
||||||
|
if (!tempId) {
|
||||||
|
tempId = `temp_${crypto.randomUUID()}`;
|
||||||
|
sessionStorage.setItem(SESSION_TEMP_ID_KEY, tempId);
|
||||||
|
}
|
||||||
|
return tempId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTempFlowId(): void {
|
||||||
|
if (environment.isClientSide()) {
|
||||||
|
sessionStorage.removeItem(SESSION_TEMP_ID_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTempFlowId(): string | null {
|
||||||
|
if (environment.isServerSide()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sessionStorage.getItem(SESSION_TEMP_ID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftData {
|
||||||
|
nodes: CustomNode[];
|
||||||
|
edges: CustomEdge[];
|
||||||
|
graphSchemas: {
|
||||||
|
input: Record<string, unknown> | null;
|
||||||
|
credentials: Record<string, unknown> | null;
|
||||||
|
output: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
nodeCounter: number;
|
||||||
|
flowVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const draftService = {
|
||||||
|
async saveDraft(flowId: string, data: DraftData): Promise<void> {
|
||||||
|
const draft: BuilderDraft = {
|
||||||
|
id: flowId,
|
||||||
|
nodes: data.nodes,
|
||||||
|
edges: data.edges,
|
||||||
|
graphSchemas: data.graphSchemas,
|
||||||
|
nodeCounter: data.nodeCounter,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
flowVersion: data.flowVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.drafts.put(draft);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadDraft(flowId: string): Promise<BuilderDraft | null> {
|
||||||
|
const draft = await db.drafts.get(flowId);
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const age = Date.now() - draft.savedAt;
|
||||||
|
if (age > DRAFT_EXPIRY_MS) {
|
||||||
|
await this.deleteDraft(flowId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDraft(flowId: string): Promise<void> {
|
||||||
|
await db.drafts.delete(flowId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async hasDraft(flowId: string): Promise<boolean> {
|
||||||
|
const draft = await db.drafts.get(flowId);
|
||||||
|
if (!draft) return false;
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
const age = Date.now() - draft.savedAt;
|
||||||
|
if (age > DRAFT_EXPIRY_MS) {
|
||||||
|
await this.deleteDraft(flowId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
isDraftDifferent(
|
||||||
|
draft: BuilderDraft,
|
||||||
|
currentNodes: CustomNode[],
|
||||||
|
currentEdges: CustomEdge[],
|
||||||
|
): boolean {
|
||||||
|
const draftNodesClean = cleanNodes(draft.nodes);
|
||||||
|
const currentNodesClean = cleanNodes(currentNodes);
|
||||||
|
const draftEdgesClean = cleanEdges(draft.edges);
|
||||||
|
const currentEdgesClean = cleanEdges(currentEdges);
|
||||||
|
|
||||||
|
const nodesDifferent = !isEqual(draftNodesClean, currentNodesClean);
|
||||||
|
const edgesDifferent = !isEqual(draftEdgesClean, currentEdgesClean);
|
||||||
|
|
||||||
|
return nodesDifferent || edgesDifferent;
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupExpired: cleanupExpiredDrafts,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user