Workflow Copilot: initial UI (#4416)
This commit is contained in:
committed by
GitHub
parent
00f7128ffd
commit
33753e0f39
@@ -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 (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2"
|
||||
title="Open Workflow Copilot"
|
||||
>
|
||||
<ChatBubbleIcon className="h-4 w-4" />
|
||||
<span>Copilot</span>
|
||||
{messageCount > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
{messageCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
WorkflowCopilotButton.displayName = "WorkflowCopilotButton";
|
||||
@@ -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<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>,
|
||||
) => {
|
||||
// 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<ChatMessage[]>([]);
|
||||
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<HTMLDivElement>(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<string, string> = {};
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className="fixed z-50 flex flex-col rounded-lg border border-slate-700 bg-slate-900 shadow-2xl"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex cursor-move items-center justify-between border-b border-slate-700 px-4 py-2"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-slate-200">
|
||||
Workflow Copilot
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewChat}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="rounded border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
New chat
|
||||
</button>
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-xs text-slate-400">Active</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose?.()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="ml-2 rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
||||
title="Close"
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-3">
|
||||
{!isLoadingHistory && messages.length === 0 && !isLoading ? (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-slate-200">Start a new chat</p>
|
||||
<p className="mt-2 text-slate-400">
|
||||
Ask the copilot to draft or edit your workflow. Provide a goal,
|
||||
the target site, and any credentials it should use.
|
||||
</p>
|
||||
<p className="mt-2 text-slate-400">
|
||||
Example: “Build a workflow visit hackernews and get top 3 news
|
||||
items”
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="flex items-start gap-3">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white ${
|
||||
message.sender === "ai" ? "bg-blue-600" : "bg-purple-600"
|
||||
}`}
|
||||
>
|
||||
{message.sender === "ai" ? "AI" : "U"}
|
||||
</div>
|
||||
<div className="relative flex-1 rounded-lg bg-slate-800 p-3 pr-12">
|
||||
<p className="text-sm text-slate-200">{message.content}</p>
|
||||
{message.timestamp ? (
|
||||
<span className="pointer-events-none absolute bottom-2 right-2 rounded bg-slate-900/70 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
{formatChatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
AI
|
||||
</div>
|
||||
<div className="flex-1 rounded-lg bg-slate-800 p-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<ReloadIcon className="h-4 w-4 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-slate-700 p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type your message..."
|
||||
value={inputValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resize Handles */}
|
||||
{/* Corners */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 z-10 h-3 w-3 cursor-nwse-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "se")}
|
||||
title="Resize"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 z-10 h-3 w-3 cursor-nesw-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "sw")}
|
||||
title="Resize"
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 z-10 h-3 w-3 cursor-nesw-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "ne")}
|
||||
title="Resize"
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 top-0 z-10 h-3 w-3 cursor-nwse-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "nw")}
|
||||
title="Resize"
|
||||
/>
|
||||
{/* Edges */}
|
||||
<div
|
||||
className="absolute left-3 right-3 top-0 z-10 h-1 cursor-ns-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "n")}
|
||||
title="Resize"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-3 right-3 z-10 h-1 cursor-ns-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "s")}
|
||||
title="Resize"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-3 left-0 top-3 z-10 w-1 cursor-ew-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "w")}
|
||||
title="Resize"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-3 right-0 top-3 z-10 w-1 cursor-ew-resize"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "e")}
|
||||
title="Resize"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const [
|
||||
openConfirmCacheKeyValueDeleteDialogue,
|
||||
setOpenConfirmCacheKeyValueDeleteDialogue,
|
||||
@@ -1485,6 +1500,11 @@ function Workspace({
|
||||
/>
|
||||
</div>
|
||||
<footer className="flex h-[2rem] w-full items-center justify-start gap-4">
|
||||
<WorkflowCopilotButton
|
||||
ref={copilotButtonRef}
|
||||
messageCount={copilotMessageCount}
|
||||
onClick={() => setIsCopilotOpen((prev) => !prev)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon /> Live Browser
|
||||
</div>
|
||||
@@ -1626,6 +1646,89 @@ function Workspace({
|
||||
</Splitter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkflowCopilotChat
|
||||
isOpen={isCopilotOpen}
|
||||
onClose={() => 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",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user