diff --git a/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx b/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx new file mode 100644 index 00000000..ef3034f4 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx @@ -0,0 +1,100 @@ +import { ExitIcon } from "@radix-ui/react-icons"; +import { Handle } from "@xyflow/react"; +import { Position } from "@xyflow/react"; + +import { WorkflowBlockType } from "@/routes/workflows/types/workflowTypes"; +import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback"; +import { cn } from "@/util/utils"; + +import { CodeEditor } from "./CodeEditor"; +import { workflowBlockTitle } from "../editor/nodes/types"; +import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; + +function BlockCodeEditor({ + blockLabel, + blockType, + script, + onClick, +}: { + blockLabel: string; + blockType: WorkflowBlockType; + script: string | undefined; + onClick?: (e: React.MouseEvent) => void; +}) { + const blockTitle = workflowBlockTitle[blockType]; + const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); + + return ( +
+ + + +
{ + onClick?.(e); + }} + > +
+
+
+ +
+ code +
+
+
+ {blockLabel} + {blockTitle} +
+
+ { + toggleScriptForNodeCallback({ + label: blockLabel, + show: false, + }); + }} + className="size-5 cursor-pointer" + /> +
+
+
+ {script ? ( +
+ +
+ ) : ( +
+ No script defined +
+ )} +
+
+ ); +} + +export { BlockCodeEditor }; diff --git a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx index 050156cb..d56c2b47 100644 --- a/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/components/CodeEditor.tsx @@ -20,6 +20,7 @@ type Props = { value: string; onChange?: (value: string) => void; language?: "python" | "json" | "html"; + lineWrap?: boolean; readOnly?: boolean; minHeight?: string; maxHeight?: string; @@ -33,13 +34,14 @@ function CodeEditor({ minHeight, maxHeight, language, + lineWrap = true, className, readOnly = false, fontSize = 12, }: Props) { const extensions = language - ? [getLanguageExtension(language), EditorView.lineWrapping] - : [EditorView.lineWrapping]; + ? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []] + : [lineWrap ? EditorView.lineWrapping : []]; return ( node.id === id); + if (!node || !isWorkflowBlockNode(node)) { + return; + } + + node.data.showCode = show; + } else if (label) { + const node = nodes.find( + (node) => "label" in node.data && node.data.label === label, + ); + + if (!node || !isWorkflowBlockNode(node)) { + return; + } + + node.data.showCode = show; + } + + doLayout(nodes, edges); + } + const editorElementRef = useRef(null); useAutoPan(editorElementRef, nodes); @@ -642,7 +673,12 @@ function FlowRenderer({ - + )} - + ); diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowDebugger.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowDebugger.tsx index 45d13ec4..f53c0762 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowDebugger.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowDebugger.tsx @@ -24,6 +24,8 @@ import { toast } from "@/components/ui/use-toast"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useMountEffect } from "@/hooks/useMountEffect"; import { statusIsFinalized } from "@/routes/tasks/types.ts"; +import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery"; +import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useSidebarStore } from "@/store/SidebarStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; @@ -47,12 +49,18 @@ function WorkflowDebugger() { const credentialGetter = useCredentialGetter(); const queryClient = useQueryClient(); const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false); + const blockScriptStore = useBlockScriptStore(); const { data: workflowRun } = useWorkflowRunQuery(); + const { data: workflow } = useWorkflowQuery({ workflowPermanentId, }); + const { data: blockScripts } = useBlockScriptsQuery({ + workflowPermanentId, + }); + const { data: debugSession } = useDebugSessionQuery({ workflowPermanentId, enabled: shouldFetchDebugSession && !!workflowPermanentId, @@ -80,6 +88,11 @@ function WorkflowDebugger() { } }); + useEffect(() => { + blockScriptStore.setScripts(blockScripts ?? {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blockScripts]); + const afterCycleBrowser = () => { setOpenDialogue(false); setShowPowerButton(false); diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index 0237f1fb..c1425b4d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -1,4 +1,7 @@ +import { useEffect } from "react"; import { useMountEffect } from "@/hooks/useMountEffect"; +import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery"; +import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useSidebarStore } from "@/store/SidebarStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { ReactFlowProvider } from "@xyflow/react"; @@ -17,6 +20,7 @@ function WorkflowEditor() { return state.setCollapsed; }); const workflowChangesStore = useWorkflowHasChangesStore(); + const blockScriptStore = useBlockScriptStore(); const { data: workflow, isLoading } = useWorkflowQuery({ workflowPermanentId, @@ -25,11 +29,20 @@ function WorkflowEditor() { const { data: globalWorkflows, isLoading: isGlobalWorkflowsLoading } = useGlobalWorkflowsQuery(); + const { data: blockScripts } = useBlockScriptsQuery({ + workflowPermanentId, + }); + useMountEffect(() => { setCollapsed(true); workflowChangesStore.setHasChanges(false); }); + useEffect(() => { + blockScriptStore.setScripts(blockScripts ?? {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blockScripts]); + if (isLoading || isGlobalWorkflowsLoading) { return (
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx index 82649378..719b26a4 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx @@ -1,3 +1,5 @@ +import { useEffect } from "react"; +import { Flippable } from "@/components/Flippable"; import { HelpTooltip } from "@/components/HelpTooltip"; import { Accordion, @@ -12,7 +14,9 @@ import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; +import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { Handle, NodeProps, @@ -40,7 +44,10 @@ function NavigationNode({ id, data, type }: NodeProps) { const { blockLabel: urlBlockLabel } = useParams(); const debugStore = useDebugStore(); const { updateNodeData } = useReactFlow(); + const [facing, setFacing] = useState<"front" | "back">("front"); + const blockScriptStore = useBlockScriptStore(); const { editable, debuggable, label } = data; + const script = blockScriptStore.scripts[label]; const thisBlockIsPlaying = urlBlockLabel !== undefined && urlBlockLabel === label; const elideFromDebugging = debugStore.isDebugMode && !debuggable; @@ -76,359 +83,382 @@ function NavigationNode({ id, data, type }: NodeProps) { updateNodeData(id, { [key]: value }); } - return ( -
- - -
- -
-
-
-
- - -
- {isFirstWorkflowBlock ? ( -
- Tip: Use the {"+"} button to add parameters! -
- ) : null} -
+ useEffect(() => { + setFacing(data.showCode ? "back" : "front"); + }, [data.showCode]); - { - handleChange("url", value); - }} - value={inputs.url} - placeholder={placeholders["navigation"]["url"]} - className="nopan text-xs" - /> -
-
-
- - +
+ + + +
+ +
+
+
+
+ + +
+ {isFirstWorkflowBlock ? ( +
+ Tip: Use the {"+"} button to add parameters! +
+ ) : null} +
+ + { + handleChange("url", value); + }} + value={inputs.url} + placeholder={placeholders["navigation"]["url"]} + className="nopan text-xs" />
- { - handleChange("navigationGoal", value); - }} - value={inputs.navigationGoal} - placeholder={placeholders["navigation"]["navigationGoal"]} - className="nopan text-xs" - /> -
-
-
- Tip: Try to phrase your prompt as a goal with an explicit - completion criteria. While executing, Skyvern will take as many - actions as necessary to accomplish the goal. Use words like - "Complete" or "Terminate" to help Skyvern identify when it's - finished or when it should give up. +
+
+ + +
+ { + handleChange("navigationGoal", value); + }} + value={inputs.navigationGoal} + placeholder={placeholders["navigation"]["navigationGoal"]} + className="nopan text-xs" + /> +
+
+
+ Tip: Try to phrase your prompt as a goal with an explicit + completion criteria. While executing, Skyvern will take as many + actions as necessary to accomplish the goal. Use words like + "Complete" or "Terminate" to help Skyvern identify when it's + finished or when it should give up. +
-
- - - - - Advanced Settings - - -
-
- { - updateNodeData(id, { parameterKeys }); - }} - /> -
-
- - { - handleChange("completeCriterion", value); - }} - value={inputs.completeCriterion} - className="nopan text-xs" - /> -
- - { - handleChange("model", value); - }} - /> -
-
- -
- { - handleChange("engine", value); - }} - className="nopan w-52 text-xs" - /> -
-
-
- - + + + + Advanced Settings + + +
+
+ { + updateNodeData(id, { parameterKeys }); + }} />
- + + { + handleChange("completeCriterion", value); + }} + value={inputs.completeCriterion} + className="nopan text-xs" + /> +
+ + { - const value = - event.target.value === "" - ? null - : Number(event.target.value); - handleChange("maxStepsOverride", value); + value={inputs.model} + onChange={(value) => { + handleChange("model", value); }} /> -
-
-
+
+
+ { + handleChange("engine", value); + }} + className="nopan w-52 text-xs" + /> +
+
+
+
- { - handleChange( - "errorCodeMapping", - checked - ? JSON.stringify(errorMappingExampleValue, null, 2) - : "null", - ); - }} - /> -
- {inputs.errorCodeMapping !== "null" && ( -
- { - handleChange("errorCodeMapping", value); - }} - className="nowheel nopan" - fontSize={8} - /> -
- )} -
- -
-
- - -
-
- { - handleChange( - "includeActionHistoryInVerification", - checked, - ); + className="nopan w-52 text-xs" + min="0" + value={inputs.maxStepsOverride ?? ""} + onChange={(event) => { + const value = + event.target.value === "" + ? null + : Number(event.target.value); + handleChange("maxStepsOverride", value); }} />
-
-
-
- - +
+
+
+ + +
+ { + handleChange( + "errorCodeMapping", + checked + ? JSON.stringify( + errorMappingExampleValue, + null, + 2, + ) + : "null", + ); + }} + /> +
+ {inputs.errorCodeMapping !== "null" && ( +
+ { + handleChange("errorCodeMapping", value); + }} + className="nowheel nopan" + fontSize={8} + /> +
+ )}
-
- { - handleChange("continueOnFailure", checked); + +
+
+ + +
+
+ { + handleChange( + "includeActionHistoryInVerification", + checked, + ); + }} + /> +
+
+
+
+ + +
+
+ { + handleChange("continueOnFailure", checked); + }} + /> +
+
+
+
+ + +
+
+ { + handleChange("cacheActions", checked); + }} + /> +
+
+ +
+
+ + +
+
+ { + handleChange("allowDownloads", checked); + }} + /> +
+
+
+
+ + +
+ { + handleChange("downloadSuffix", value); }} />
-
-
-
- - -
-
- { - handleChange("cacheActions", checked); + +
+
+ + +
+ { + handleChange("totpIdentifier", value); }} + value={inputs.totpIdentifier ?? ""} + placeholder={placeholders["navigation"]["totpIdentifier"]} + className="nopan text-xs" />
-
- -
-
- - -
-
- { - handleChange("allowDownloads", checked); +
+
+ + +
+ { + handleChange("totpVerificationUrl", value); }} + value={inputs.totpVerificationUrl ?? ""} + placeholder={placeholders["task"]["totpVerificationUrl"]} + className="nopan text-xs" />
-
-
- - -
- { - handleChange("downloadSuffix", value); - }} - /> -
- -
-
- - -
- { - handleChange("totpIdentifier", value); - }} - value={inputs.totpIdentifier ?? ""} - placeholder={placeholders["navigation"]["totpIdentifier"]} - className="nopan text-xs" - /> -
-
-
- - -
- { - handleChange("totpVerificationUrl", value); - }} - value={inputs.totpVerificationUrl ?? ""} - placeholder={placeholders["task"]["totpVerificationUrl"]} - className="nopan text-xs" - /> -
-
- - - + + + +
-
+ + + ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx index 34d149c7..b2df436d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx @@ -7,12 +7,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { OrgWalled } from "@/components/Orgwalled"; type Props = { onDelete: () => void; + onShowScript?: () => void; }; -function NodeActionMenu({ onDelete }: Props) { +function NodeActionMenu({ onDelete, onShowScript }: Props) { return ( @@ -28,6 +30,17 @@ function NodeActionMenu({ onDelete }: Props) { > Delete Block + + {onShowScript && ( + { + onShowScript(); + }} + > + Show Script + + )} + ); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx index fc94cb5e..3a6e5e97 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -12,6 +12,7 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback"; import { useDebugSessionQuery } from "@/routes/workflows/hooks/useDebugSessionQuery"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; import { @@ -145,6 +146,7 @@ function NodeHeader({ }); const blockTitle = workflowBlockTitle[type]; const deleteNodeCallback = useDeleteNodeCallback(); + const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); const credentialGetter = useCredentialGetter(); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -411,6 +413,9 @@ function NodeHeader({ onDelete={() => { deleteNodeCallback(nodeId); }} + onShowScript={() => + toggleScriptForNodeCallback({ id: nodeId, show: true }) + } />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index e5a53d06..a7f4d89d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -7,6 +7,7 @@ export type NodeBaseData = { continueOnFailure: boolean; editable: boolean; model: WorkflowModel | null; + showCode?: boolean; }; export const errorMappingExampleValue = { diff --git a/skyvern-frontend/src/routes/workflows/hooks/useBlockScriptsQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useBlockScriptsQuery.ts new file mode 100644 index 00000000..f93e88c1 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useBlockScriptsQuery.ts @@ -0,0 +1,37 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; +import { ScriptBlocksResponse } from "../types/scriptTypes"; + +type Props = { + cacheKey?: string; + cacheKeyValue?: string; + workflowPermanentId?: string; +}; + +function useBlockScriptsQuery({ + cacheKey, + cacheKeyValue, + workflowPermanentId, +}: Props) { + const credentialGetter = useCredentialGetter(); + + return useQuery<{ [blockName: string]: string }>({ + queryKey: ["block-scripts", workflowPermanentId, cacheKey, cacheKeyValue], + queryFn: async () => { + const client = await getClient(credentialGetter, "sans-api-v1"); + + const result = await client + .post(`/scripts/${workflowPermanentId}/blocks`, { + cache_key: cacheKey ?? "", + cache_key_value: cacheKeyValue ?? "", + }) + .then((response) => response.data); + + return result.blocks; + }, + enabled: !!workflowPermanentId, + }); +} + +export { useBlockScriptsQuery }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts index 915eba43..6351aa48 100644 --- a/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts +++ b/skyvern-frontend/src/routes/workflows/hooks/useDeleteNodeCallback.ts @@ -1,12 +1,12 @@ -import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext"; +import { BlockActionContext } from "@/store/BlockActionContext"; import { useContext } from "react"; function useDeleteNodeCallback() { - const deleteNodeCallback = useContext(DeleteNodeCallbackContext); + const deleteNodeCallback = useContext(BlockActionContext)?.deleteNodeCallback; if (!deleteNodeCallback) { throw new Error( - "useDeleteNodeCallback must be used within a DeleteNodeCallbackProvider", + "useDeleteNodeCallback must be used within a BlockActionContextProvider", ); } diff --git a/skyvern-frontend/src/routes/workflows/hooks/useToggleScriptForNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useToggleScriptForNodeCallback.ts new file mode 100644 index 00000000..26af5954 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useToggleScriptForNodeCallback.ts @@ -0,0 +1,17 @@ +import { BlockActionContext } from "@/store/BlockActionContext"; +import { useContext } from "react"; + +function useToggleScriptForNodeCallback() { + const toggleScriptForNodeCallback = + useContext(BlockActionContext)?.toggleScriptForNodeCallback; + + if (!toggleScriptForNodeCallback) { + throw new Error( + "useToggleScriptForNodeCallback must be used within a BlockActionContextProvider", + ); + } + + return toggleScriptForNodeCallback; +} + +export { useToggleScriptForNodeCallback }; diff --git a/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts b/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts new file mode 100644 index 00000000..11e145e0 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts @@ -0,0 +1,3 @@ +export type ScriptBlocksResponse = { + blocks: { [blockName: string]: string }; +}; diff --git a/skyvern-frontend/src/store/BlockActionContext.ts b/skyvern-frontend/src/store/BlockActionContext.ts new file mode 100644 index 00000000..3a699192 --- /dev/null +++ b/skyvern-frontend/src/store/BlockActionContext.ts @@ -0,0 +1,18 @@ +import { createContext } from "react"; + +type DeleteNodeCallback = (id: string) => void; +type ToggleScriptForNodeCallback = (opts: { + id?: string; + label?: string; + show: boolean; +}) => void; + +const BlockActionContext = createContext< + | { + deleteNodeCallback: DeleteNodeCallback; + toggleScriptForNodeCallback?: ToggleScriptForNodeCallback; + } + | undefined +>(undefined); + +export { BlockActionContext }; diff --git a/skyvern-frontend/src/store/BlockScriptStore.ts b/skyvern-frontend/src/store/BlockScriptStore.ts new file mode 100644 index 00000000..81b8098d --- /dev/null +++ b/skyvern-frontend/src/store/BlockScriptStore.ts @@ -0,0 +1,45 @@ +/** + * A store to hold the scripts for individual blocks in a workflow. As each + * workflow has uniquely (and differently) labelled blocks, and those labels + * are block identity, we'll eschew strong typing for this, and use a loose + * object literal instead. + */ + +import { create } from "zustand"; + +interface BlockScriptStore { + scriptId?: string; + scripts: { [k: string]: string }; + // -- + setScript: (blockId: string, script: string) => void; + setScripts: (scripts: { [k: string]: string }) => void; + reset: () => void; +} + +const useBlockScriptStore = create((set) => { + return { + scriptId: undefined, + scripts: {}, + // -- + setScript: (blockId: string, script: string) => { + set((state) => ({ + scripts: { + ...state.scripts, + [blockId]: script, + }, + })); + }, + setScripts: (scripts: { [k: string]: string }) => { + set(() => ({ + scripts, + })); + }, + reset: () => { + set({ + scripts: {}, + }); + }, + }; +}); + +export { useBlockScriptStore }; diff --git a/skyvern-frontend/src/store/DeleteNodeCallbackContext.ts b/skyvern-frontend/src/store/DeleteNodeCallbackContext.ts deleted file mode 100644 index eb762276..00000000 --- a/skyvern-frontend/src/store/DeleteNodeCallbackContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext } from "react"; - -type DeleteNodeCallback = (id: string) => void; - -const DeleteNodeCallbackContext = createContext( - undefined, -); - -export { DeleteNodeCallbackContext };