Workflow Copilot: review and approve/reject changes (#4559)

This commit is contained in:
Stanislav Novosad
2026-01-27 13:24:44 -07:00
committed by GitHub
parent cb2a72775d
commit c0f361bb6e
10 changed files with 481 additions and 80 deletions

View File

@@ -0,0 +1,33 @@
"""workflow copilot chat proposed workflow
Revision ID: 43217e31df12
Revises: ce791d022652
Create Date: 2026-01-27 20:20:36.952383+00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "43217e31df12"
down_revision: Union[str, None] = "ce791d022652"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("workflow_copilot_chats", sa.Column("proposed_workflow", sa.JSON(), nullable=True))
op.add_column("workflow_copilot_chats", sa.Column("auto_accept", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflow_copilot_chats", "auto_accept")
op.drop_column("workflow_copilot_chats", "proposed_workflow")
# ### end Alembic commands ###

View File

@@ -16,6 +16,7 @@ import {
WorkflowCopilotStreamResponseUpdate, WorkflowCopilotStreamResponseUpdate,
WorkflowCopilotChatSender, WorkflowCopilotChatSender,
WorkflowCopilotChatRequest, WorkflowCopilotChatRequest,
WorkflowCopilotClearProposedWorkflowRequest,
} from "./workflowCopilotTypes"; } from "./workflowCopilotTypes";
interface ChatMessage { interface ChatMessage {
@@ -41,7 +42,12 @@ const formatChatTimestamp = (value: string) => {
}); });
}; };
const MessageItem = memo(({ message }: { message: ChatMessage }) => { interface MessageItemProps {
message: ChatMessage;
footer?: React.ReactNode;
}
const MessageItem = memo(({ message, footer }: MessageItemProps) => {
return ( return (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
@@ -55,6 +61,7 @@ const MessageItem = memo(({ message }: { message: ChatMessage }) => {
<p className="whitespace-pre-wrap pr-3 text-sm text-slate-200"> <p className="whitespace-pre-wrap pr-3 text-sm text-slate-200">
{message.content} {message.content}
</p> </p>
{footer ? <div className="mt-3 flex gap-2">{footer}</div> : null}
{message.timestamp ? ( {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"> <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)} {formatChatTimestamp(message.timestamp)}
@@ -67,6 +74,10 @@ const MessageItem = memo(({ message }: { message: ChatMessage }) => {
interface WorkflowCopilotChatProps { interface WorkflowCopilotChatProps {
onWorkflowUpdate?: (workflow: WorkflowApiResponse) => void; onWorkflowUpdate?: (workflow: WorkflowApiResponse) => void;
onReviewWorkflow?: (
workflow: WorkflowApiResponse,
clearPending: () => void,
) => void;
isOpen?: boolean; isOpen?: boolean;
onClose?: () => void; onClose?: () => void;
onMessageCountChange?: (count: number) => void; onMessageCountChange?: (count: number) => void;
@@ -117,12 +128,16 @@ const constrainPosition = (
export function WorkflowCopilotChat({ export function WorkflowCopilotChat({
onWorkflowUpdate, onWorkflowUpdate,
onReviewWorkflow,
isOpen = true, isOpen = true,
onClose, onClose,
onMessageCountChange, onMessageCountChange,
buttonRef, buttonRef,
}: WorkflowCopilotChatProps = {}) { }: WorkflowCopilotChatProps = {}) {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [proposedWorkflow, setProposedWorkflow] =
useState<WorkflowApiResponse | null>(null);
const [autoAccept, setAutoAccept] = useState<boolean>(false);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [processingStatus, setProcessingStatus] = useState<string>(""); const [processingStatus, setProcessingStatus] = useState<string>("");
@@ -184,9 +199,74 @@ export function WorkflowCopilotChat({
const handleNewChat = () => { const handleNewChat = () => {
setMessages([]); setMessages([]);
setWorkflowCopilotChatId(null); setWorkflowCopilotChatId(null);
setProposedWorkflow(null);
setAutoAccept(false);
hasScrolledOnLoad.current = false; hasScrolledOnLoad.current = false;
}; };
const applyWorkflowUpdate = (workflow: WorkflowApiResponse): boolean => {
if (!onWorkflowUpdate) {
return true;
}
try {
onWorkflowUpdate(workflow);
return true;
} catch (updateError) {
console.error("Failed to update workflow:", updateError);
toast({
title: "Update failed",
description: "Failed to apply workflow changes. Please try again.",
variant: "destructive",
});
return false;
}
};
const handleAcceptWorkflow = (
workflow: WorkflowApiResponse,
alwaysAccept: boolean = false,
) => {
if (!applyWorkflowUpdate(workflow)) {
return;
}
setProposedWorkflow(null);
if (alwaysAccept) {
setAutoAccept(true);
}
void clearProposedWorkflow(alwaysAccept);
};
const handleRejectWorkflow = () => {
setProposedWorkflow(null);
void clearProposedWorkflow(false);
};
const clearProposedWorkflow = async (autoAcceptValue: boolean) => {
try {
const client = await getClient(credentialGetter, "sans-api-v1");
await client.post<WorkflowCopilotClearProposedWorkflowRequest>(
"/workflow/copilot/clear-proposed-workflow",
{
workflow_copilot_chat_id: workflowCopilotChatId ?? "",
auto_accept: autoAcceptValue,
} as WorkflowCopilotClearProposedWorkflowRequest,
);
} catch (error) {
console.error("Failed to clear proposed workflow:", error);
toast({
title: "Copilot update failed",
description: autoAcceptValue
? "Workflow was applied, but auto-accept did not update."
: "Failed to clear copilot proposal. Please try again.",
variant: "destructive",
});
}
};
const handleReviewWorkflow = (workflow: WorkflowApiResponse) => {
onReviewWorkflow?.(workflow, () => setProposedWorkflow(null));
};
// Notify parent of message count changes // Notify parent of message count changes
useEffect(() => { useEffect(() => {
if (onMessageCountChange) { if (onMessageCountChange) {
@@ -214,6 +294,8 @@ export function WorkflowCopilotChat({
if (!workflowPermanentId) { if (!workflowPermanentId) {
setMessages([]); setMessages([]);
setWorkflowCopilotChatId(null); setWorkflowCopilotChatId(null);
setProposedWorkflow(null);
setAutoAccept(false);
return; return;
} }
@@ -243,6 +325,8 @@ export function WorkflowCopilotChat({
); );
setMessages(historyMessages); setMessages(historyMessages);
setWorkflowCopilotChatId(response.data.workflow_copilot_chat_id); setWorkflowCopilotChatId(response.data.workflow_copilot_chat_id);
setProposedWorkflow(response.data.proposed_workflow ?? null);
setAutoAccept(response.data.auto_accept ?? false);
} catch (error) { } catch (error) {
console.error("Failed to load chat history:", error); console.error("Failed to load chat history:", error);
} finally { } finally {
@@ -306,6 +390,7 @@ export function WorkflowCopilotChat({
pendingMessageId.current = userMessageId; pendingMessageId.current = userMessageId;
setMessages((prev) => [...prev, userMessage]); setMessages((prev) => [...prev, userMessage]);
setProposedWorkflow(null);
const messageContent = inputValue; const messageContent = inputValue;
setInputValue(""); setInputValue("");
setIsLoading(true); setIsLoading(true);
@@ -398,10 +483,7 @@ export function WorkflowCopilotChat({
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
message.id === pendingId message.id === pendingId
? { ? { ...message, timestamp: payload.timestamp }
...message,
timestamp: payload.timestamp,
}
: message, : message,
), ),
); );
@@ -420,19 +502,10 @@ export function WorkflowCopilotChat({
}; };
setMessages((prev) => [...prev, aiMessage]); setMessages((prev) => [...prev, aiMessage]);
if (response.updated_workflow && autoAccept) {
if (response.updated_workflow && onWorkflowUpdate) { applyWorkflowUpdate(response.updated_workflow);
try { } else {
onWorkflowUpdate(response.updated_workflow); setProposedWorkflow(response.updated_workflow ?? null);
} catch (updateError) {
console.error("Failed to update workflow:", updateError);
toast({
title: "Update failed",
description:
"Failed to apply workflow changes. Please try again.",
variant: "destructive",
});
}
} }
}; };
@@ -708,9 +781,52 @@ export function WorkflowCopilotChat({
</p> </p>
</div> </div>
) : null} ) : null}
{messages.map((message) => ( {messages.map((message, index) => {
<MessageItem key={message.id} message={message} /> const isLastMessage = index === messages.length - 1;
))} const showProposedPanel = isLastMessage && proposedWorkflow;
return (
<MessageItem
key={message.id}
message={message}
footer={
showProposedPanel ? (
<>
<button
type="button"
onClick={() => handleReviewWorkflow(proposedWorkflow)}
className="rounded border border-blue-500/60 bg-blue-500/10 px-3 py-1 text-xs text-blue-100 hover:bg-blue-500/20"
>
Review
</button>
<button
type="button"
onClick={() => handleAcceptWorkflow(proposedWorkflow)}
className="rounded bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700"
>
Accept
</button>
<button
type="button"
onClick={() =>
handleAcceptWorkflow(proposedWorkflow, true)
}
className="rounded bg-emerald-600 px-3 py-1 text-xs text-white hover:bg-emerald-700"
>
Always accept
</button>
<button
type="button"
onClick={handleRejectWorkflow}
className="rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
>
Reject
</button>
</>
) : null
}
/>
);
})}
{isLoading && ( {isLoading && (
<div className="flex items-start gap-3"> <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"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">

View File

@@ -38,6 +38,13 @@ export interface WorkflowCopilotChatHistoryMessage {
export interface WorkflowCopilotChatHistoryResponse { export interface WorkflowCopilotChatHistoryResponse {
workflow_copilot_chat_id: string | null; workflow_copilot_chat_id: string | null;
chat_history: WorkflowCopilotChatHistoryMessage[]; chat_history: WorkflowCopilotChatHistoryMessage[];
proposed_workflow?: WorkflowApiResponse | null;
auto_accept?: boolean | null;
}
export interface WorkflowCopilotClearProposedWorkflowRequest {
workflow_copilot_chat_id: string;
auto_accept: boolean;
} }
export type WorkflowCopilotStreamMessageType = export type WorkflowCopilotStreamMessageType =

View File

@@ -94,7 +94,11 @@ import {
import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel"; import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery"; import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
import { WorkflowSettings } from "../types/workflowTypes"; import {
Parameter,
WorkflowBlock,
WorkflowSettings,
} from "../types/workflowTypes";
import { ProxyLocation } from "@/api/types"; import { ProxyLocation } from "@/api/types";
import { import {
nodeAdderNode, nodeAdderNode,
@@ -107,6 +111,7 @@ import {
import { constructCacheKeyValue, getInitialParameters } from "./utils"; import { constructCacheKeyValue, getInitialParameters } from "./utils";
import { WorkflowCopilotChat } from "../copilot/WorkflowCopilotChat"; import { WorkflowCopilotChat } from "../copilot/WorkflowCopilotChat";
import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton"; import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton";
import type { CopilotReviewStatus } from "./panels/WorkflowComparisonPanel";
import "./workspace-styles.css"; import "./workspace-styles.css";
const Constants = { const Constants = {
@@ -981,6 +986,40 @@ function Workspace({
} }
}; };
const applyWorkflowUpdate = (workflowData: WorkflowVersion) => {
const settings: WorkflowSettings = {
proxyLocation: workflowData.proxy_location ?? ProxyLocation.Residential,
webhookCallbackUrl: workflowData.webhook_callback_url || "",
persistBrowserSession: workflowData.persist_browser_session ?? false,
model: workflowData.model ?? null,
maxScreenshotScrolls: workflowData.max_screenshot_scrolls || 3,
extraHttpHeaders: workflowData.extra_http_headers
? JSON.stringify(workflowData.extra_http_headers)
: null,
runWith: workflowData.run_with ?? null,
scriptCacheKey: workflowData.cache_key ?? null,
aiFallback: workflowData.ai_fallback ?? true,
runSequentially: workflowData.run_sequentially ?? false,
sequentialKey: workflowData.sequential_key ?? null,
finallyBlockLabel:
workflowData.workflow_definition?.finally_block_label ?? null,
};
const elements = getElements(
workflowData.workflow_definition.blocks,
settings,
true,
);
setNodes(elements.nodes);
setEdges(elements.edges);
const initialParameters = getInitialParameters(workflowData);
useWorkflowParametersStore.getState().setParameters(initialParameters);
workflowChangesStore.setHasChanges(true);
};
const handleSelectState = (selectedVersion: WorkflowVersion) => { const handleSelectState = (selectedVersion: WorkflowVersion) => {
// Close panels // Close panels
setWorkflowPanelState({ setWorkflowPanelState({
@@ -1239,7 +1278,9 @@ function Workspace({
<div <div
className="absolute left-6 top-[6rem]" className="absolute left-6 top-[6rem]"
style={{ style={{
width: "calc(100% - 32rem)", width: workflowPanelState.active
? "calc(100% - 32rem)"
: "calc(100% - 3rem)",
height: "calc(100vh - 11rem)", height: "calc(100vh - 11rem)",
}} }}
> >
@@ -1248,6 +1289,10 @@ function Workspace({
version1={workflowPanelState.data.version1} version1={workflowPanelState.data.version1}
version2={workflowPanelState.data.version2} version2={workflowPanelState.data.version2}
onSelectState={handleSelectState} onSelectState={handleSelectState}
mode={workflowPanelState.data.mode}
onCopilotReviewClose={
workflowPanelState.data.onCopilotReviewClose
}
/> />
</div> </div>
@@ -1698,43 +1743,109 @@ function Workspace({
onClose={() => setIsCopilotOpen(false)} onClose={() => setIsCopilotOpen(false)}
onMessageCountChange={setCopilotMessageCount} onMessageCountChange={setCopilotMessageCount}
buttonRef={copilotButtonRef} buttonRef={copilotButtonRef}
onReviewWorkflow={(pendingWorkflow, clearPending) => {
const saveData = workflowChangesStore.getSaveData?.();
if (!saveData) return;
// Construct fake WorkflowVersion for current state
const currentVersion: WorkflowVersion = {
workflow_id: saveData.workflow.workflow_id,
organization_id: "",
is_saved_task: saveData.workflow.is_saved_task ?? false,
is_template: false,
title: "Current",
workflow_permanent_id: saveData.workflow.workflow_permanent_id,
version: saveData.workflow.version ?? 0,
description: saveData.workflow.description ?? "",
workflow_definition: {
parameters: saveData.parameters as unknown as Parameter[],
blocks: saveData.blocks as unknown as WorkflowBlock[],
finally_block_label: saveData.settings.finallyBlockLabel,
},
proxy_location: saveData.settings.proxyLocation,
webhook_callback_url: saveData.settings.webhookCallbackUrl,
extra_http_headers: saveData.settings.extraHttpHeaders
? JSON.parse(saveData.settings.extraHttpHeaders)
: null,
persist_browser_session: saveData.settings.persistBrowserSession,
model: saveData.settings.model,
totp_verification_url: saveData.workflow.totp_verification_url,
totp_identifier: null,
max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls,
status: saveData.workflow.status,
created_at: new Date().toISOString(),
modified_at: new Date().toISOString(),
deleted_at: null,
run_with: saveData.settings.runWith,
cache_key: saveData.settings.scriptCacheKey,
ai_fallback: saveData.settings.aiFallback,
run_sequentially: saveData.settings.runSequentially,
sequential_key: saveData.settings.sequentialKey,
folder_id: null,
import_error: null,
};
// Construct fake WorkflowVersion for pending copilot suggestion
const pendingVersion: WorkflowVersion = {
...pendingWorkflow,
title: "Copilot Suggestion",
};
// Handle copilot review close with status
const handleCopilotReviewClose = (status: CopilotReviewStatus) => {
if (status === "approve") {
try {
applyWorkflowUpdate(pendingWorkflow);
} catch (error) {
console.error(
"Failed to apply copilot workflow",
error,
pendingWorkflow,
);
toast({
title: "Update failed",
description:
"Failed to apply workflow update. Please try again.",
variant: "destructive",
});
}
}
// Close the panel and reopen copilot chat
setWorkflowPanelState({
active: false,
content: "history",
data: {
showComparison: false,
version1: undefined,
version2: undefined,
},
});
setIsCopilotOpen(true);
// Clear pending for approve and reject, but not for close
if (status !== "close") {
clearPending();
}
};
// Hide chat and show comparison
setIsCopilotOpen(false);
setWorkflowPanelState({
active: false,
content: "history",
data: {
version1: currentVersion,
version2: pendingVersion,
showComparison: true,
mode: "copilot",
onCopilotReviewClose: handleCopilotReviewClose,
},
});
}}
onWorkflowUpdate={(workflowData) => { onWorkflowUpdate={(workflowData) => {
try { try {
const settings: WorkflowSettings = { applyWorkflowUpdate(workflowData);
proxyLocation:
workflowData.proxy_location ?? ProxyLocation.Residential,
webhookCallbackUrl: workflowData.webhook_callback_url || "",
persistBrowserSession:
workflowData.persist_browser_session ?? false,
model: workflowData.model ?? null,
maxScreenshotScrolls: workflowData.max_screenshot_scrolls || 3,
extraHttpHeaders: workflowData.extra_http_headers
? JSON.stringify(workflowData.extra_http_headers)
: null,
runWith: workflowData.run_with ?? null,
scriptCacheKey: workflowData.cache_key ?? null,
aiFallback: workflowData.ai_fallback ?? true,
runSequentially: workflowData.run_sequentially ?? false,
sequentialKey: workflowData.sequential_key ?? null,
finallyBlockLabel:
workflowData.workflow_definition?.finally_block_label ?? null,
};
const elements = getElements(
workflowData.workflow_definition.blocks,
settings,
true,
);
setNodes(elements.nodes);
setEdges(elements.edges);
const initialParameters = getInitialParameters(workflowData);
useWorkflowParametersStore
.getState()
.setParameters(initialParameters);
workflowChangesStore.setHasChanges(true);
} catch (error) { } catch (error) {
console.error( console.error(
"Failed to parse and apply workflow", "Failed to parse and apply workflow",

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Cross2Icon } from "@radix-ui/react-icons";
import { import {
ReactFlowProvider, ReactFlowProvider,
useNodesState, useNodesState,
@@ -22,10 +23,16 @@ type BlockComparison = {
identifier: string; identifier: string;
}; };
type ComparisonMode = "history" | "copilot";
export type CopilotReviewStatus = "approve" | "reject" | "close";
type Props = { type Props = {
version1: WorkflowVersion; version1: WorkflowVersion;
version2: WorkflowVersion; version2: WorkflowVersion;
onSelectState?: (version: WorkflowVersion) => void; onSelectState?: (version: WorkflowVersion) => void;
mode?: ComparisonMode;
onCopilotReviewClose?: (status: CopilotReviewStatus) => void;
}; };
// Mapping from WorkflowBlock.block_type to ReactFlow node.type // Mapping from WorkflowBlock.block_type to ReactFlow node.type
@@ -255,7 +262,13 @@ function WorkflowComparisonRenderer({
); );
} }
function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { function WorkflowComparisonPanel({
version1,
version2,
onSelectState,
mode = "history",
onCopilotReviewClose,
}: Props) {
const comparisons = useMemo(() => { const comparisons = useMemo(() => {
const blocks1 = version1?.workflow_definition?.blocks || []; const blocks1 = version1?.workflow_definition?.blocks || [];
const blocks2 = version2?.workflow_definition?.blocks || []; const blocks2 = version2?.workflow_definition?.blocks || [];
@@ -265,6 +278,20 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
version2?.workflow_definition?.blocks, version2?.workflow_definition?.blocks,
]); ]);
// ESC key handler for copilot mode - close without rejecting
useEffect(() => {
if (mode !== "copilot" || !onCopilotReviewClose) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onCopilotReviewClose("close");
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, onCopilotReviewClose]);
// Statistics // Statistics
const stats = useMemo( const stats = useMemo(
() => ({ () => ({
@@ -321,7 +348,18 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
return ( return (
<div className="flex h-full w-full flex-col rounded-lg bg-slate-elevation2"> <div className="flex h-full w-full flex-col rounded-lg bg-slate-elevation2">
{/* Header */} {/* Header */}
<div className="flex-shrink-0 p-4 pb-3"> <div className="relative flex-shrink-0 p-4 pb-3">
{/* Close button for copilot mode */}
{mode === "copilot" && onCopilotReviewClose && (
<button
type="button"
onClick={() => onCopilotReviewClose("close")}
className="absolute right-4 top-4 rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
title="Close (Esc)"
>
<Cross2Icon className="h-5 w-5" />
</button>
)}
{/* 3x3 Grid Layout */} {/* 3x3 Grid Layout */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{/* Row 1: Workflow Names and Title */} {/* Row 1: Workflow Names and Title */}
@@ -329,17 +367,21 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
{version1.title} {version1.title}
</h2> </h2>
<h3 className="text-center text-lg font-medium text-muted-foreground"> <h3 className="text-center text-lg font-medium text-muted-foreground">
Version Comparison {mode === "copilot" ? "Copilot Review" : "Version Comparison"}
</h3> </h3>
<h2 className="text-center text-xl font-semibold"> <h2 className="text-center text-xl font-semibold">
{version2.title} {version2.title}
</h2> </h2>
{/* Row 2: Version Details and Statistics */} {/* Row 2: Version Details and Statistics */}
<div className="text-center text-sm text-muted-foreground"> {mode === "history" ? (
[Version {version1.version}] {" "} <div className="text-center text-sm text-muted-foreground">
{new Date(version1.modified_at).toLocaleDateString()} [Version {version1.version}] {" "}
</div> {new Date(version1.modified_at).toLocaleDateString()}
</div>
) : (
<div></div>
)}
<div className="flex justify-center gap-3 text-sm"> <div className="flex justify-center gap-3 text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-green-300"></div> <div className="h-3 w-3 rounded-full bg-green-300"></div>
@@ -358,14 +400,18 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
<span>Removed ({stats.removed})</span> <span>Removed ({stats.removed})</span>
</div> </div>
</div> </div>
<div className="text-center text-sm text-muted-foreground"> {mode === "history" ? (
[Version {version2.version}] {" "} <div className="text-center text-sm text-muted-foreground">
{new Date(version2.modified_at).toLocaleDateString()} [Version {version2.version}] {" "}
</div> {new Date(version2.modified_at).toLocaleDateString()}
</div>
) : (
<div></div>
)}
{/* Row 3: Select Buttons */} {/* Row 3: Action Buttons */}
<div className="flex justify-center"> <div className="flex justify-center">
{onSelectState && ( {mode === "history" && onSelectState && (
<Button <Button
size="sm" size="sm"
onClick={() => onSelectState(version1)} onClick={() => onSelectState(version1)}
@@ -375,9 +421,29 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
</Button> </Button>
)} )}
</div> </div>
<div></div> <div className="flex justify-center gap-2">
{mode === "copilot" && (
<>
<Button
size="sm"
variant="destructive"
onClick={() => onCopilotReviewClose?.("reject")}
className="text-xs"
>
Reject
</Button>
<Button
size="sm"
onClick={() => onCopilotReviewClose?.("approve")}
className="bg-green-600 text-xs hover:bg-green-700"
>
Accept
</Button>
</>
)}
</div>
<div className="flex justify-center"> <div className="flex justify-center">
{onSelectState && ( {mode === "history" && onSelectState && (
<Button <Button
size="sm" size="sm"
onClick={() => onSelectState(version2)} onClick={() => onSelectState(version2)}

View File

@@ -1,5 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery"; import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery";
import { CopilotReviewStatus } from "@/routes/workflows/editor/panels/WorkflowComparisonPanel";
export type BranchContext = { export type BranchContext = {
conditionalNodeId: string; conditionalNodeId: string;
@@ -27,6 +28,8 @@ type WorkflowPanelState = {
version1?: WorkflowVersion; version1?: WorkflowVersion;
version2?: WorkflowVersion; version2?: WorkflowVersion;
showComparison?: boolean; showComparison?: boolean;
mode?: "history" | "copilot";
onCopilotReviewClose?: (status: CopilotReviewStatus) => void;
}; };
}; };

View File

@@ -140,6 +140,7 @@ from skyvern.schemas.workflows import BlockStatus, BlockType, WorkflowStatus
from skyvern.webeye.actions.actions import Action from skyvern.webeye.actions.actions import Action
LOG = structlog.get_logger() LOG = structlog.get_logger()
_UNSET = object()
def _serialize_proxy_location(proxy_location: ProxyLocationInput) -> str | None: def _serialize_proxy_location(proxy_location: ProxyLocationInput) -> str | None:
@@ -3667,6 +3668,33 @@ class AgentDB(BaseAlchemyDB):
await session.refresh(new_chat) await session.refresh(new_chat)
return WorkflowCopilotChat.model_validate(new_chat) return WorkflowCopilotChat.model_validate(new_chat)
async def update_workflow_copilot_chat(
self,
organization_id: str,
workflow_copilot_chat_id: str,
proposed_workflow: dict | None | object = _UNSET,
auto_accept: bool | None = None,
) -> WorkflowCopilotChat | None:
async with self.Session() as session:
chat = (
await session.scalars(
select(WorkflowCopilotChatModel)
.where(WorkflowCopilotChatModel.organization_id == organization_id)
.where(WorkflowCopilotChatModel.workflow_copilot_chat_id == workflow_copilot_chat_id)
)
).first()
if not chat:
return None
if proposed_workflow is not _UNSET:
chat.proposed_workflow = proposed_workflow
if auto_accept is not None:
chat.auto_accept = auto_accept
await session.commit()
await session.refresh(chat)
return WorkflowCopilotChat.model_validate(chat)
async def create_workflow_copilot_chat_message( async def create_workflow_copilot_chat_message(
self, self,
organization_id: str, organization_id: str,

View File

@@ -1098,6 +1098,8 @@ class WorkflowCopilotChatModel(Base):
workflow_copilot_chat_id = Column(String, primary_key=True, default=generate_workflow_copilot_chat_id) workflow_copilot_chat_id = Column(String, primary_key=True, default=generate_workflow_copilot_chat_id)
organization_id = Column(String, nullable=False) organization_id = Column(String, nullable=False)
workflow_permanent_id = Column(String, nullable=False, index=True) workflow_permanent_id = Column(String, nullable=False, index=True)
proposed_workflow = Column(JSON, nullable=True)
auto_accept = Column(Boolean, nullable=True, default=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
modified_at = Column( modified_at = Column(

View File

@@ -26,6 +26,7 @@ from skyvern.forge.sdk.schemas.workflow_copilot import (
WorkflowCopilotChatMessage, WorkflowCopilotChatMessage,
WorkflowCopilotChatRequest, WorkflowCopilotChatRequest,
WorkflowCopilotChatSender, WorkflowCopilotChatSender,
WorkflowCopilotClearProposedWorkflowRequest,
WorkflowCopilotProcessingUpdate, WorkflowCopilotProcessingUpdate,
WorkflowCopilotStreamErrorUpdate, WorkflowCopilotStreamErrorUpdate,
WorkflowCopilotStreamMessageType, WorkflowCopilotStreamMessageType,
@@ -448,6 +449,13 @@ async def workflow_copilot_chat_post(
) )
return return
if updated_workflow and chat.auto_accept is not True:
await app.DATABASE.update_workflow_copilot_chat(
organization_id=chat.organization_id,
workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
proposed_workflow=updated_workflow.model_dump(mode="json"),
)
await app.DATABASE.create_workflow_copilot_chat_message( await app.DATABASE.create_workflow_copilot_chat_message(
organization_id=chat.organization_id, organization_id=chat.organization_id,
workflow_copilot_chat_id=chat.workflow_copilot_chat_id, workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
@@ -518,17 +526,35 @@ async def workflow_copilot_chat_history(
organization_id=organization.organization_id, organization_id=organization.organization_id,
workflow_permanent_id=workflow_permanent_id, workflow_permanent_id=workflow_permanent_id,
) )
if not latest_chat: if latest_chat:
return WorkflowCopilotChatHistoryResponse(workflow_copilot_chat_id=None, chat_history=[]) chat_messages = await app.DATABASE.get_workflow_copilot_chat_messages(latest_chat.workflow_copilot_chat_id)
chat_messages = await app.DATABASE.get_workflow_copilot_chat_messages( else:
workflow_copilot_chat_id=latest_chat.workflow_copilot_chat_id, chat_messages = []
)
return WorkflowCopilotChatHistoryResponse( return WorkflowCopilotChatHistoryResponse(
workflow_copilot_chat_id=latest_chat.workflow_copilot_chat_id, workflow_copilot_chat_id=latest_chat.workflow_copilot_chat_id if latest_chat else None,
chat_history=convert_to_history_messages(chat_messages), chat_history=convert_to_history_messages(chat_messages),
proposed_workflow=latest_chat.proposed_workflow if latest_chat else None,
auto_accept=latest_chat.auto_accept if latest_chat else None,
) )
@base_router.post(
"/workflow/copilot/clear-proposed-workflow", include_in_schema=False, status_code=status.HTTP_204_NO_CONTENT
)
async def workflow_copilot_clear_proposed_workflow(
clear_request: WorkflowCopilotClearProposedWorkflowRequest,
organization: Organization = Depends(org_auth_service.get_current_org),
) -> None:
updated_chat = await app.DATABASE.update_workflow_copilot_chat(
organization_id=organization.organization_id,
workflow_copilot_chat_id=clear_request.workflow_copilot_chat_id,
proposed_workflow=None,
auto_accept=clear_request.auto_accept,
)
if not updated_chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
def convert_to_history_messages( def convert_to_history_messages(
messages: list[WorkflowCopilotChatMessage], messages: list[WorkflowCopilotChatMessage],
) -> list[WorkflowCopilotChatHistoryMessage]: ) -> list[WorkflowCopilotChatHistoryMessage]:

View File

@@ -10,6 +10,8 @@ class WorkflowCopilotChat(BaseModel):
workflow_copilot_chat_id: str = Field(..., description="ID for the workflow copilot chat") workflow_copilot_chat_id: str = Field(..., description="ID for the workflow copilot chat")
organization_id: str = Field(..., description="Organization ID for the chat") organization_id: str = Field(..., description="Organization ID for the chat")
workflow_permanent_id: str = Field(..., description="Workflow permanent ID for the chat") workflow_permanent_id: str = Field(..., description="Workflow permanent ID for the chat")
proposed_workflow: dict | None = Field(None, description="Latest workflow proposed by the copilot")
auto_accept: bool | None = Field(False, description="Whether copilot auto-accepts workflow updates")
created_at: datetime = Field(..., description="When the chat was created") created_at: datetime = Field(..., description="When the chat was created")
modified_at: datetime = Field(..., description="When the chat was last modified") modified_at: datetime = Field(..., description="When the chat was last modified")
@@ -40,6 +42,11 @@ class WorkflowCopilotChatRequest(BaseModel):
workflow_yaml: str = Field(..., description="Current workflow YAML including unsaved changes") workflow_yaml: str = Field(..., description="Current workflow YAML including unsaved changes")
class WorkflowCopilotClearProposedWorkflowRequest(BaseModel):
workflow_copilot_chat_id: str = Field(..., description="The chat ID to update")
auto_accept: bool = Field(..., description="Whether to auto-accept future workflow updates")
class WorkflowCopilotChatHistoryMessage(BaseModel): class WorkflowCopilotChatHistoryMessage(BaseModel):
sender: WorkflowCopilotChatSender = Field(..., description="Message sender") sender: WorkflowCopilotChatSender = Field(..., description="Message sender")
content: str = Field(..., description="Message content") content: str = Field(..., description="Message content")
@@ -49,6 +56,8 @@ class WorkflowCopilotChatHistoryMessage(BaseModel):
class WorkflowCopilotChatHistoryResponse(BaseModel): class WorkflowCopilotChatHistoryResponse(BaseModel):
workflow_copilot_chat_id: str | None = Field(None, description="Latest chat ID for the workflow") workflow_copilot_chat_id: str | None = Field(None, description="Latest chat ID for the workflow")
chat_history: list[WorkflowCopilotChatHistoryMessage] = Field(default_factory=list, description="Chat messages") chat_history: list[WorkflowCopilotChatHistoryMessage] = Field(default_factory=list, description="Chat messages")
proposed_workflow: dict | None = Field(None, description="Latest workflow proposed by the copilot")
auto_accept: bool | None = Field(None, description="Whether copilot auto-accepts workflow updates")
class WorkflowCopilotStreamMessageType(StrEnum): class WorkflowCopilotStreamMessageType(StrEnum):