Merge branch 'dev' into ntindle/reddit-rewrite

This commit is contained in:
Nicholas Tindle
2025-12-31 13:43:58 -06:00
committed by GitHub
16 changed files with 755 additions and 23 deletions

View File

@@ -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",

View File

@@ -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:

View File

@@ -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,

View File

@@ -30,6 +30,7 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
targetState={publishState}
onStateChange={handleStateChange}
preSelectedAgentId={flowID || undefined}
showTrigger={false}
/>
</>
);

View File

@@ -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>
);
}

View File

@@ -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,
};
};

View File

@@ -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 */}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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({

View File

@@ -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>

View File

@@ -30,6 +30,7 @@ export interface Props {
onStateChange?: (state: PublishState) => void;
preSelectedAgentId?: string;
preSelectedAgentVersion?: number;
showTrigger?: boolean;
}
export function usePublishAgentModal({

View 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;
}

View 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);
}

View File

@@ -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,
};