diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowCopilotButton.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowCopilotButton.tsx new file mode 100644 index 00000000..edb62c02 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowCopilotButton.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from "react"; +import { ChatBubbleIcon } from "@radix-ui/react-icons"; +import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser"; + +interface WorkflowCopilotButtonProps { + messageCount: number; + onClick: () => void; +} + +export const WorkflowCopilotButton = forwardRef< + HTMLButtonElement, + WorkflowCopilotButtonProps +>(({ messageCount, onClick }, ref) => { + const isSkyvernUser = useIsSkyvernUser(); + + if (!isSkyvernUser) { + return null; + } + + return ( + + ); +}); + +WorkflowCopilotButton.displayName = "WorkflowCopilotButton"; diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowCopilotChat.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowCopilotChat.tsx new file mode 100644 index 00000000..d8e23dfe --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowCopilotChat.tsx @@ -0,0 +1,674 @@ +import { useState, useEffect, useRef } from "react"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser"; +import { useParams } from "react-router-dom"; +import { ReloadIcon, Cross2Icon } from "@radix-ui/react-icons"; +import { stringify as convertToYAML } from "yaml"; +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; +import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes"; +import { toast } from "@/components/ui/use-toast"; + +interface ChatMessage { + id: string; + sender: "ai" | "user"; + content: string; + timestamp?: string; +} + +interface WorkflowCopilotChatProps { + onWorkflowUpdate?: (workflowYaml: string) => void; + isOpen?: boolean; + onClose?: () => void; + onMessageCountChange?: (count: number) => void; + buttonRef?: React.RefObject; +} + +const DEFAULT_WINDOW_WIDTH = 600; +const DEFAULT_WINDOW_HEIGHT = 400; +const MIN_WINDOW_WIDTH = 300; +const MIN_WINDOW_HEIGHT = 300; +const OFFSET = 24; + +const calculateDefaultPosition = ( + width: number, + height: number, + buttonRef?: React.RefObject, +) => { + // If button ref is available, align left edge of window with left edge of button + if (buttonRef?.current) { + const buttonRect = buttonRef.current.getBoundingClientRect(); + return { + x: buttonRect.left - OFFSET, + y: window.innerHeight - height - 2 * OFFSET, + }; + } + + // Fallback to centered position + return { + x: window.innerWidth / 2 - width / 2, + y: window.innerHeight - height - 2 * OFFSET, + }; +}; + +const constrainPosition = ( + x: number, + y: number, + width: number, + height: number, +) => { + const maxX = window.innerWidth - width - OFFSET; + const maxY = window.innerHeight - height - OFFSET; + + return { + x: Math.min(Math.max(0, x), maxX), + y: Math.min(Math.max(0, y), maxY), + }; +}; + +export function WorkflowCopilotChat({ + onWorkflowUpdate, + isOpen = true, + onClose, + onMessageCountChange, + buttonRef, +}: WorkflowCopilotChatProps = {}) { + const isSkyvernUser = useIsSkyvernUser(); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const [workflowCopilotChatId, setWorkflowCopilotChatId] = useState< + string | null + >(null); + const [size, setSize] = useState({ + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + }); + const [position, setPosition] = useState( + calculateDefaultPosition( + DEFAULT_WINDOW_WIDTH, + DEFAULT_WINDOW_HEIGHT, + buttonRef, + ), + ); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [isResizing, setIsResizing] = useState(false); + const [resizeDirection, setResizeDirection] = useState< + "n" | "s" | "e" | "w" | "se" | "sw" | "ne" | "nw" + >("se"); + const [resizeStart, setResizeStart] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + posX: 0, + posY: 0, + }); + const credentialGetter = useCredentialGetter(); + const { workflowRunId, workflowPermanentId } = useParams(); + const messagesEndRef = useRef(null); + const { getSaveData } = useWorkflowHasChangesStore(); + const hasInitializedPosition = useRef(false); + + const formatChatTimestamp = (value: string) => { + let normalizedValue = value.replace(/\.(\d{3})\d*/, ".$1"); + if (!normalizedValue.endsWith("Z")) { + normalizedValue += "Z"; + } + return new Date(normalizedValue).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); + }; + + const hasScrolledOnLoad = useRef(false); + + const scrollToBottom = (behavior: ScrollBehavior) => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }; + + const handleNewChat = () => { + setMessages([]); + setWorkflowCopilotChatId(null); + hasScrolledOnLoad.current = false; + }; + + // Notify parent of message count changes + useEffect(() => { + if (onMessageCountChange) { + onMessageCountChange(messages.length); + } + }, [messages.length, onMessageCountChange]); + + useEffect(() => { + if (isLoadingHistory) { + return; + } + if (!hasScrolledOnLoad.current) { + scrollToBottom("auto"); + hasScrolledOnLoad.current = true; + return; + } + scrollToBottom("smooth"); + }, [messages, isLoading, isLoadingHistory]); + + useEffect(() => { + if (!workflowPermanentId) { + setMessages([]); + setWorkflowCopilotChatId(null); + return; + } + + let isMounted = true; + + const fetchHistory = async () => { + setIsLoadingHistory(true); + hasScrolledOnLoad.current = false; + try { + const client = await getClient(credentialGetter, "sans-api-v1"); + const response = await client.get<{ + workflow_copilot_chat_id: string | null; + chat_history: Array<{ + sender: "ai" | "user"; + content: string; + created_at: string; + }>; + }>("/workflow/copilot/chat-history", { + params: { workflow_permanent_id: workflowPermanentId }, + }); + + if (!isMounted) return; + + const historyMessages = response.data.chat_history.map( + (message, index) => ({ + id: `${index}-${Date.now()}`, + sender: message.sender, + content: message.content, + timestamp: message.created_at, + }), + ); + setMessages(historyMessages); + setWorkflowCopilotChatId(response.data.workflow_copilot_chat_id); + } catch (error) { + console.error("Failed to load chat history:", error); + } finally { + if (isMounted) { + setIsLoadingHistory(false); + } + } + }; + + fetchHistory(); + + return () => { + isMounted = false; + }; + }, [credentialGetter, workflowPermanentId]); + + const handleSend = async () => { + if (!inputValue.trim() || isLoading) return; + if (!workflowPermanentId) { + toast({ + title: "Missing workflow", + description: "Workflow permanent ID is required to chat.", + variant: "destructive", + }); + return; + } + + const userMessageId = Date.now().toString(); + const userMessage: ChatMessage = { + id: userMessageId, + sender: "user", + content: inputValue, + }; + + setMessages((prev) => [...prev, userMessage]); + const messageContent = inputValue; + setInputValue(""); + setIsLoading(true); + + try { + const saveData = getSaveData(); + let workflowYaml = ""; + + if (saveData) { + const extraHttpHeaders: Record = {}; + if (saveData.settings.extraHttpHeaders) { + try { + const parsedHeaders = JSON.parse( + saveData.settings.extraHttpHeaders, + ); + if ( + parsedHeaders && + typeof parsedHeaders === "object" && + !Array.isArray(parsedHeaders) + ) { + for (const [key, value] of Object.entries(parsedHeaders)) { + if (key && typeof key === "string") { + extraHttpHeaders[key] = String(value); + } + } + } + } catch (error) { + console.error("Error parsing extra HTTP headers:", error); + } + } + + const scriptCacheKey = saveData.settings.scriptCacheKey ?? ""; + const normalizedKey = + scriptCacheKey === "" ? "default" : saveData.settings.scriptCacheKey; + + const requestBody: WorkflowCreateYAMLRequest = { + title: saveData.title, + description: saveData.workflow.description, + proxy_location: saveData.settings.proxyLocation, + webhook_callback_url: saveData.settings.webhookCallbackUrl, + persist_browser_session: saveData.settings.persistBrowserSession, + model: saveData.settings.model, + max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls, + totp_verification_url: saveData.workflow.totp_verification_url, + extra_http_headers: extraHttpHeaders, + run_with: saveData.settings.runWith, + cache_key: normalizedKey, + ai_fallback: saveData.settings.aiFallback ?? true, + workflow_definition: { + version: saveData.workflowDefinitionVersion, + parameters: saveData.parameters, + blocks: saveData.blocks, + }, + is_saved_task: saveData.workflow.is_saved_task, + status: saveData.workflow.status, + run_sequentially: saveData.settings.runSequentially, + sequential_key: saveData.settings.sequentialKey, + }; + + workflowYaml = convertToYAML(requestBody); + } + + const client = await getClient(credentialGetter, "sans-api-v1"); + + const response = await client.post<{ + workflow_copilot_chat_id: string; + message: string; + updated_workflow_yaml: string | null; + request_time: string; + response_time: string; + }>("/workflow/copilot/chat-post", { + workflow_permanent_id: workflowPermanentId, + workflow_copilot_chat_id: workflowCopilotChatId, + workflow_run_id: workflowRunId, + message: messageContent, + workflow_yaml: workflowYaml, + }); + + setWorkflowCopilotChatId(response.data.workflow_copilot_chat_id); + + const aiMessage: ChatMessage = { + id: Date.now().toString(), + sender: "ai", + content: response.data.message || "I received your message.", + timestamp: response.data.response_time, + }; + + setMessages((prev) => [ + ...prev.map((message) => + message.id === userMessageId + ? { + ...message, + timestamp: response.data.request_time, + } + : message, + ), + aiMessage, + ]); + + if (response.data.updated_workflow_yaml && onWorkflowUpdate) { + try { + onWorkflowUpdate(response.data.updated_workflow_yaml); + } catch (updateError) { + console.error("Failed to update workflow:", updateError); + toast({ + title: "Update failed", + description: "Failed to apply workflow changes. Please try again.", + variant: "destructive", + }); + } + } + } catch (error) { + console.error("Failed to send message:", error); + const errorMessage: ChatMessage = { + id: Date.now().toString(), + sender: "ai", + content: "Sorry, I encountered an error. Please try again.", + timestamp: new Date().toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }), + }; + setMessages((prev) => [...prev, errorMessage]); + } finally { + setIsLoading(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSend(); + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + setDragStart({ + x: e.clientX - position.x, + y: e.clientY - position.y, + }); + }; + + const handleResizeMouseDown = ( + e: React.MouseEvent, + direction: "n" | "s" | "e" | "w" | "se" | "sw" | "ne" | "nw", + ) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeDirection(direction); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: size.width, + height: size.height, + posX: position.x, + posY: position.y, + }); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + setPosition({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y, + }); + } + if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.posX; + let newY = resizeStart.posY; + + // Corners + if (resizeDirection === "se") { + // Southeast: resize from bottom-right + newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width + deltaX); + newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height + deltaY); + } else if (resizeDirection === "sw") { + // Southwest: resize from bottom-left + newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width - deltaX); + newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height + deltaY); + if (resizeStart.width - deltaX >= MIN_WINDOW_WIDTH) { + newX = resizeStart.posX + deltaX; + } + } else if (resizeDirection === "ne") { + // Northeast: resize from top-right + newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width + deltaX); + newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height - deltaY); + if (resizeStart.height - deltaY >= MIN_WINDOW_HEIGHT) { + newY = resizeStart.posY + deltaY; + } + } else if (resizeDirection === "nw") { + // Northwest: resize from top-left + newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width - deltaX); + newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height - deltaY); + if (resizeStart.width - deltaX >= MIN_WINDOW_WIDTH) { + newX = resizeStart.posX + deltaX; + } + if (resizeStart.height - deltaY >= MIN_WINDOW_HEIGHT) { + newY = resizeStart.posY + deltaY; + } + } + // Edges + else if (resizeDirection === "n") { + // North: resize from top + newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height - deltaY); + if (resizeStart.height - deltaY >= MIN_WINDOW_HEIGHT) { + newY = resizeStart.posY + deltaY; + } + } else if (resizeDirection === "s") { + // South: resize from bottom + newHeight = Math.max(MIN_WINDOW_HEIGHT, resizeStart.height + deltaY); + } else if (resizeDirection === "e") { + // East: resize from right + newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width + deltaX); + } else if (resizeDirection === "w") { + // West: resize from left + newWidth = Math.max(MIN_WINDOW_WIDTH, resizeStart.width - deltaX); + if (resizeStart.width - deltaX >= MIN_WINDOW_WIDTH) { + newX = resizeStart.posX + deltaX; + } + } + + setSize({ + width: newWidth, + height: newHeight, + }); + setPosition({ + x: newX, + y: newY, + }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + if (isDragging || isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, dragStart, isResizing, resizeStart, resizeDirection]); + + // Handle window resize to keep chat window within viewport + useEffect(() => { + const handleResize = () => { + setPosition((prev) => + constrainPosition(prev.x, prev.y, size.width, size.height), + ); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [size]); + + // Recalculate position when chat opens to align with button (only first time) + useEffect(() => { + if (isOpen && buttonRef?.current && !hasInitializedPosition.current) { + const newPosition = calculateDefaultPosition( + size.width, + size.height, + buttonRef, + ); + setPosition(newPosition); + hasInitializedPosition.current = true; + } + }, [isOpen, buttonRef, size.width, size.height]); + + // Only show to Skyvern users + if (!isSkyvernUser || !isOpen) { + return null; + } + + return ( +
+ {/* Header */} +
+

+ Workflow Copilot +

+
+ +
+ Active + +
+
+ + {/* Messages */} +
+
+ {!isLoadingHistory && messages.length === 0 && !isLoading ? ( +
+

Start a new chat

+

+ Ask the copilot to draft or edit your workflow. Provide a goal, + the target site, and any credentials it should use. +

+

+ Example: “Build a workflow visit hackernews and get top 3 news + items” +

+
+ ) : null} + {messages.map((message) => ( +
+
+ {message.sender === "ai" ? "AI" : "U"} +
+
+

{message.content}

+ {message.timestamp ? ( + + {formatChatTimestamp(message.timestamp)} + + ) : null} +
+
+ ))} + {isLoading && ( +
+
+ AI +
+
+
+ + Processing... +
+
+
+ )} +
+
+
+ + {/* Input */} +
+
+ setInputValue(e.target.value)} + onKeyPress={handleKeyPress} + disabled={isLoading} + className="flex-1 rounded-md border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> + +
+
+ + {/* Resize Handles */} + {/* Corners */} +
handleResizeMouseDown(e, "se")} + title="Resize" + /> +
handleResizeMouseDown(e, "sw")} + title="Resize" + /> +
handleResizeMouseDown(e, "ne")} + title="Resize" + /> +
handleResizeMouseDown(e, "nw")} + title="Resize" + /> + {/* Edges */} +
handleResizeMouseDown(e, "n")} + title="Resize" + /> +
handleResizeMouseDown(e, "s")} + title="Resize" + /> +
handleResizeMouseDown(e, "w")} + title="Resize" + /> +
handleResizeMouseDown(e, "e")} + title="Resize" + /> +
+ ); +} diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 841aca51..1776fd27 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -70,6 +70,7 @@ import { useWorkflowHasChangesStore, useWorkflowSave, } from "@/store/WorkflowHasChangesStore"; +import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils"; import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns"; import { cn } from "@/util/utils"; @@ -85,7 +86,11 @@ import { getWorkflowErrors, getElements } from "./workflowEditorUtils"; import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel"; import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery"; -import { WorkflowSettings } from "../types/workflowTypes"; +import { + WorkflowApiResponse, + WorkflowBlock, + WorkflowSettings, +} from "../types/workflowTypes"; import { ProxyLocation } from "@/api/types"; import { nodeAdderNode, @@ -94,8 +99,16 @@ import { generateNodeLabel, layout, startNode, + upgradeWorkflowBlocksV1toV2, } from "./workflowEditorUtils"; -import { constructCacheKeyValue } from "./utils"; +import { constructCacheKeyValue, getInitialParameters } from "./utils"; +import { WorkflowCopilotChat } from "./WorkflowCopilotChat"; +import { WorkflowCopilotButton } from "./WorkflowCopilotButton"; +import { parse as parseYAML } from "yaml"; +import { + BlockYAML, + WorkflowCreateYAMLRequest, +} from "../types/workflowYamlTypes"; import "./workspace-styles.css"; const Constants = { @@ -226,7 +239,6 @@ function Workspace({ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const { getNodes, getEdges } = useReactFlow(); const saveWorkflow = useWorkflowSave({ status: "published" }); - const { data: workflowRun } = useWorkflowRunQuery(); const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : false; @@ -235,6 +247,9 @@ function Workspace({ const [toDeleteCacheKeyValue, setToDeleteCacheKeyValue] = useState< string | null >(null); + const [isCopilotOpen, setIsCopilotOpen] = useState(false); + const [copilotMessageCount, setCopilotMessageCount] = useState(0); + const copilotButtonRef = useRef(null); const [ openConfirmCacheKeyValueDeleteDialogue, setOpenConfirmCacheKeyValueDeleteDialogue, @@ -1485,6 +1500,11 @@ function Workspace({ />
+ setIsCopilotOpen((prev) => !prev)} + />
Live Browser
@@ -1626,6 +1646,89 @@ function Workspace({
)} + + setIsCopilotOpen(false)} + onMessageCountChange={setCopilotMessageCount} + buttonRef={copilotButtonRef} + onWorkflowUpdate={(workflowYaml) => { + try { + const parsedYaml = parseYAML( + workflowYaml, + ) as WorkflowCreateYAMLRequest; + + const settings: WorkflowSettings = { + proxyLocation: + parsedYaml.proxy_location || ProxyLocation.Residential, + webhookCallbackUrl: parsedYaml.webhook_callback_url || "", + persistBrowserSession: + parsedYaml.persist_browser_session ?? false, + model: parsedYaml.model ?? null, + maxScreenshotScrolls: parsedYaml.max_screenshot_scrolls || 3, + extraHttpHeaders: parsedYaml.extra_http_headers + ? JSON.stringify(parsedYaml.extra_http_headers) + : null, + runWith: parsedYaml.run_with ?? null, + scriptCacheKey: parsedYaml.cache_key ?? null, + aiFallback: parsedYaml.ai_fallback ?? true, + runSequentially: parsedYaml.run_sequentially ?? false, + sequentialKey: parsedYaml.sequential_key ?? null, + }; + + // Convert YAML blocks to internal format + // YAML has parameter_keys (array of strings), internal format has parameters (array of objects) + let blocks = (parsedYaml.workflow_definition?.blocks || []).map( + (block: BlockYAML) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const convertedBlock = { ...block } as any; + + // Convert parameter_keys to parameters format + if ("parameter_keys" in block) { + convertedBlock.parameters = (block.parameter_keys || []).map( + (key: string) => ({ + key, + parameter_type: "workflow", + }), + ); + } + + return convertedBlock; + }, + ) as WorkflowBlock[]; + + // Auto-upgrade v1 workflows to v2 by assigning sequential next_block_label values + const workflowVersion = + parsedYaml.workflow_definition?.version ?? 1; + if (workflowVersion < 2) { + blocks = upgradeWorkflowBlocksV1toV2(blocks); + } + + const elements = getElements(blocks, settings, true); + + setNodes(elements.nodes); + setEdges(elements.edges); + + const initialParameters = getInitialParameters({ + workflow_definition: { + parameters: parsedYaml.workflow_definition?.parameters || [], + }, + } as WorkflowApiResponse); + useWorkflowParametersStore + .getState() + .setParameters(initialParameters); + + workflowChangesStore.setHasChanges(true); + } catch (error) { + console.error("Failed to parse and apply workflow YAML:", error); + toast({ + title: "Update failed", + description: "Failed to parse workflow YAML. Please try again.", + variant: "destructive", + }); + } + }} + />
); }