mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-06 22:03:59 -05:00
Merge branch 'dev' into ntindle/reddit-rewrite
This commit is contained in:
@@ -69,6 +69,7 @@
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
"dexie": "4.2.1",
|
||||
"dotenv": "17.2.3",
|
||||
"elliptic": "6.6.1",
|
||||
"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:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0
|
||||
dexie:
|
||||
specifier: 4.2.1
|
||||
version: 4.2.1
|
||||
dotenv:
|
||||
specifier: 17.2.3
|
||||
version: 17.2.3
|
||||
@@ -4428,6 +4431,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dexie@4.2.1:
|
||||
resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
@@ -12323,6 +12329,8 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dexie@4.2.1: {}
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diffie-hellman@5.0.3:
|
||||
|
||||
@@ -19,8 +19,8 @@ export const BuilderActionButton = ({
|
||||
"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)]",
|
||||
"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)]",
|
||||
"active:shadow-[inset_0_2px_4px_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:border-none active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
"transition-all duration-150",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
targetState={publishState}
|
||||
onStateChange={handleStateChange}
|
||||
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 { useShallow } from "zustand/react/shallow";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo, useEffect, useCallback } from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
@@ -21,6 +21,7 @@ import { okData } from "@/app/api/helpers";
|
||||
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
|
||||
import { resolveCollisions } from "./helpers/resolve-collision";
|
||||
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
||||
import { DraftRecoveryPopup } from "../../DraftRecoveryDialog/DraftRecoveryPopup";
|
||||
|
||||
export const Flow = () => {
|
||||
const [{ flowID, flowExecutionID }] = useQueryStates({
|
||||
@@ -60,26 +61,22 @@ export const Flow = () => {
|
||||
}, [setNodes, nodes]);
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
|
||||
useFlow();
|
||||
// for loading purpose
|
||||
const {
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isFlowContentLoading,
|
||||
isInitialLoadComplete,
|
||||
isLocked,
|
||||
setIsLocked,
|
||||
} = useFlow();
|
||||
|
||||
// This hook is used for websocket realtime updates.
|
||||
useFlowRealtime();
|
||||
|
||||
// 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(
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
@@ -115,6 +112,7 @@ export const Flow = () => {
|
||||
className="right-2 top-32 p-2"
|
||||
/>
|
||||
)}
|
||||
<DraftRecoveryPopup isInitialLoadComplete={isInitialLoadComplete} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{/* 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 { v4 as uuidv4 } from "uuid";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
@@ -151,5 +151,16 @@ export function useCopyPaste() {
|
||||
[getViewport, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handleCopyPaste(event);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [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 = () => {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [hasAutoFramed, setHasAutoFramed] = useState(false);
|
||||
const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false);
|
||||
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
||||
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
||||
const updateNodeStatus = useNodeStore(
|
||||
@@ -174,11 +175,23 @@ export const useFlow = () => {
|
||||
if (customNodes.length > 0 && graph?.links) {
|
||||
const timer = setTimeout(() => {
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
// Mark initial load as complete after history is initialized
|
||||
setIsInitialLoadComplete(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [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(() => {
|
||||
return () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
@@ -217,6 +230,7 @@ export const useFlow = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setHasAutoFramed(false);
|
||||
setIsInitialLoadComplete(false);
|
||||
}, [flowID, flowVersion]);
|
||||
|
||||
// Drag and drop block from block menu
|
||||
@@ -253,6 +267,7 @@ export const useFlow = () => {
|
||||
|
||||
return {
|
||||
isFlowContentLoading: isGraphLoading || isBlocksLoading,
|
||||
isInitialLoadComplete,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isLocked,
|
||||
|
||||
@@ -15,6 +15,11 @@ import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
|
||||
import { useGraphStore } from "../stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
draftService,
|
||||
clearTempFlowId,
|
||||
getTempFlowId,
|
||||
} from "@/services/builder-draft/draft-service";
|
||||
|
||||
export type SaveGraphOptions = {
|
||||
showToast?: boolean;
|
||||
@@ -52,12 +57,19 @@ export const useSaveGraph = ({
|
||||
const { mutateAsync: createNewGraph, isPending: isCreating } =
|
||||
usePostV1CreateNewGraph({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
onSuccess: async (response) => {
|
||||
const data = response.data as GraphModel;
|
||||
setQueryStates({
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
|
||||
const tempFlowId = getTempFlowId();
|
||||
if (tempFlowId) {
|
||||
await draftService.deleteDraft(tempFlowId);
|
||||
clearTempFlowId();
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
if (showToast) {
|
||||
toast({
|
||||
@@ -82,12 +94,18 @@ export const useSaveGraph = ({
|
||||
const { mutateAsync: updateGraph, isPending: isUpdating } =
|
||||
usePutV1UpdateGraphVersion({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
onSuccess: async (response) => {
|
||||
const data = response.data as GraphModel;
|
||||
setQueryStates({
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
|
||||
// Clear the draft for this flow after successful save
|
||||
if (data.id) {
|
||||
await draftService.deleteDraft(data.id);
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
if (showToast) {
|
||||
toast({
|
||||
|
||||
@@ -20,6 +20,7 @@ export function PublishAgentModal({
|
||||
onStateChange,
|
||||
preSelectedAgentId,
|
||||
preSelectedAgentVersion,
|
||||
showTrigger = true,
|
||||
}: Props) {
|
||||
const {
|
||||
// State
|
||||
@@ -121,9 +122,11 @@ export function PublishAgentModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
{trigger || <Button size="small">Publish Agent</Button>}
|
||||
</Dialog.Trigger>
|
||||
{showTrigger && (
|
||||
<Dialog.Trigger>
|
||||
{trigger || <Button size="small">Publish Agent</Button>}
|
||||
</Dialog.Trigger>
|
||||
)}
|
||||
<Dialog.Content>
|
||||
<div data-testid="publish-agent-modal">{renderContent()}</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface Props {
|
||||
onStateChange?: (state: PublishState) => void;
|
||||
preSelectedAgentId?: string;
|
||||
preSelectedAgentVersion?: number;
|
||||
showTrigger?: boolean;
|
||||
}
|
||||
|
||||
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