diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 81e1ba80..74e7d745 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -223,6 +223,7 @@ export type TaskGenerationApiResponse = { }; export type ActionsApiResponse = { + action_id: string; action_type: ActionType; status: Status; task_id: string | null; diff --git a/skyvern-frontend/src/components/SwitchBarNavigation.tsx b/skyvern-frontend/src/components/SwitchBarNavigation.tsx new file mode 100644 index 00000000..c955b17d --- /dev/null +++ b/skyvern-frontend/src/components/SwitchBarNavigation.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/util/utils"; +import { NavLink } from "react-router-dom"; + +type Option = { + label: string; + to: string; +}; + +type Props = { + options: Option[]; +}; + +function SwitchBarNavigation({ options }: Props) { + return ( +
+ {options.map((option) => { + return ( + { + return cn( + "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", + { + "bg-slate-700": isActive, + }, + ); + }} + > + {option.label} + + ); + })} +
+ ); +} + +export { SwitchBarNavigation }; diff --git a/skyvern-frontend/src/router.tsx b/skyvern-frontend/src/router.tsx index 03076618..aad16219 100644 --- a/skyvern-frontend/src/router.tsx +++ b/skyvern-frontend/src/router.tsx @@ -17,10 +17,10 @@ import { WorkflowRunParameters } from "./routes/workflows/WorkflowRunParameters" import { Workflows } from "./routes/workflows/Workflows"; import { WorkflowsPageLayout } from "./routes/workflows/WorkflowsPageLayout"; import { WorkflowEditor } from "./routes/workflows/editor/WorkflowEditor"; -import { WorkflowRunBlocks } from "./routes/workflows/workflowRun/WorkflowRunBlocks"; import { WorkflowRunOutput } from "./routes/workflows/workflowRun/WorkflowRunOutput"; import { WorkflowPostRunParameters } from "./routes/workflows/workflowRun/WorkflowPostRunParameters"; import { WorkflowRunRecording } from "./routes/workflows/workflowRun/WorkflowRunRecording"; +import { WorkflowRunOverview } from "./routes/workflows/workflowRun/WorkflowRunOverview"; const router = createBrowserRouter([ { @@ -115,11 +115,11 @@ const router = createBrowserRouter([ children: [ { index: true, - element: , + element: , }, { - path: "blocks", - element: , + path: "overview", + element: , }, { path: "output", diff --git a/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx b/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx index bbb7c15b..638354f7 100644 --- a/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx +++ b/skyvern-frontend/src/routes/tasks/create/PromptBox.tsx @@ -19,7 +19,6 @@ import { import { Textarea } from "@/components/ui/textarea"; import { toast } from "@/components/ui/use-toast"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { observerFeatureEnabled } from "@/util/env"; import { FileTextIcon, GearIcon, @@ -233,35 +232,33 @@ function PromptBox() { placeholder="Enter your prompt..." rows={1} /> - {observerFeatureEnabled && ( - + + + + + +
+
+ Skyvern 1.0 (Tasks)
- - -
-
- Skyvern 2.0 (Observer) -
-
- best for complex tasks -
+
+ best for simple tasks
- - - - )} +
+
+ +
+
+ Skyvern 2.0 (Observer) +
+
+ best for complex tasks +
+
+
+ +
{startObserverCruiseMutation.isPending || getTaskFromPromptMutation.isPending || @@ -271,7 +268,7 @@ function PromptBox() { { - if (observerFeatureEnabled && selectValue === "v2") { + if (selectValue === "v2") { startObserverCruiseMutation.mutate(prompt); return; } diff --git a/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx b/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx index 2600f83a..dcdaa739 100644 --- a/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/ActionScreenshot.tsx @@ -3,7 +3,6 @@ import { ArtifactApiResponse, ArtifactType, Status } from "@/api/types"; import { ZoomableImage } from "@/components/ZoomableImage"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router-dom"; import { getImageURL } from "./artifactUtils"; import { ReloadIcon } from "@radix-ui/react-icons"; import { statusIsNotFinalized } from "../types"; @@ -15,15 +14,18 @@ type Props = { }; function ActionScreenshot({ stepId, index, taskStatus }: Props) { - const { taskId } = useParams(); const credentialGetter = useCredentialGetter(); - const { data: artifacts, isLoading } = useQuery>({ - queryKey: ["task", taskId, "steps", stepId, "artifacts"], + const { + data: artifacts, + isLoading, + isFetching, + } = useQuery>({ + queryKey: ["step", stepId, "artifacts"], queryFn: async () => { const client = await getClient(credentialGetter); return client - .get(`/tasks/${taskId}/steps/${stepId}/artifacts`) + .get(`/step/${stepId}/artifacts`) .then((response) => response.data); }, refetchInterval: (query) => { @@ -46,7 +48,7 @@ function ActionScreenshot({ stepId, index, taskStatus }: Props) { if (isLoading) { return ( -
+
Loading screenshot...
@@ -59,14 +61,25 @@ function ActionScreenshot({ stepId, index, taskStatus }: Props) { statusIsNotFinalized({ status: taskStatus }) ) { return
The screenshot for this action is not available yet.
; + } else if (isFetching) { + return ( +
+ +
Loading screenshot...
+
+ ); } if (!screenshot) { - return
No screenshot found for this action.
; + return ( +
+ No screenshot found for this action. +
+ ); } return ( -
+
); diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx index bba4a2e9..12955277 100644 --- a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx @@ -4,6 +4,8 @@ import { TaskApiResponse, WorkflowRunStatusApiResponse, } from "@/api/types"; +import { StatusBadge } from "@/components/StatusBadge"; +import { SwitchBarNavigation } from "@/components/SwitchBarNavigation"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -18,20 +20,18 @@ import { import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "@/components/ui/use-toast"; +import { useApiCredential } from "@/hooks/useApiCredential"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { cn } from "@/util/utils"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; +import { copyText } from "@/util/copyText"; +import { apiBaseUrl } from "@/util/env"; import { CopyIcon, PlayIcon, ReloadIcon } from "@radix-ui/react-icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link, NavLink, Outlet, useParams } from "react-router-dom"; -import { useTaskQuery } from "./hooks/useTaskQuery"; import fetchToCurl from "fetch-to-curl"; -import { apiBaseUrl } from "@/util/env"; -import { useApiCredential } from "@/hooks/useApiCredential"; -import { copyText } from "@/util/copyText"; -import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; -import { StatusBadge } from "@/components/StatusBadge"; -import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { Link, Outlet, useParams } from "react-router-dom"; import { statusIsFinalized } from "../types"; +import { useTaskQuery } from "./hooks/useTaskQuery"; function createTaskRequestObject(values: TaskApiResponse) { return { @@ -257,7 +257,7 @@ function TaskDetails() { workflow && workflowRun && ( {workflow.title} @@ -274,64 +274,26 @@ function TaskDetails() { {failureReason} )} -
- { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Actions - - { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Recording - - { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Parameters - - { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Diagnostics - -
+
); diff --git a/skyvern-frontend/src/routes/tasks/types.ts b/skyvern-frontend/src/routes/tasks/types.ts index 4b1ecf01..d8712b5b 100644 --- a/skyvern-frontend/src/routes/tasks/types.ts +++ b/skyvern-frontend/src/routes/tasks/types.ts @@ -35,6 +35,14 @@ export function statusIsFinalized({ status }: { status: Status }): boolean { ); } +export function statusIsAFailureType({ status }: { status: Status }): boolean { + return ( + status === Status.Failed || + status === Status.Terminated || + status === Status.TimedOut + ); +} + export function statusIsRunningOrQueued({ status, }: { diff --git a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx index 4bea16ad..e069990f 100644 --- a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx +++ b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx @@ -135,7 +135,7 @@ function RunWorkflowForm({
{workflowFailureReason} -
- { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Blocks - - { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Output - - { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Parameters - - { - return cn( - "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", - { - "bg-slate-700": isActive, - }, - ); - }} - > - Recording - -
+
); diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index 141423d1..fa228fc7 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -396,14 +396,14 @@ function Workflows() { if (event.ctrlKey || event.metaKey) { window.open( window.location.origin + - `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/blocks`, + `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`, "_blank", "noopener,noreferrer", ); return; } navigate( - `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/blocks`, + `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`, ); }} className="cursor-pointer" diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx index df0ac683..4f607dcf 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -19,10 +19,11 @@ import { Checkbox } from "@/components/ui/checkbox"; import { errorMappingExampleValue } from "../types"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { Switch } from "@/components/ui/switch"; -import { ClickIcon } from "@/components/icons/ClickIcon"; import { placeholders, helpTooltips } from "../../helpContent"; import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; +import { WorkflowBlockIcon } from "../WorkflowBlockIcon"; +import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes"; const urlTooltip = "The URL Skyvern is navigating to. Leave this field blank to pick up from where the last block left off."; @@ -78,7 +79,10 @@ function ActionNode({ id, data }: NodeProps) {
- +
) { @@ -38,7 +39,10 @@ function CodeBlockNode({ id, data }: NodeProps) {
- +
) { const [label, setLabel] = useNodeLabelChangeHandler({ @@ -35,7 +36,10 @@ function DownloadNode({ id, data }: NodeProps) {
- +
) { const { updateNodeData } = useReactFlow(); @@ -40,7 +41,10 @@ function FileParserNode({ id, data }: NodeProps) {
- +
) {
- +
) { const { updateNodeData } = useReactFlow(); @@ -88,7 +89,10 @@ function LoopNode({ id, data }: NodeProps) {
- +
) { const { updateNodeData } = useReactFlow(); @@ -74,7 +75,10 @@ function NavigationNode({ id, data }: NodeProps) {
- +
) { @@ -52,7 +53,10 @@ function SendEmailNode({ id, data }: NodeProps) {
- +
) { const { updateNodeData } = useReactFlow(); @@ -91,7 +92,10 @@ function TaskNode({ id, data }: NodeProps) {
- +
) { @@ -60,7 +61,10 @@ function TextPromptNode({ id, data }: NodeProps) {
- +
) { @@ -35,7 +36,10 @@ function UploadNode({ id, data }: NodeProps) {
- +
) { const { updateNodeData } = useReactFlow(); @@ -62,7 +63,10 @@ function ValidationNode({ id, data }: NodeProps) {
- +
) { const { updateNodeData } = useReactFlow(); @@ -49,7 +50,10 @@ function WaitNode({ id, data }: NodeProps) {
- +
; + } + case "code": { + return ; + } + case "download_to_s3": { + return ; + } + case "extraction": { + return ; + } + case "file_download": { + return ; + } + case "file_url_parser": { + return ; + } + case "for_loop": { + return ; + } + case "login": { + return ; + } + case "navigation": { + return ; + } + case "send_email": { + return ; + } + case "task": { + return ; + } + case "text_prompt": { + return ; + } + case "upload_to_s3": { + return ; + } + case "validation": { + return ; + } + case "wait": { + return ; + } + } +} + +export { WorkflowBlockIcon }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index d4c66bfe..2409c5ae 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -1,3 +1,5 @@ +import { WorkflowBlockType } from "../../types/workflowTypes"; + export type NodeBaseData = { label: string; continueOnFailure: boolean; @@ -14,3 +16,23 @@ export const dataSchemaExampleValue = { sample: { type: "string" }, }, } as const; + +export const workflowBlockTitle: { + [blockType in WorkflowBlockType]: string; +} = { + action: "Action", + code: "Code", + download_to_s3: "Download", + extraction: "Extraction", + file_download: "File Download", + file_url_parser: "File Parser", + for_loop: "Loop", + login: "Login", + navigation: "Navigation", + send_email: "Send Email", + task: "Task", + text_prompt: "Text Prompt", + upload_to_s3: "Upload", + validation: "Validation", + wait: "Wait", +}; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index 81d12c7b..ceb521b3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -1,24 +1,10 @@ -import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; -import { - CheckCircledIcon, - Cross2Icon, - CursorTextIcon, - DownloadIcon, - EnvelopeClosedIcon, - FileIcon, - ListBulletIcon, - LockOpen1Icon, - PlusIcon, - StopwatchIcon, - UpdateIcon, - UploadIcon, -} from "@radix-ui/react-icons"; -import { WorkflowBlockNode } from "../nodes"; -import { AddNodeProps } from "../FlowRenderer"; -import { ClickIcon } from "@/components/icons/ClickIcon"; import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; -import { RobotIcon } from "@/components/icons/RobotIcon"; -import { ExtractIcon } from "@/components/icons/ExtractIcon"; +import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; +import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { WorkflowBlockTypes } from "../../types/workflowTypes"; +import { AddNodeProps } from "../FlowRenderer"; +import { WorkflowBlockNode } from "../nodes"; +import { WorkflowBlockIcon } from "../nodes/WorkflowBlockIcon"; const nodeLibraryItems: Array<{ nodeType: NonNullable; @@ -28,93 +14,166 @@ const nodeLibraryItems: Array<{ }> = [ { nodeType: "navigation", - icon: , + icon: ( + + ), title: "Navigation Block", description: "Navigate on the page", }, { nodeType: "action", - icon: , + icon: ( + + ), title: "Action Block", description: "Take a single action", }, { nodeType: "extraction", - icon: , + icon: ( + + ), title: "Extraction Block", description: "Extract data from the page", }, { nodeType: "validation", - icon: , + icon: ( + + ), title: "Validation Block", description: "Validate the state of the workflow or terminate", }, { nodeType: "task", - icon: , + icon: ( + + ), title: "Task Block", description: "Takes actions or extracts information", }, { nodeType: "textPrompt", - icon: , + icon: ( + + ), title: "Text Prompt Block", description: "Generates AI response", }, { nodeType: "sendEmail", - icon: , + icon: ( + + ), title: "Send Email Block", description: "Sends an email", }, { nodeType: "loop", - icon: , + icon: ( + + ), title: "For Loop Block", description: "Repeats nested elements", }, // temporarily removed // { // nodeType: "codeBlock", - // icon: , + // icon: , // title: "Code Block", // description: "Executes Python code", // }, { nodeType: "fileParser", - icon: , + icon: ( + + ), title: "File Parser Block", description: "Downloads and parses a file", }, // disabled // { // nodeType: "download", - // icon: , + // icon: ( + // + // ), // title: "Download Block", // description: "Downloads a file from S3", // }, { nodeType: "upload", - icon: , + icon: ( + + ), title: "Upload Block", description: "Uploads a file to S3", }, { nodeType: "fileDownload", - icon: , + icon: ( + + ), title: "File Download Block", description: "Download a file", }, { nodeType: "login", - icon: , + icon: ( + + ), title: "Login Block", description: "Login to a website", }, { nodeType: "wait", - icon: , + icon: ( + + ), title: "Wait Block", description: "Wait for some time", }, diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunTimelineQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunTimelineQuery.ts new file mode 100644 index 00000000..7bda8515 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunTimelineQuery.ts @@ -0,0 +1,32 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { statusIsNotFinalized } from "@/routes/tasks/types"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import { WorkflowRunTimelineItem } from "../types/workflowRunTypes"; +import { useWorkflowRunQuery } from "./useWorkflowRunQuery"; + +function useWorkflowRunTimelineQuery() { + const { workflowRunId, workflowPermanentId } = useParams(); + const credentialGetter = useCredentialGetter(); + const { data: workflowRun } = useWorkflowRunQuery(); + + return useQuery>({ + queryKey: ["workflowRunTimeline", workflowPermanentId, workflowRunId], + queryFn: async () => { + const client = await getClient(credentialGetter); + return client + .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}/timeline`) + .then((response) => response.data); + }, + refetchInterval: + workflowRun && statusIsNotFinalized(workflowRun) ? 5000 : false, + placeholderData: keepPreviousData, + refetchOnMount: + workflowRun && statusIsNotFinalized(workflowRun) ? "always" : false, + refetchOnWindowFocus: + workflowRun && statusIsNotFinalized(workflowRun) ? "always" : false, + }); +} + +export { useWorkflowRunTimelineQuery }; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts new file mode 100644 index 00000000..48e0ef76 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts @@ -0,0 +1,138 @@ +import { ActionsApiResponse, Status } from "@/api/types"; +import { isTaskVariantBlock, WorkflowBlockType } from "./workflowTypes"; + +export const WorkflowRunTimelineItemTypes = { + Thought: "thought", + Block: "block", +} as const; + +export type WorkflowRunTimelineItemType = + (typeof WorkflowRunTimelineItemTypes)[keyof typeof WorkflowRunTimelineItemTypes]; + +export type ObserverThought = { + observer_thought_id: string; + user_input: string | null; + observation: string | null; + thought: string | null; + answer: string | null; + created_at: string; + modified_at: string; +}; + +export type WorkflowRunBlock = { + workflow_run_block_id: string; + workflow_run_id: string; + parent_workflow_run_block_id: string | null; + block_type: WorkflowBlockType; + label: string | null; + title: string | null; + status: Status | null; + failure_reason: string | null; + output: object | Array | string | null; + continue_on_failure: boolean; + task_id: string | null; + url: string | null; + navigation_goal: string | null; + navigation_payload: Record | null; + data_extraction_goal: string | null; + data_schema: object | Array | string | null; + terminate_criterion: string | null; + complete_criterion: string | null; + actions: Array | null; + recipients?: Array | null; + attachments?: Array | null; + subject?: string | null; + body?: string | null; + prompt?: string | null; + wait_sec?: number | null; + created_at: string; + modified_at: string; +}; + +export type WorkflowRunTimelineBlockItem = { + type: "block"; + block: WorkflowRunBlock; + children: Array; + thought: null; + created_at: string; + modified_at: string; +}; + +export type WorkflowRunTimelineThoughtItem = { + type: "thought"; + block: null; + children: Array; + thought: ObserverThought; + created_at: string; + modified_at: string; +}; + +export type WorkflowRunTimelineItem = + | WorkflowRunTimelineBlockItem + | WorkflowRunTimelineThoughtItem; + +export function isThoughtItem( + item: unknown, +): item is WorkflowRunTimelineThoughtItem { + return ( + typeof item === "object" && + item !== null && + "type" in item && + item.type === "thought" && + "thought" in item && + item.thought !== null + ); +} + +export function isBlockItem( + item: unknown, +): item is WorkflowRunTimelineBlockItem { + return ( + typeof item === "object" && + item !== null && + "type" in item && + item.type === "block" && + "block" in item && + item.block !== null + ); +} + +export function isTaskVariantBlockItem(item: unknown) { + return isBlockItem(item) && isTaskVariantBlock(item.block); +} + +export function isWorkflowRunBlock(item: unknown): item is WorkflowRunBlock { + return ( + typeof item === "object" && + item !== null && + "block_type" in item && + "workflow_run_block_id" in item + ); +} + +export function isObserverThought(item: unknown): item is ObserverThought { + return ( + typeof item === "object" && + item !== null && + "observer_thought_id" in item && + "thought" in item + ); +} + +export function isAction(item: unknown): item is ActionsApiResponse { + return typeof item === "object" && item !== null && "action_id" in item; +} + +export function hasExtractedInformation( + item: unknown, +): item is { extracted_information: unknown } { + return ( + item !== null && typeof item === "object" && "extracted_information" in item + ); +} + +export function hasNavigationGoal( + item: unknown, +): item is { navigation_goal: unknown } { + return item !== null && typeof item === "object" && "navigation_goal" in item; +} diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 12942628..7a9d44e3 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -120,7 +120,7 @@ export type WorkflowBlock = | WaitBlock | FileDownloadBlock; -export const WorkflowBlockType = { +export const WorkflowBlockTypes = { Task: "task", ForLoop: "for_loop", Code: "code", @@ -138,8 +138,22 @@ export const WorkflowBlockType = { FileDownload: "file_download", } as const; +export function isTaskVariantBlock(item: { + block_type: WorkflowBlockType; +}): boolean { + return ( + item.block_type === "task" || + item.block_type === "navigation" || + item.block_type === "action" || + item.block_type === "extraction" || + item.block_type === "validation" || + item.block_type === "login" || + item.block_type === "file_download" + ); +} + export type WorkflowBlockType = - (typeof WorkflowBlockType)[keyof typeof WorkflowBlockType]; + (typeof WorkflowBlockTypes)[keyof typeof WorkflowBlockTypes]; export type WorkflowBlockBase = { label: string; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index ad865a65..062ffedd 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -1,3 +1,5 @@ +import { WorkflowBlockType } from "./workflowTypes"; + export type WorkflowCreateYAMLRequest = { title: string; description?: string | null; @@ -67,26 +69,6 @@ export type OutputParameterYAML = ParameterYAMLBase & { parameter_type: "output"; }; -const BlockTypes = { - TASK: "task", - FOR_LOOP: "for_loop", - CODE: "code", - TEXT_PROMPT: "text_prompt", - DOWNLOAD_TO_S3: "download_to_s3", - UPLOAD_TO_S3: "upload_to_s3", - SEND_EMAIL: "send_email", - FILE_URL_PARSER: "file_url_parser", - VALIDATION: "validation", - ACTION: "action", - NAVIGATION: "navigation", - EXTRACTION: "extraction", - LOGIN: "login", - WAIT: "wait", - FILE_DOWNLOAD: "file_download", -} as const; - -export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes]; - export type BlockYAML = | TaskBlockYAML | CodeBlockYAML @@ -105,7 +87,7 @@ export type BlockYAML = | FileDownloadBlockYAML; export type BlockYAMLBase = { - block_type: BlockType; + block_type: WorkflowBlockType; label: string; continue_on_failure?: boolean; }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx new file mode 100644 index 00000000..17d42478 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx @@ -0,0 +1,63 @@ +import { ActionsApiResponse, ActionTypes, Status } from "@/api/types"; +import { Separator } from "@/components/ui/separator"; +import { ActionTypePill } from "@/routes/tasks/detail/ActionTypePill"; +import { cn } from "@/util/utils"; +import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; + +type Props = { + action: ActionsApiResponse; + index: number; + active: boolean; + onClick: () => void; +}; + +function ActionCard({ action, onClick, active, index }: Props) { + const success = action.status === Status.Completed; + + return ( +
+
+
+
+ #{index} +
+
+ + {success ? ( +
+ + Success +
+ ) : ( +
+ + Fail +
+ )} +
+
+
{action.reasoning}
+ {action.action_type === ActionTypes.InputText && ( + <> + +
+ Input: {action.response} +
+ + )} +
+
+ ); +} + +export { ActionCard }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/BlockCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/BlockCard.tsx new file mode 100644 index 00000000..e98da892 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/BlockCard.tsx @@ -0,0 +1,41 @@ +import { CubeIcon } from "@radix-ui/react-icons"; +import { WorkflowRunBlock } from "../types/workflowRunTypes"; +import { cn } from "@/util/utils"; +import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; +import { workflowBlockTitle } from "../editor/nodes/types"; + +type Props = { + active: boolean; + block: WorkflowRunBlock; + onClick: () => void; +}; + +function BlockCard({ block, onClick, active }: Props) { + return ( +
+
+
+ + {workflowBlockTitle[block.block_type]} +
+
+ + Block +
+
+
+ ); +} + +export { BlockCard }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ObserverThoughtScreenshot.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ObserverThoughtScreenshot.tsx new file mode 100644 index 00000000..705e7321 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ObserverThoughtScreenshot.tsx @@ -0,0 +1,76 @@ +import { getClient } from "@/api/AxiosClient"; +import { ArtifactApiResponse, ArtifactType, Status } from "@/api/types"; +import { ZoomableImage } from "@/components/ZoomableImage"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; +import { ReloadIcon } from "@radix-ui/react-icons"; +import { statusIsNotFinalized } from "@/routes/tasks/types"; +import { getImageURL } from "@/routes/tasks/detail/artifactUtils"; + +type Props = { + observerThoughtId: string; + taskStatus?: Status; // to give a hint that screenshot may not be available if task is not finalized +}; + +function ObserverThoughtScreenshot({ observerThoughtId, taskStatus }: Props) { + const credentialGetter = useCredentialGetter(); + + const { data: artifacts, isLoading } = useQuery>({ + queryKey: ["observerThought", observerThoughtId, "artifacts"], + queryFn: async () => { + const client = await getClient(credentialGetter); + return client + .get(`/observer_thought/${observerThoughtId}/artifacts`) + .then((response) => response.data); + }, + refetchInterval: (query) => { + const data = query.state.data; + const screenshot = data?.filter( + (artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot, + )?.[0]; + if (!screenshot) { + return 5000; + } + return false; + }, + }); + + const llmScreenshots = artifacts?.filter( + (artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot, + ); + + const screenshot = llmScreenshots?.[0]; + + if (isLoading) { + return ( +
+ +
Loading screenshot...
+
+ ); + } + + if ( + !screenshot && + taskStatus && + statusIsNotFinalized({ status: taskStatus }) + ) { + return
The screenshot for this action is not available yet.
; + } + + if (!screenshot) { + return ( +
+ No screenshot found for this action. +
+ ); + } + + return ( +
+ +
+ ); +} + +export { ObserverThoughtScreenshot }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx new file mode 100644 index 00000000..cc9d9c5d --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx @@ -0,0 +1,36 @@ +import { PersonIcon } from "@radix-ui/react-icons"; +import { ObserverThought } from "../types/workflowRunTypes"; +import { cn } from "@/util/utils"; + +type Props = { + active: boolean; + thought: ObserverThought; + onClick: (thought: ObserverThought) => void; +}; + +function ThoughtCard({ thought, onClick, active }: Props) { + return ( +
{ + onClick(thought); + }} + > +
+ Thought +
+ + Decision +
+
+
{thought.answer}
+
+ ); +} + +export { ThoughtCard }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx new file mode 100644 index 00000000..7ae3f212 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx @@ -0,0 +1,171 @@ +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { WorkflowRunOverviewSkeleton } from "./WorkflowRunOverviewSkeleton"; +import { useState } from "react"; +import { statusIsNotFinalized } from "@/routes/tasks/types"; +import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; +import { WorkflowRunStream } from "./WorkflowRunStream"; +import { ActionScreenshot } from "@/routes/tasks/detail/ActionScreenshot"; +import { WorkflowRunTimelineBlockItem } from "./WorkflowRunTimelineBlockItem"; +import { ThoughtCard } from "./ThoughtCard"; +import { + isAction, + isBlockItem, + isObserverThought, + isThoughtItem, + isWorkflowRunBlock, + ObserverThought, + WorkflowRunBlock, +} from "../types/workflowRunTypes"; +import { ActionsApiResponse } from "@/api/types"; +import { cn } from "@/util/utils"; +import { DotFilledIcon } from "@radix-ui/react-icons"; +import { WorkflowRunTimelineItemInfoSection } from "./WorkflowRunTimelineItemInfoSection"; +import { ObserverThoughtScreenshot } from "./ObserverThoughtScreenshot"; +import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; + +export type WorkflowRunOverviewActiveElement = + | ActionsApiResponse + | ObserverThought + | WorkflowRunBlock + | "stream" + | null; + +function WorkflowRunOverview() { + const [active, setActive] = useState(null); + const { data: workflowRun, isLoading: workflowRunIsLoading } = + useWorkflowRunQuery(); + + const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } = + useWorkflowRunTimelineQuery(); + + if (workflowRunIsLoading || workflowRunTimelineIsLoading) { + return ; + } + + if (!workflowRun) { + return null; + } + + if (typeof workflowRunTimeline === "undefined") { + return null; + } + + const workflowRunIsNotFinalized = statusIsNotFinalized(workflowRun); + + const timeline = workflowRunTimeline.slice().reverse(); + + function getActiveSelection(): WorkflowRunOverviewActiveElement { + if (active === null) { + if (workflowRunIsNotFinalized) { + return "stream"; + } + if (timeline!.length > 0) { + const timelineItem = timeline![0]; + if (isBlockItem(timelineItem)) { + if ( + timelineItem.block.actions && + timelineItem.block.actions.length > 0 + ) { + return timelineItem.block + .actions[0] as WorkflowRunOverviewActiveElement; + } + return timelineItem.block; + } + if (isThoughtItem(timelineItem)) { + return timelineItem.thought; + } + } + } + return active; + } + + const selection = getActiveSelection(); + + return ( +
+
+ + {selection === "stream" && } + {selection !== "stream" && isAction(selection) && ( + + )} + {isWorkflowRunBlock(selection) && ( +
+ No screenshot found for this block +
+ )} + {isObserverThought(selection) && ( + + )} +
+ + +
+
+ + +
+
+ {workflowRunIsNotFinalized && ( +
setActive("stream")} + > +
+ + Live +
+
+ )} + {timeline.length === 0 &&
Workflow timeline is empty
} + {timeline?.map((timelineItem) => { + if (isBlockItem(timelineItem)) { + return ( + item.type === "block") + .map((item) => item.block)} + activeItem={selection} + block={timelineItem.block} + onActionClick={setActive} + onBlockItemClick={setActive} + /> + ); + } + if (isThoughtItem(timelineItem)) { + return ( + + ); + } + })} +
+
+
+
+
+ ); +} + +export { WorkflowRunOverview }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverviewSkeleton.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverviewSkeleton.tsx new file mode 100644 index 00000000..fd26404b --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverviewSkeleton.tsx @@ -0,0 +1,22 @@ +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Skeleton } from "@/components/ui/skeleton"; + +function WorkflowRunOverviewSkeleton() { + return ( +
+
+ + + +
+ +
+
+
+ +
+
+ ); +} + +export { WorkflowRunOverviewSkeleton }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStream.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStream.tsx new file mode 100644 index 00000000..574f548e --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunStream.tsx @@ -0,0 +1,152 @@ +import { Status } from "@/api/types"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { ZoomableImage } from "@/components/ZoomableImage"; +import { useEffect, useState } from "react"; +import { statusIsNotFinalized } from "@/routes/tasks/types"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useParams } from "react-router-dom"; +import { envCredential } from "@/util/env"; +import { toast } from "@/components/ui/use-toast"; +import { useQueryClient } from "@tanstack/react-query"; + +type StreamMessage = { + task_id: string; + status: string; + screenshot?: string; +}; + +let socket: WebSocket | null = null; + +const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL; + +function WorkflowRunStream() { + const { data: workflowRun } = useWorkflowRunQuery(); + const [streamImgSrc, setStreamImgSrc] = useState(""); + const showStream = workflowRun && statusIsNotFinalized(workflowRun); + const credentialGetter = useCredentialGetter(); + const { workflowRunId, workflowPermanentId } = useParams(); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!showStream) { + return; + } + + async function run() { + // Create WebSocket connection. + let credential = null; + if (credentialGetter) { + const token = await credentialGetter(); + credential = `?token=Bearer ${token}`; + } else { + credential = `?apikey=${envCredential}`; + } + if (socket) { + socket.close(); + } + socket = new WebSocket( + `${wssBaseUrl}/stream/workflow_runs/${workflowRunId}${credential}`, + ); + // Listen for messages + socket.addEventListener("message", (event) => { + try { + const message: StreamMessage = JSON.parse(event.data); + if (message.screenshot) { + setStreamImgSrc(message.screenshot); + } + if ( + message.status === "completed" || + message.status === "failed" || + message.status === "terminated" + ) { + socket?.close(); + queryClient.invalidateQueries({ + queryKey: ["workflowRuns"], + }); + queryClient.invalidateQueries({ + queryKey: ["workflowRun", workflowPermanentId, workflowRunId], + }); + queryClient.invalidateQueries({ + queryKey: ["workflowTasks", workflowRunId], + }); + if ( + message.status === "failed" || + message.status === "terminated" + ) { + toast({ + title: "Run Failed", + description: "The workflow run has failed.", + variant: "destructive", + }); + } else if (message.status === "completed") { + toast({ + title: "Run Completed", + description: "The workflow run has been completed.", + variant: "success", + }); + } + } + } catch (e) { + console.error("Failed to parse message", e); + } + }); + + socket.addEventListener("close", () => { + socket = null; + }); + } + run(); + + return () => { + if (socket) { + socket.close(); + socket = null; + } + }; + }, [ + credentialGetter, + workflowRunId, + showStream, + queryClient, + workflowPermanentId, + ]); + + if (workflowRun?.status === Status.Created) { + return ( +
+ Workflow has been created. + Stream will start when the workflow is running. +
+ ); + } + if (workflowRun?.status === Status.Queued) { + return ( +
+ Your workflow run is queued. + Stream will start when the workflow is running. +
+ ); + } + + if (workflowRun?.status === Status.Running && streamImgSrc.length === 0) { + return ( +
+ Starting the stream... +
+ ); + } + + if (workflowRun?.status === Status.Running && streamImgSrc.length > 0) { + return ( +
+ +
+ ); + } + return null; +} + +export { WorkflowRunStream }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx new file mode 100644 index 00000000..70d5cc29 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -0,0 +1,70 @@ +import { ActionsApiResponse } from "@/api/types"; +import { + isAction, + isWorkflowRunBlock, + WorkflowRunBlock, +} from "../types/workflowRunTypes"; +import { ActionCard } from "./ActionCard"; +import { BlockCard } from "./BlockCard"; +import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; + +type Props = { + activeItem: WorkflowRunOverviewActiveElement; + block: WorkflowRunBlock; + subBlocks: Array; + onBlockItemClick: (block: WorkflowRunBlock) => void; + onActionClick: (action: ActionsApiResponse) => void; +}; + +function WorkflowRunTimelineBlockItem({ + activeItem, + block, + subBlocks, + onBlockItemClick, + onActionClick, +}: Props) { + const actions = block.actions ? [...block.actions].reverse() : []; + + return ( +
+ {actions.map((action, index) => { + return ( + { + onActionClick(action); + }} + /> + ); + })} + {subBlocks.map((block) => { + return ( + + ); + })} + { + onBlockItemClick(block); + }} + /> +
+ ); +} + +export { WorkflowRunTimelineBlockItem }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx new file mode 100644 index 00000000..0bff9fcd --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineItemInfoSection.tsx @@ -0,0 +1,211 @@ +import { ActionsApiResponse, Status } from "@/api/types"; +import { + hasExtractedInformation, + isAction, + isObserverThought, + isWorkflowRunBlock, + ObserverThought, + WorkflowRunBlock, +} from "../types/workflowRunTypes"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CodeEditor } from "../components/CodeEditor"; +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { WorkflowBlockTypes } from "../types/workflowTypes"; +import { statusIsAFailureType } from "@/routes/tasks/types"; +import { SendEmailBlockInfo } from "./blockInfo/SendEmailBlockInfo"; + +type Props = { + item: + | ActionsApiResponse + | ObserverThought + | WorkflowRunBlock + | "stream" + | null; +}; + +function WorkflowRunTimelineItemInfoSection({ item }: Props) { + if (!item) { + return null; + } + if (item === "stream") { + return null; + } + if (isAction(item)) { + return null; + } + if (isObserverThought(item)) { + return ( +
+ + + Observation + Thought + Answer + + + + + + + + + + + +
+ ); + } + if (isWorkflowRunBlock(item)) { + if ( + item.block_type === WorkflowBlockTypes.Task || + item.block_type === WorkflowBlockTypes.Navigation || + item.block_type === WorkflowBlockTypes.Action || + item.block_type === WorkflowBlockTypes.Extraction || + item.block_type === WorkflowBlockTypes.Validation || + item.block_type === WorkflowBlockTypes.Login || + item.block_type === WorkflowBlockTypes.FileDownload + ) { + return ( +
+ + + Navigation Goal + {item.status === Status.Completed && ( + + Extracted Information + + )} + {item.status && statusIsAFailureType({ status: item.status }) && ( + Failure Reason + )} + Parameters + + + + + {item.status === Status.Completed && ( + + + + )} + {item.status && statusIsAFailureType({ status: item.status }) && ( + + + + )} + + + + +
+ ); + } + if (item.block_type === WorkflowBlockTypes.SendEmail) { + if ( + item.body !== null && + typeof item.body !== "undefined" && + item.recipients !== null && + typeof item.recipients !== "undefined" + ) { + return ( + + ); + } + return null; + } + + if (item.block_type === WorkflowBlockTypes.TextPrompt) { + if (item.prompt !== null) { + return ( +
+ + + Prompt + Output + + + + + + + + +
+ ); + } + return null; + } + + if (item.block_type === WorkflowBlockTypes.Wait) { + if (item.wait_sec !== null && typeof item.wait_sec !== "undefined") { + return ( +
+ Wait Time + {item.wait_sec} Seconds +
+ ); + } + return null; + } + + return ( +
+ + + Output + + + + + +
+ ); + } +} + +export { WorkflowRunTimelineItemInfoSection }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx new file mode 100644 index 00000000..93f3a460 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockInfo/SendEmailBlockInfo.tsx @@ -0,0 +1,29 @@ +type Props = { + recipients: Array; + body: string; +}; + +function SendEmailBlockInfo({ recipients, body }: Props) { + return ( +
+
+
+ From + hello@skyvern.com +
+
+ To + {recipients.map((recipient) => { + return {recipient}; + })} +
+
+
+ Body +

{body}

+
+
+ ); +} + +export { SendEmailBlockInfo }; diff --git a/skyvern-frontend/src/util/env.ts b/skyvern-frontend/src/util/env.ts index 19721eb6..d54adc00 100644 --- a/skyvern-frontend/src/util/env.ts +++ b/skyvern-frontend/src/util/env.ts @@ -19,13 +19,4 @@ if (!artifactApiBaseUrl) { console.warn("artifactApiBaseUrl environment variable was not set"); } -const observerEnabled = import.meta.env.VITE_OBSERVER_ENABLED as string; -const observerFeatureEnabled = observerEnabled === "true"; - -export { - apiBaseUrl, - environment, - envCredential, - artifactApiBaseUrl, - observerFeatureEnabled, -}; +export { apiBaseUrl, environment, envCredential, artifactApiBaseUrl };