Browser recording action (#4130)
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
// import { getClient } from "@/api/AxiosClient";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
// import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
// import { type MessageInExfiltratedEvent } from "@/store/useRecordingStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import {
|
||||
type ActionBlock,
|
||||
type WorkflowBlock,
|
||||
} from "@/routes/workflows/types/workflowTypes";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const FAIL_QUITE_NO_EVENTS = "FAIL-QUIET:NO-EVENTS" as const;
|
||||
|
||||
const useProcessRecordingMutation = ({
|
||||
browserSessionId,
|
||||
onSuccess,
|
||||
}: {
|
||||
browserSessionId: string | null;
|
||||
onSuccess?: (workflowBlocks: Array<WorkflowBlock>) => void;
|
||||
}) => {
|
||||
// const credentialGetter = useCredentialGetter();
|
||||
const recordingStore = useRecordingStore();
|
||||
|
||||
const processRecordingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!browserSessionId) {
|
||||
throw new Error(
|
||||
"Cannot process recording without a valid browser session ID.",
|
||||
);
|
||||
}
|
||||
|
||||
const eventCount = recordingStore.getEventCount();
|
||||
|
||||
if (eventCount === 0) {
|
||||
throw new Error(FAIL_QUITE_NO_EVENTS);
|
||||
}
|
||||
|
||||
// (this flushes any pending events)
|
||||
const compressedChunks = await recordingStore.getCompressedChunks();
|
||||
|
||||
// TODO: Replace this mock with actual API call when endpoint is ready
|
||||
// const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
// return client
|
||||
// .post<
|
||||
// { compressed_chunks: string[] },
|
||||
// { data: Array<WorkflowBlock> }
|
||||
// >(`/browser_sessions/${browserSessionId}/process_recording`, {
|
||||
// compressed_chunks: compressedChunks,
|
||||
// })
|
||||
// .then((response) => response.data);
|
||||
|
||||
// Mock response with 2-second delay
|
||||
console.log(
|
||||
`Processing ${eventCount} events in ${compressedChunks.length} compressed chunks`,
|
||||
);
|
||||
await sleep(2000);
|
||||
|
||||
// Return mock workflow blocks with two ActionBlocks
|
||||
const mockWorkflowBlocks: Array<WorkflowBlock> = [
|
||||
{
|
||||
block_type: "action",
|
||||
label: "action_1",
|
||||
title: "Enter search term",
|
||||
navigation_goal: "Enter 'foo' in the search field",
|
||||
url: null,
|
||||
error_code_mapping: null,
|
||||
parameters: [],
|
||||
engine: null,
|
||||
continue_on_failure: false,
|
||||
output_parameter: {
|
||||
parameter_type: "output",
|
||||
key: "action_1_output",
|
||||
description: null,
|
||||
output_parameter_id: "mock-output-1",
|
||||
workflow_id: browserSessionId || "mock-workflow-id",
|
||||
created_at: new Date().toISOString(),
|
||||
modified_at: new Date().toISOString(),
|
||||
deleted_at: null,
|
||||
},
|
||||
model: null,
|
||||
} satisfies ActionBlock,
|
||||
{
|
||||
block_type: "action",
|
||||
label: "action_2",
|
||||
title: "Click search",
|
||||
navigation_goal: "Click the search button",
|
||||
url: null,
|
||||
error_code_mapping: null,
|
||||
parameters: [],
|
||||
engine: null,
|
||||
continue_on_failure: false,
|
||||
output_parameter: {
|
||||
parameter_type: "output",
|
||||
key: "action_2_output",
|
||||
description: null,
|
||||
output_parameter_id: "mock-output-2",
|
||||
workflow_id: browserSessionId || "mock-workflow-id",
|
||||
created_at: new Date().toISOString(),
|
||||
modified_at: new Date().toISOString(),
|
||||
deleted_at: null,
|
||||
},
|
||||
model: null,
|
||||
} satisfies ActionBlock,
|
||||
];
|
||||
return mockWorkflowBlocks;
|
||||
},
|
||||
onSuccess: (workflowBlocks) => {
|
||||
// Clear events after successful flush
|
||||
recordingStore.clear();
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Recording Processed",
|
||||
description: "The recording has been successfully processed.",
|
||||
});
|
||||
|
||||
if (workflowBlocks) {
|
||||
onSuccess?.(workflowBlocks);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error && error.message === FAIL_QUITE_NO_EVENTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error Processing Recording",
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return processRecordingMutation;
|
||||
};
|
||||
|
||||
export { useProcessRecordingMutation };
|
||||
@@ -11,6 +11,7 @@ import { useOnChange } from "@/hooks/useOnChange";
|
||||
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
|
||||
import { BlockActionContext } from "@/store/BlockActionContext";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
||||
import {
|
||||
useWorkflowHasChangesStore,
|
||||
useWorkflowSave,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
EdgeChange,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBlocker } from "react-router-dom";
|
||||
import {
|
||||
@@ -76,8 +78,10 @@ import {
|
||||
import "./reactFlowOverrideStyles.css";
|
||||
import {
|
||||
convertEchoParameters,
|
||||
convertToNode,
|
||||
createNode,
|
||||
descendants,
|
||||
generateNodeLabel,
|
||||
getAdditionalParametersForEmailBlock,
|
||||
getOrderedChildrenBlocks,
|
||||
getOutputParameterKey,
|
||||
@@ -319,6 +323,13 @@ function FlowRenderer({
|
||||
const setGetSaveDataRef = useRef(workflowChangesStore.setGetSaveData);
|
||||
setGetSaveDataRef.current = workflowChangesStore.setGetSaveData;
|
||||
const saveWorkflow = useWorkflowSave({ status: "published" });
|
||||
const recordedBlocks = useRecordedBlocksStore((state) => state.blocks);
|
||||
const recordedInsertionPoint = useRecordedBlocksStore(
|
||||
(state) => state.insertionPoint,
|
||||
);
|
||||
const clearRecordedBlocks = useRecordedBlocksStore(
|
||||
(state) => state.clearRecordedBlocks,
|
||||
);
|
||||
useShouldNotifyWhenClosingTab(workflowChangesStore.hasChanges);
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
|
||||
return (
|
||||
@@ -572,6 +583,85 @@ function FlowRenderer({
|
||||
doLayout(nodes, edges);
|
||||
}
|
||||
|
||||
// effect to add new blocks that were generated from a browser recording
|
||||
useEffect(() => {
|
||||
if (!recordedBlocks || !recordedInsertionPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { previous, next, parent, connectingEdgeType } =
|
||||
recordedInsertionPoint;
|
||||
|
||||
const newNodes: Array<AppNode> = [];
|
||||
const newEdges: Array<Edge> = [];
|
||||
|
||||
let existingLabels = nodes
|
||||
.filter(isWorkflowBlockNode)
|
||||
.map((node) => node.data.label);
|
||||
|
||||
let prevNodeId = previous;
|
||||
|
||||
// convert each WorkflowBlock to an AppNode
|
||||
recordedBlocks.forEach((block, index) => {
|
||||
const id = nanoid();
|
||||
const label = generateNodeLabel(existingLabels);
|
||||
existingLabels = [...existingLabels, label];
|
||||
const blockWithLabel = { ...block, label: block.label || label };
|
||||
|
||||
const node = convertToNode(
|
||||
{ id, parentId: parent },
|
||||
blockWithLabel,
|
||||
true,
|
||||
);
|
||||
newNodes.push(node);
|
||||
|
||||
// create edge from previous node to this one
|
||||
if (prevNodeId) {
|
||||
newEdges.push({
|
||||
id: nanoid(),
|
||||
type: "edgeWithAddButton",
|
||||
source: prevNodeId,
|
||||
target: id,
|
||||
style: { strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
// if this is the last block, connect to next
|
||||
if (index === recordedBlocks.length - 1 && next) {
|
||||
newEdges.push({
|
||||
id: nanoid(),
|
||||
type: connectingEdgeType,
|
||||
source: id,
|
||||
target: next,
|
||||
style: { strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
prevNodeId = id;
|
||||
});
|
||||
|
||||
const editedEdges = previous
|
||||
? edges.filter((edge) => edge.source !== previous)
|
||||
: edges;
|
||||
|
||||
const previousNode = nodes.find((node) => node.id === previous);
|
||||
const previousNodeIndex = previousNode
|
||||
? nodes.indexOf(previousNode)
|
||||
: nodes.length - 1;
|
||||
|
||||
const newNodesAfter = [
|
||||
...nodes.slice(0, previousNodeIndex + 1),
|
||||
...newNodes,
|
||||
...nodes.slice(previousNodeIndex + 1),
|
||||
];
|
||||
|
||||
workflowChangesStore.setHasChanges(true);
|
||||
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
|
||||
|
||||
clearRecordedBlocks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recordedBlocks, recordedInsertionPoint]);
|
||||
|
||||
const editorElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useAutoPan(editorElementRef, nodes);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { SquareIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { RadialMenu } from "@/components/RadialMenu";
|
||||
import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useSettingsStore } from "@/store/SettingsStore";
|
||||
|
||||
type WorkflowAddMenuProps = {
|
||||
buttonSize?: string;
|
||||
children: ReactNode;
|
||||
gap?: number;
|
||||
radius?: string;
|
||||
rotateText?: boolean;
|
||||
startAt?: number;
|
||||
// --
|
||||
onAdd: () => void;
|
||||
onRecord: () => void;
|
||||
};
|
||||
|
||||
function WorkflowAddMenu({
|
||||
buttonSize,
|
||||
children,
|
||||
gap,
|
||||
radius = "80px",
|
||||
rotateText = true,
|
||||
startAt = 90,
|
||||
// --
|
||||
onAdd,
|
||||
onRecord,
|
||||
}: WorkflowAddMenuProps) {
|
||||
const debugStore = useDebugStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const isSkyvernUser = useIsSkyvernUser();
|
||||
|
||||
if (
|
||||
!isSkyvernUser ||
|
||||
!debugStore.isDebugMode ||
|
||||
!settingsStore.isUsingABrowser
|
||||
) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RadialMenu
|
||||
items={[
|
||||
{
|
||||
id: "1",
|
||||
icon: <PlusIcon className={buttonSize ? "h-3 w-3" : undefined} />,
|
||||
text: "Add Block",
|
||||
onClick: () => {
|
||||
onAdd();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
icon: <SquareIcon className={buttonSize ? "h-3 w-3" : undefined} />,
|
||||
enabled: !recordingStore.isRecording && settingsStore.isUsingABrowser,
|
||||
text: "Record Browser",
|
||||
onClick: () => {
|
||||
if (!settingsStore.isUsingABrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRecord();
|
||||
},
|
||||
},
|
||||
]}
|
||||
buttonSize={buttonSize}
|
||||
radius={radius}
|
||||
startAt={startAt}
|
||||
gap={gap}
|
||||
rotateText={rotateText}
|
||||
>
|
||||
{children}
|
||||
</RadialMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowAddMenu };
|
||||
@@ -0,0 +1,29 @@
|
||||
@keyframes pulse-dash {
|
||||
0% {
|
||||
stroke-dasharray: 141.4 141.4;
|
||||
stroke-width: 6;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 10 11.4;
|
||||
stroke-width: 8;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 141.4 141.4;
|
||||
stroke-width: 6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dash-small {
|
||||
0% {
|
||||
stroke-dasharray: 100.4 100.4;
|
||||
stroke-width: 3;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 10 11.4;
|
||||
stroke-width: 5;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 100.4 100.4;
|
||||
stroke-width: 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useRecordingStore, CHUNK_SIZE } from "@/store/useRecordingStore";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import "./WorkflowAdderBusy.css";
|
||||
|
||||
type Operation = "recording" | "processing";
|
||||
|
||||
type Size = "small" | "large";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* The operation being performed (e.g., recording or processing).
|
||||
*/
|
||||
operation: Operation;
|
||||
/**
|
||||
* An explicit sizing; otherwise the size will be determined by the child content.
|
||||
*/
|
||||
size?: Size;
|
||||
/**
|
||||
* Color for the cover and ellipses. Defaults to "red".
|
||||
*/
|
||||
color?: string;
|
||||
// --
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
function WorkflowAdderBusy({
|
||||
children,
|
||||
operation,
|
||||
size,
|
||||
color = "red",
|
||||
onComplete,
|
||||
}: Props) {
|
||||
const recordingStore = useRecordingStore();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [shouldBump, setShouldBump] = useState(false);
|
||||
const bumpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const prevCountRef = useRef(0);
|
||||
|
||||
const eventCount =
|
||||
recordingStore.pendingEvents.length +
|
||||
recordingStore.compressedChunks.length * CHUNK_SIZE;
|
||||
|
||||
// effect for bump animation when count changes
|
||||
useEffect(() => {
|
||||
if (eventCount > prevCountRef.current && prevCountRef.current > 0) {
|
||||
if (bumpTimeoutRef.current) {
|
||||
clearTimeout(bumpTimeoutRef.current);
|
||||
}
|
||||
|
||||
setShouldBump(true);
|
||||
|
||||
bumpTimeoutRef.current = setTimeout(() => {
|
||||
setShouldBump(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
prevCountRef.current = eventCount;
|
||||
|
||||
return () => {
|
||||
if (bumpTimeoutRef.current) {
|
||||
clearTimeout(bumpTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [eventCount]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onComplete();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="relative inline-block">
|
||||
<Tooltip open={isHovered}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn("relative inline-block", {
|
||||
"flex items-center justify-center": size !== undefined,
|
||||
"min-h-[40px] min-w-[40px]": size === "small",
|
||||
"min-h-[80px] min-w-[80px]": size === "large",
|
||||
})}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* cover */}
|
||||
<div
|
||||
className={cn("absolute inset-0 rounded-full opacity-40", {
|
||||
"opacity-30": isHovered,
|
||||
})}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<div className="pointer-events-none flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg
|
||||
className="h-full w-full animate-spin"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
style={{ transformOrigin: "center" }}
|
||||
>
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="50"
|
||||
rx="45"
|
||||
ry="45"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={size === "small" ? "3" : "6"}
|
||||
strokeDasharray="141.4 141.4"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{
|
||||
animation: `${size === "small" ? "pulse-dash-small" : "pulse-dash"} 10s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{isHovered && (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg
|
||||
className="h-full w-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<rect
|
||||
x="30"
|
||||
y="30"
|
||||
width="40"
|
||||
height="40"
|
||||
fill={color}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="animate-in zoom-in-0"
|
||||
style={{
|
||||
transformOrigin: "center",
|
||||
transformBox: "fill-box",
|
||||
animationDuration: "200ms",
|
||||
animationTimingFunction:
|
||||
"cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{operation === "recording" ? "Finish Recording" : "Processing..."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{recordingStore.isRecording && eventCount > 0 && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -right-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white shadow-lg transition-transform",
|
||||
{
|
||||
"scale-125": shouldBump,
|
||||
"scale-100": !shouldBump,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
transition: "transform 0.6s",
|
||||
}}
|
||||
>
|
||||
{eventCount}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Event Count</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowAdderBusy };
|
||||
@@ -29,6 +29,7 @@ import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQu
|
||||
import { WorkflowRunStream } from "@/routes/workflows/workflowRun/WorkflowRunStream";
|
||||
import { useCacheKeyValuesQuery } from "../hooks/useCacheKeyValuesQuery";
|
||||
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useSidebarStore } from "@/store/SidebarStore";
|
||||
|
||||
import { AnimatedWave } from "@/components/AnimatedWave";
|
||||
@@ -238,6 +239,7 @@ function Workspace({
|
||||
const queryClient = useQueryClient();
|
||||
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
|
||||
const blockScriptStore = useBlockScriptStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
const cacheKey = workflow?.cache_key ?? "";
|
||||
|
||||
const [cacheKeyValue, setCacheKeyValue] = useState(
|
||||
@@ -1339,6 +1341,7 @@ function Workspace({
|
||||
<div className="skyvern-vnc-browser flex h-full w-[calc(100%_-_6rem)] flex-1 flex-col items-center justify-center">
|
||||
<div key={reloadKey} className="w-full flex-1">
|
||||
<BrowserStream
|
||||
exfiltrate={recordingStore.isRecording}
|
||||
interactive={true}
|
||||
browserSessionId={
|
||||
activeDebugSession?.browser_session_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SquareIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
} from "@xyflow/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadialMenu } from "@/components/RadialMenu";
|
||||
import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useSettingsStore } from "@/store/SettingsStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
|
||||
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
|
||||
import { WorkflowAddMenu } from "../WorkflowAddMenu";
|
||||
import { WorkflowAdderBusy } from "../WorkflowAdderBusy";
|
||||
|
||||
function EdgeWithAddButton({
|
||||
source,
|
||||
@@ -27,8 +31,6 @@ function EdgeWithAddButton({
|
||||
style = {},
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const debugStore = useDebugStore();
|
||||
const isSkyvernUser = useIsSkyvernUser();
|
||||
const nodes = useNodes();
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
@@ -38,14 +40,35 @@ function EdgeWithAddButton({
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
const debugStore = useDebugStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const workflowStatePanel = useWorkflowPanelStore();
|
||||
const setRecordedBlocks = useRecordedBlocksStore(
|
||||
(state) => state.setRecordedBlocks,
|
||||
);
|
||||
const setWorkflowPanelState = useWorkflowPanelStore(
|
||||
(state) => state.setWorkflowPanelState,
|
||||
);
|
||||
const processRecordingMutation = useProcessRecordingMutation({
|
||||
browserSessionId: settingsStore.browserSessionId,
|
||||
onSuccess: (blocks) => {
|
||||
setRecordedBlocks(blocks, {
|
||||
previous: source,
|
||||
next: target,
|
||||
parent: sourceNode?.parentId,
|
||||
connectingEdgeType: "edgeWithAddButton",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isProcessing = processRecordingMutation.isPending;
|
||||
|
||||
const sourceNode = nodes.find((node) => node.id === source);
|
||||
|
||||
const onAdd = () => {
|
||||
const updateWorkflowPanelState = (active: boolean) => {
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
active,
|
||||
content: "nodeLibrary",
|
||||
data: {
|
||||
previous: source,
|
||||
@@ -55,6 +78,25 @@ function EdgeWithAddButton({
|
||||
});
|
||||
};
|
||||
|
||||
const onAdd = () => updateWorkflowPanelState(true);
|
||||
|
||||
const onRecord = () => {
|
||||
if (recordingStore.isRecording) {
|
||||
recordingStore.setIsRecording(false);
|
||||
} else {
|
||||
recordingStore.setIsRecording(true);
|
||||
updateWorkflowPanelState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEndRecord = () => {
|
||||
if (recordingStore.isRecording) {
|
||||
recordingStore.setIsRecording(false);
|
||||
}
|
||||
|
||||
processRecordingMutation.mutate();
|
||||
};
|
||||
|
||||
const adder = (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -65,6 +107,41 @@ function EdgeWithAddButton({
|
||||
</Button>
|
||||
);
|
||||
|
||||
const menu = (
|
||||
<WorkflowAddMenu
|
||||
buttonSize="25px"
|
||||
gap={35}
|
||||
radius="50px"
|
||||
startAt={72.5}
|
||||
onAdd={onAdd}
|
||||
onRecord={onRecord}
|
||||
>
|
||||
{adder}
|
||||
</WorkflowAddMenu>
|
||||
);
|
||||
|
||||
const busy = (
|
||||
<WorkflowAdderBusy
|
||||
color={isProcessing ? "white" : "red"}
|
||||
operation={isProcessing ? "processing" : "recording"}
|
||||
size="small"
|
||||
onComplete={() => {
|
||||
onEndRecord();
|
||||
}}
|
||||
>
|
||||
{adder}
|
||||
</WorkflowAdderBusy>
|
||||
);
|
||||
|
||||
const isBusy =
|
||||
(isProcessing || recordingStore.isRecording) &&
|
||||
debugStore.isDebugMode &&
|
||||
settingsStore.isUsingABrowser &&
|
||||
workflowStatePanel.workflowPanelState.data?.previous === source &&
|
||||
workflowStatePanel.workflowPanelState.data?.next === target &&
|
||||
workflowStatePanel.workflowPanelState.data?.parent ===
|
||||
(sourceNode?.parentId || undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||
@@ -81,38 +158,7 @@ function EdgeWithAddButton({
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
>
|
||||
{isSkyvernUser && debugStore.isDebugMode ? (
|
||||
<RadialMenu
|
||||
items={[
|
||||
{
|
||||
id: "1",
|
||||
icon: <PlusIcon className="h-3 w-3" />,
|
||||
text: "Add Block",
|
||||
onClick: () => {
|
||||
onAdd();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
icon: <SquareIcon className="h-3 w-3" />,
|
||||
enabled: false,
|
||||
text: "Record Browser",
|
||||
onClick: () => {
|
||||
console.log("Record");
|
||||
},
|
||||
},
|
||||
]}
|
||||
buttonSize="25px"
|
||||
radius="50px"
|
||||
startAt={72.5}
|
||||
gap={35}
|
||||
rotateText={true}
|
||||
>
|
||||
{adder}
|
||||
</RadialMenu>
|
||||
) : (
|
||||
adder
|
||||
)}
|
||||
{isBusy ? busy : menu}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
import { SquareIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
|
||||
|
||||
import { RadialMenu } from "@/components/RadialMenu";
|
||||
import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser";
|
||||
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import { useSettingsStore } from "@/store/SettingsStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
|
||||
import type { NodeAdderNode } from "./types";
|
||||
import { WorkflowAddMenu } from "../../WorkflowAddMenu";
|
||||
import { WorkflowAdderBusy } from "../../WorkflowAdderBusy";
|
||||
|
||||
function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
const debugStore = useDebugStore();
|
||||
const isSkyvernUser = useIsSkyvernUser();
|
||||
const edges = useEdges();
|
||||
const debugStore = useDebugStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const setWorkflowPanelState = useWorkflowPanelStore(
|
||||
(state) => state.setWorkflowPanelState,
|
||||
);
|
||||
const workflowStatePanel = useWorkflowPanelStore();
|
||||
const setRecordedBlocks = useRecordedBlocksStore(
|
||||
(state) => state.setRecordedBlocks,
|
||||
);
|
||||
|
||||
const onAdd = () => {
|
||||
const previous = edges.find((edge) => edge.target === id)?.source ?? null;
|
||||
|
||||
const processRecordingMutation = useProcessRecordingMutation({
|
||||
browserSessionId: settingsStore.browserSessionId,
|
||||
onSuccess: (blocks) => {
|
||||
setRecordedBlocks(blocks, {
|
||||
previous,
|
||||
next: id,
|
||||
parent: parentId,
|
||||
connectingEdgeType: "default",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isProcessing = processRecordingMutation.isPending;
|
||||
|
||||
const updateWorkflowPanelState = (active: boolean) => {
|
||||
const previous = edges.find((edge) => edge.target === id)?.source;
|
||||
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
active,
|
||||
content: "nodeLibrary",
|
||||
data: {
|
||||
previous: previous ?? null,
|
||||
@@ -30,9 +56,30 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
});
|
||||
};
|
||||
|
||||
const onAdd = () => {
|
||||
updateWorkflowPanelState(true);
|
||||
};
|
||||
|
||||
const onRecord = () => {
|
||||
if (recordingStore.isRecording) {
|
||||
recordingStore.setIsRecording(false);
|
||||
} else {
|
||||
recordingStore.setIsRecording(true);
|
||||
updateWorkflowPanelState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEndRecord = () => {
|
||||
if (recordingStore.isRecording) {
|
||||
recordingStore.setIsRecording(false);
|
||||
}
|
||||
|
||||
processRecordingMutation.mutate();
|
||||
};
|
||||
|
||||
const adder = (
|
||||
<div
|
||||
className="rounded-full bg-slate-50 p-2"
|
||||
className={"rounded-full bg-slate-50 p-2"}
|
||||
onClick={() => {
|
||||
onAdd();
|
||||
}}
|
||||
@@ -41,6 +88,33 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const busy = (
|
||||
<WorkflowAdderBusy
|
||||
color={isProcessing ? "gray" : "red"}
|
||||
operation={isProcessing ? "processing" : "recording"}
|
||||
onComplete={() => {
|
||||
onEndRecord();
|
||||
}}
|
||||
>
|
||||
{adder}
|
||||
</WorkflowAdderBusy>
|
||||
);
|
||||
|
||||
const menu = (
|
||||
<WorkflowAddMenu onAdd={onAdd} onRecord={onRecord}>
|
||||
{adder}
|
||||
</WorkflowAddMenu>
|
||||
);
|
||||
|
||||
const isBusy =
|
||||
(isProcessing || recordingStore.isRecording) &&
|
||||
debugStore.isDebugMode &&
|
||||
settingsStore.isUsingABrowser &&
|
||||
workflowStatePanel.workflowPanelState.data?.previous === previous &&
|
||||
workflowStatePanel.workflowPanelState.data?.next === id &&
|
||||
workflowStatePanel.workflowPanelState.data?.parent ===
|
||||
(parentId || undefined);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -55,36 +129,7 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
{isSkyvernUser && debugStore.isDebugMode ? (
|
||||
<RadialMenu
|
||||
items={[
|
||||
{
|
||||
id: "1",
|
||||
icon: <PlusIcon />,
|
||||
text: "Add Block",
|
||||
onClick: () => {
|
||||
onAdd();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
icon: <SquareIcon />,
|
||||
enabled: false,
|
||||
text: "Record Browser",
|
||||
onClick: () => {
|
||||
console.log("Record");
|
||||
},
|
||||
},
|
||||
]}
|
||||
radius="80px"
|
||||
startAt={90}
|
||||
rotateText={true}
|
||||
>
|
||||
{adder}
|
||||
</RadialMenu>
|
||||
) : (
|
||||
adder
|
||||
)}
|
||||
{isBusy ? busy : menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2458,6 +2458,7 @@ function getLabelForWorkflowParameterType(type: WorkflowParameterValueType) {
|
||||
export {
|
||||
convert,
|
||||
convertEchoParameters,
|
||||
convertToNode,
|
||||
createNode,
|
||||
generateNodeData,
|
||||
generateNodeLabel,
|
||||
|
||||
Reference in New Issue
Block a user