Workflow Copilot: server update streaming with "cancel" ability (#4456)

This commit is contained in:
Stanislav Novosad
2026-01-14 18:34:09 -07:00
committed by GitHub
parent 6b9ea59e67
commit 9cf1f87514
7 changed files with 462 additions and 170 deletions

View File

@@ -2,19 +2,65 @@ import { fetchEventSource } from "@microsoft/fetch-event-source";
import type { CredentialGetter } from "@/api/AxiosClient"; import type { CredentialGetter } from "@/api/AxiosClient";
import { getRuntimeApiKey, runsApiBaseUrl } from "@/util/env"; import { getRuntimeApiKey, runsApiBaseUrl } from "@/util/env";
export type SseJsonPayload = Record<string, unknown>; export type SseMessageHandler<T> = (payload: T, eventName: string) => boolean;
type SseClient = { type SseStreamingOptions = {
post: <T extends SseJsonPayload>(path: string, body: unknown) => Promise<T>; signal?: AbortSignal;
}; };
export async function fetchJsonSse<T extends SseJsonPayload>( type SseClient = {
postStreaming: <T>(
path: string,
body: unknown,
onMessage: SseMessageHandler<T>,
options?: SseStreamingOptions,
) => Promise<void>;
};
export async function fetchStreamingSse<T>(
input: RequestInfo | URL, input: RequestInfo | URL,
init: RequestInit, init: RequestInit,
): Promise<T> { onMessage: SseMessageHandler<T>,
options?: SseStreamingOptions,
): Promise<void> {
const controller = new AbortController(); const controller = new AbortController();
const externalSignal = options?.signal;
let settled = false;
const resolveOnce = () => {
if (!settled) {
settled = true;
return true;
}
return false;
};
const onExternalAbort = () => {
controller.abort();
};
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
return;
}
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
}
try { try {
const parsedPayload = await new Promise<T>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const safeResolve = () => {
if (resolveOnce()) {
resolve();
}
};
const safeReject = (error: unknown) => {
if (controller.signal.aborted) {
safeResolve();
return;
}
if (!settled) {
settled = true;
reject(error);
}
};
fetchEventSource(input instanceof URL ? input.toString() : input, { fetchEventSource(input instanceof URL ? input.toString() : input, {
method: init.method, method: init.method,
headers: init.headers as Record<string, string>, headers: init.headers as Record<string, string>,
@@ -26,25 +72,28 @@ export async function fetchJsonSse<T extends SseJsonPayload>(
} }
try { try {
const payload = JSON.parse(event.data) as T; const payload = JSON.parse(event.data) as T;
resolve(payload); if (onMessage(payload, event.event)) {
safeResolve();
}
} catch (error) { } catch (error) {
reject(error); safeReject(error);
} }
}, },
onerror: (error) => { onerror: (error) => {
reject(error); safeReject(error);
}, },
onopen: async (response) => { onopen: async (response) => {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
reject(new Error(errorText || "Failed to send request.")); safeReject(new Error(errorText || "Failed to send request."));
} }
}, },
}).catch(reject); }).catch(safeReject);
}); });
return parsedPayload;
} finally { } finally {
if (externalSignal) {
externalSignal.removeEventListener("abort", onExternalAbort);
}
controller.abort(); controller.abort();
} }
} }
@@ -73,14 +122,21 @@ export async function getSseClient(
} }
return { return {
post: <T extends SseJsonPayload>(path: string, body: unknown) => { postStreaming: <T>(
return fetchJsonSse<T>( path: string,
body: unknown,
onMessage: SseMessageHandler<T>,
options?: SseStreamingOptions,
) => {
return fetchStreamingSse<T>(
`${runsApiBaseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`, `${runsApiBaseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`,
{ {
method: "POST", method: "POST",
headers: requestHeaders, headers: requestHeaders,
body: JSON.stringify(body), body: JSON.stringify(body),
}, },
onMessage,
options,
); );
}, },
}; };

View File

@@ -9,14 +9,26 @@ import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes"; import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { getSseClient } from "@/api/sse"; import { getSseClient } from "@/api/sse";
import {
WorkflowCopilotChatHistoryResponse,
WorkflowCopilotProcessingUpdate,
WorkflowCopilotStreamError,
WorkflowCopilotStreamResponse,
WorkflowCopilotChatSender,
} from "./workflowCopilotTypes";
interface ChatMessage { interface ChatMessage {
id: string; id: string;
sender: "ai" | "user"; sender: WorkflowCopilotChatSender;
content: string; content: string;
timestamp?: string; timestamp?: string;
} }
type WorkflowCopilotSsePayload =
| WorkflowCopilotProcessingUpdate
| WorkflowCopilotStreamResponse
| WorkflowCopilotStreamError;
const formatChatTimestamp = (value: string) => { const formatChatTimestamp = (value: string) => {
let normalizedValue = value.replace(/\.(\d{3})\d*/, ".$1"); let normalizedValue = value.replace(/\.(\d{3})\d*/, ".$1");
if (!normalizedValue.endsWith("Z")) { if (!normalizedValue.endsWith("Z")) {
@@ -39,7 +51,9 @@ const MessageItem = memo(({ message }: { message: ChatMessage }) => {
{message.sender === "ai" ? "AI" : "U"} {message.sender === "ai" ? "AI" : "U"}
</div> </div>
<div className="relative flex-1 rounded-lg bg-slate-800 p-3 pr-12"> <div className="relative flex-1 rounded-lg bg-slate-800 p-3 pr-12">
<p className="text-sm text-slate-200">{message.content}</p> <p className="whitespace-pre-wrap pr-3 text-sm text-slate-200">
{message.content}
</p>
{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)}
@@ -111,7 +125,10 @@ export function WorkflowCopilotChat({
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [processingStatus, setProcessingStatus] = useState<string>("");
const [isLoadingHistory, setIsLoadingHistory] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const streamingAbortController = useRef<AbortController | null>(null);
const pendingMessageId = useRef<string | null>(null);
const [workflowCopilotChatId, setWorkflowCopilotChatId] = useState< const [workflowCopilotChatId, setWorkflowCopilotChatId] = useState<
string | null string | null
>(null); >(null);
@@ -143,6 +160,7 @@ export function WorkflowCopilotChat({
const credentialGetter = useCredentialGetter(); const credentialGetter = useCredentialGetter();
const { workflowRunId, workflowPermanentId } = useParams(); const { workflowRunId, workflowPermanentId } = useParams();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { getSaveData } = useWorkflowHasChangesStore(); const { getSaveData } = useWorkflowHasChangesStore();
const hasInitializedPosition = useRef(false); const hasInitializedPosition = useRef(false);
const hasScrolledOnLoad = useRef(false); const hasScrolledOnLoad = useRef(false);
@@ -151,6 +169,18 @@ export function WorkflowCopilotChat({
messagesEndRef.current?.scrollIntoView({ behavior }); messagesEndRef.current?.scrollIntoView({ behavior });
}; };
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`;
};
useEffect(() => {
adjustTextareaHeight();
}, [inputValue]);
const handleNewChat = () => { const handleNewChat = () => {
setMessages([]); setMessages([]);
setWorkflowCopilotChatId(null); setWorkflowCopilotChatId(null);
@@ -194,16 +224,12 @@ export function WorkflowCopilotChat({
hasScrolledOnLoad.current = false; hasScrolledOnLoad.current = false;
try { try {
const client = await getClient(credentialGetter, "sans-api-v1"); const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.get<{ const response = await client.get<WorkflowCopilotChatHistoryResponse>(
workflow_copilot_chat_id: string | null; "/workflow/copilot/chat-history",
chat_history: Array<{ {
sender: "ai" | "user"; params: { workflow_permanent_id: workflowPermanentId },
content: string; },
created_at: string; );
}>;
}>("/workflow/copilot/chat-history", {
params: { workflow_permanent_id: workflowPermanentId },
});
if (!isMounted) return; if (!isMounted) return;
@@ -233,6 +259,33 @@ export function WorkflowCopilotChat({
}; };
}, [credentialGetter, workflowPermanentId]); }, [credentialGetter, workflowPermanentId]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !isOpen || !isLoading) {
return;
}
cancelSend();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isLoading, isOpen]);
const cancelSend = async () => {
if (!streamingAbortController.current) return;
if (pendingMessageId.current) {
const messageId = pendingMessageId.current;
pendingMessageId.current = null;
setMessages((prev) => prev.filter((message) => message.id !== messageId));
}
setIsLoading(false);
setProcessingStatus("");
streamingAbortController.current?.abort();
};
const handleSend = async () => { const handleSend = async () => {
if (!inputValue.trim() || isLoading) return; if (!inputValue.trim() || isLoading) return;
if (!workflowPermanentId) { if (!workflowPermanentId) {
@@ -251,10 +304,16 @@ export function WorkflowCopilotChat({
content: inputValue, content: inputValue,
}; };
pendingMessageId.current = userMessageId;
setMessages((prev) => [...prev, userMessage]); setMessages((prev) => [...prev, userMessage]);
const messageContent = inputValue; const messageContent = inputValue;
setInputValue(""); setInputValue("");
setIsLoading(true); setIsLoading(true);
setProcessingStatus("Starting...");
const abortController = new AbortController();
streamingAbortController.current?.abort();
streamingAbortController.current = abortController;
try { try {
const saveData = getSaveData(); const saveData = getSaveData();
@@ -314,69 +373,97 @@ export function WorkflowCopilotChat({
workflowYaml = convertToYAML(requestBody); workflowYaml = convertToYAML(requestBody);
} }
const client = await getSseClient(credentialGetter); const handleProcessingUpdate = (
const response = await client.post<{ payload: WorkflowCopilotProcessingUpdate,
workflow_copilot_chat_id?: string; ) => {
message?: string; if (payload.status) {
updated_workflow_yaml?: string | null; setProcessingStatus(payload.status);
request_time?: string; }
response_time?: string;
error?: string;
}>("/workflow/copilot/chat-post", {
workflow_permanent_id: workflowPermanentId,
workflow_copilot_chat_id: workflowCopilotChatId,
workflow_run_id: workflowRunId,
message: messageContent,
workflow_yaml: workflowYaml,
});
if (response.error) { const pendingId = pendingMessageId.current;
throw new Error(response.error); if (!pendingId || !payload.timestamp) {
} return;
}
if ( setMessages((prev) =>
!response.workflow_copilot_chat_id || prev.map((message) =>
!response.message || message.id === pendingId
!response.request_time || ? {
!response.response_time ...message,
) { timestamp: payload.timestamp,
throw new Error("No response received."); }
} : message,
),
setWorkflowCopilotChatId(response.workflow_copilot_chat_id); );
const aiMessage: ChatMessage = {
id: Date.now().toString(),
sender: "ai",
content: response.message || "I received your message.",
timestamp: response.response_time,
}; };
setMessages((prev) => [ const handleResponse = (response: WorkflowCopilotStreamResponse) => {
...prev.map((message) => setWorkflowCopilotChatId(response.workflow_copilot_chat_id);
message.id === userMessageId
? {
...message,
timestamp: response.request_time,
}
: message,
),
aiMessage,
]);
if (response.updated_workflow_yaml && onWorkflowUpdate) { const aiMessage: ChatMessage = {
try { id: Date.now().toString(),
onWorkflowUpdate(response.updated_workflow_yaml); sender: "ai",
} catch (updateError) { content: response.message,
console.error("Failed to update workflow:", updateError); timestamp: response.response_time,
toast({ };
title: "Update failed",
description: "Failed to apply workflow changes. Please try again.", setMessages((prev) => [...prev, aiMessage]);
variant: "destructive",
}); if (response.updated_workflow_yaml && onWorkflowUpdate) {
try {
onWorkflowUpdate(response.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",
});
}
} }
} };
const handleError = (payload: WorkflowCopilotStreamError) => {
const errorMessage: ChatMessage = {
id: Date.now().toString(),
sender: "ai",
content: payload.error,
};
setMessages((prev) => [...prev, errorMessage]);
};
const client = await getSseClient(credentialGetter);
await client.postStreaming<WorkflowCopilotSsePayload>(
"/workflow/copilot/chat-post",
{
workflow_permanent_id: workflowPermanentId,
workflow_copilot_chat_id: workflowCopilotChatId,
workflow_run_id: workflowRunId,
message: messageContent,
workflow_yaml: workflowYaml,
},
(payload) => {
switch (payload.type) {
case "processing_update":
handleProcessingUpdate(payload);
return false;
case "response":
handleResponse(payload);
return true;
case "error":
handleError(payload);
return true;
default:
return false;
}
},
{ signal: abortController.signal },
);
} catch (error) { } catch (error) {
if (abortController.signal.aborted) {
return;
}
console.error("Failed to send message:", error); console.error("Failed to send message:", error);
const errorMessage: ChatMessage = { const errorMessage: ChatMessage = {
id: Date.now().toString(), id: Date.now().toString(),
@@ -385,12 +472,18 @@ export function WorkflowCopilotChat({
}; };
setMessages((prev) => [...prev, errorMessage]); setMessages((prev) => [...prev, errorMessage]);
} finally { } finally {
if (streamingAbortController.current === abortController) {
streamingAbortController.current = null;
}
pendingMessageId.current = null;
setIsLoading(false); setIsLoading(false);
setProcessingStatus("");
} }
}; };
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter") { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend(); handleSend();
} }
}; };
@@ -614,7 +707,7 @@ export function WorkflowCopilotChat({
<div className="flex-1 rounded-lg bg-slate-800 p-3"> <div className="flex-1 rounded-lg bg-slate-800 p-3">
<div className="flex items-center gap-2 text-sm text-slate-400"> <div className="flex items-center gap-2 text-sm text-slate-400">
<ReloadIcon className="h-4 w-4 animate-spin" /> <ReloadIcon className="h-4 w-4 animate-spin" />
<span>Processing...</span> <span>{processingStatus || "Processing..."}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -625,15 +718,21 @@ export function WorkflowCopilotChat({
{/* Input */} {/* Input */}
<div className="border-t border-slate-700 p-3"> <div className="border-t border-slate-700 p-3">
<div className="flex gap-2"> <div className="flex items-end gap-2">
<input <textarea
type="text" ref={textareaRef}
placeholder="Type your message..." placeholder="Type your message... (Shift+Enter for new line)"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress} onKeyDown={handleKeyPress}
disabled={isLoading} 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" rows={1}
className="flex-1 resize-none 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"
style={{
minHeight: "38px",
maxHeight: "150px",
overflow: "auto",
}}
/> />
<button <button
onClick={handleSend} onClick={handleSend}

View File

@@ -0,0 +1,62 @@
export type WorkflowCopilotChatSender = "user" | "ai";
export interface WorkflowCopilotChat {
workflow_copilot_chat_id: string;
organization_id: string;
workflow_permanent_id: string;
created_at: string;
modified_at: string;
}
export interface WorkflowCopilotChatMessage {
workflow_copilot_chat_message_id: string;
workflow_copilot_chat_id: string;
sender: WorkflowCopilotChatSender;
content: string;
global_llm_context: string | null;
created_at: string;
modified_at: string;
}
export interface WorkflowCopilotChatRequest {
workflow_permanent_id: string;
workflow_copilot_chat_id?: string | null;
workflow_run_id?: string | null;
message: string;
workflow_yaml: string;
}
export interface WorkflowCopilotChatHistoryMessage {
sender: WorkflowCopilotChatSender;
content: string;
created_at: string;
}
export interface WorkflowCopilotChatHistoryResponse {
workflow_copilot_chat_id: string | null;
chat_history: WorkflowCopilotChatHistoryMessage[];
}
export type WorkflowCopilotStreamMessageType =
| "processing_update"
| "response"
| "error";
export interface WorkflowCopilotProcessingUpdate {
type: "processing_update";
status: string;
timestamp: string;
}
export interface WorkflowCopilotStreamResponse {
type: "response";
workflow_copilot_chat_id: string;
message: string;
updated_workflow_yaml?: string | null;
response_time: string;
}
export interface WorkflowCopilotStreamError {
type: "error";
error: string;
}

View File

@@ -103,8 +103,8 @@ import {
upgradeWorkflowBlocksV1toV2, upgradeWorkflowBlocksV1toV2,
} from "./workflowEditorUtils"; } from "./workflowEditorUtils";
import { constructCacheKeyValue, getInitialParameters } from "./utils"; import { constructCacheKeyValue, getInitialParameters } from "./utils";
import { WorkflowCopilotChat } from "./WorkflowCopilotChat"; import { WorkflowCopilotChat } from "../copilot/WorkflowCopilotChat";
import { WorkflowCopilotButton } from "./WorkflowCopilotButton"; import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton";
import { parse as parseYAML } from "yaml"; import { parse as parseYAML } from "yaml";
import { import {
BlockYAML, BlockYAML,

View File

@@ -6,7 +6,7 @@ from typing import Any, AsyncGenerator
import structlog import structlog
import yaml import yaml
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from sse_starlette import EventSourceResponse, JSONServerSentEvent, ServerSentEvent from sse_starlette import EventSourceResponse, JSONServerSentEvent, ServerSentEvent
from skyvern.forge import app from skyvern.forge import app
@@ -22,8 +22,11 @@ from skyvern.forge.sdk.schemas.workflow_copilot import (
WorkflowCopilotChatHistoryResponse, WorkflowCopilotChatHistoryResponse,
WorkflowCopilotChatMessage, WorkflowCopilotChatMessage,
WorkflowCopilotChatRequest, WorkflowCopilotChatRequest,
WorkflowCopilotChatResponse,
WorkflowCopilotChatSender, WorkflowCopilotChatSender,
WorkflowCopilotProcessingUpdate,
WorkflowCopilotStreamErrorUpdate,
WorkflowCopilotStreamMessageType,
WorkflowCopilotStreamResponseUpdate,
) )
from skyvern.forge.sdk.services import org_auth_service from skyvern.forge.sdk.services import org_auth_service
from skyvern.schemas.workflows import LoginBlockYAML, WorkflowCreateYAMLRequest from skyvern.schemas.workflows import LoginBlockYAML, WorkflowCreateYAMLRequest
@@ -236,66 +239,81 @@ async def _process_workflow_yaml(action_data: dict[str, Any]) -> None | str:
@base_router.post("/workflow/copilot/chat-post", include_in_schema=False) @base_router.post("/workflow/copilot/chat-post", include_in_schema=False)
async def workflow_copilot_chat_post( async def workflow_copilot_chat_post(
request: Request,
chat_request: WorkflowCopilotChatRequest, chat_request: WorkflowCopilotChatRequest,
organization: Organization = Depends(org_auth_service.get_current_org), organization: Organization = Depends(org_auth_service.get_current_org),
) -> EventSourceResponse: ) -> EventSourceResponse:
LOG.info(
"Workflow copilot chat request",
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
workflow_run_id=chat_request.workflow_run_id,
message=chat_request.message,
workflow_yaml_length=len(chat_request.workflow_yaml),
organization_id=organization.organization_id,
)
request_started_at = datetime.now(timezone.utc)
if chat_request.workflow_copilot_chat_id:
chat = await app.DATABASE.get_workflow_copilot_chat_by_id(
organization_id=organization.organization_id,
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat_request.workflow_permanent_id != chat.workflow_permanent_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Wrong workflow permanent ID")
else:
chat = await app.DATABASE.create_workflow_copilot_chat(
organization_id=organization.organization_id,
workflow_permanent_id=chat_request.workflow_permanent_id,
)
chat_messages = await app.DATABASE.get_workflow_copilot_chat_messages(
workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
)
global_llm_context = None
for message in reversed(chat_messages):
if message.global_llm_context is not None:
global_llm_context = message.global_llm_context
break
debug_run_info = await _get_debug_run_info(organization.organization_id, chat_request.workflow_run_id)
# Format debug run info for prompt
debug_run_info_text = ""
if debug_run_info:
debug_run_info_text = f"Block Label: {debug_run_info.block_label}"
debug_run_info_text += f" Block Type: {debug_run_info.block_type}"
debug_run_info_text += f" Status: {debug_run_info.block_status}"
if debug_run_info.failure_reason:
debug_run_info_text += f"\nFailure Reason: {debug_run_info.failure_reason}"
if debug_run_info.html:
debug_run_info_text += f"\n\nVisible Elements Tree (HTML):\n{debug_run_info.html}"
await app.DATABASE.create_workflow_copilot_chat_message(
organization_id=chat.organization_id,
workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
sender=WorkflowCopilotChatSender.USER,
content=chat_request.message,
)
async def event_stream() -> AsyncGenerator[JSONServerSentEvent, None]: async def event_stream() -> AsyncGenerator[JSONServerSentEvent, None]:
LOG.info(
"Workflow copilot chat request",
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
workflow_run_id=chat_request.workflow_run_id,
message=chat_request.message,
workflow_yaml_length=len(chat_request.workflow_yaml),
organization_id=organization.organization_id,
)
try: try:
yield JSONServerSentEvent(
data=WorkflowCopilotProcessingUpdate(
type=WorkflowCopilotStreamMessageType.PROCESSING_UPDATE,
status="Processing...",
timestamp=datetime.now(timezone.utc),
).model_dump(mode="json"),
)
if chat_request.workflow_copilot_chat_id:
chat = await app.DATABASE.get_workflow_copilot_chat_by_id(
organization_id=organization.organization_id,
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat_request.workflow_permanent_id != chat.workflow_permanent_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Wrong workflow permanent ID")
else:
chat = await app.DATABASE.create_workflow_copilot_chat(
organization_id=organization.organization_id,
workflow_permanent_id=chat_request.workflow_permanent_id,
)
chat_messages = await app.DATABASE.get_workflow_copilot_chat_messages(
workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
)
global_llm_context = None
for message in reversed(chat_messages):
if message.global_llm_context is not None:
global_llm_context = message.global_llm_context
break
debug_run_info = await _get_debug_run_info(organization.organization_id, chat_request.workflow_run_id)
# Format debug run info for prompt
debug_run_info_text = ""
if debug_run_info:
debug_run_info_text = f"Block Label: {debug_run_info.block_label}"
debug_run_info_text += f" Block Type: {debug_run_info.block_type}"
debug_run_info_text += f" Status: {debug_run_info.block_status}"
if debug_run_info.failure_reason:
debug_run_info_text += f"\nFailure Reason: {debug_run_info.failure_reason}"
if debug_run_info.html:
debug_run_info_text += f"\n\nVisible Elements Tree (HTML):\n{debug_run_info.html}"
yield JSONServerSentEvent(
data=WorkflowCopilotProcessingUpdate(
type=WorkflowCopilotStreamMessageType.PROCESSING_UPDATE,
status="Thinking...",
timestamp=datetime.now(timezone.utc),
).model_dump(mode="json"),
)
if await request.is_disconnected():
LOG.info(
"Workflow copilot chat request is disconnected before LLM call",
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
)
return
user_response, updated_workflow_yaml, updated_global_llm_context = await copilot_call_llm( user_response, updated_workflow_yaml, updated_global_llm_context = await copilot_call_llm(
organization.organization_id, organization.organization_id,
chat_request, chat_request,
@@ -303,6 +321,21 @@ async def workflow_copilot_chat_post(
global_llm_context, global_llm_context,
debug_run_info_text, debug_run_info_text,
) )
if await request.is_disconnected():
LOG.info(
"Workflow copilot chat request is disconnected after LLM call",
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
)
return
await app.DATABASE.create_workflow_copilot_chat_message(
organization_id=chat.organization_id,
workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
sender=WorkflowCopilotChatSender.USER,
content=chat_request.message,
)
assistant_message = await app.DATABASE.create_workflow_copilot_chat_message( assistant_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,
@@ -311,32 +344,53 @@ async def workflow_copilot_chat_post(
global_llm_context=updated_global_llm_context, global_llm_context=updated_global_llm_context,
) )
response_payload = WorkflowCopilotChatResponse( yield JSONServerSentEvent(
workflow_copilot_chat_id=chat.workflow_copilot_chat_id, data=WorkflowCopilotStreamResponseUpdate(
message=user_response, type=WorkflowCopilotStreamMessageType.RESPONSE,
updated_workflow_yaml=updated_workflow_yaml, workflow_copilot_chat_id=chat.workflow_copilot_chat_id,
request_time=request_started_at, message=user_response,
response_time=assistant_message.created_at, updated_workflow_yaml=updated_workflow_yaml,
).model_dump(mode="json") response_time=assistant_message.created_at,
yield JSONServerSentEvent(response_payload) ).model_dump(mode="json"),
)
except HTTPException as exc: except HTTPException as exc:
yield JSONServerSentEvent({"error": exc.detail}) if await request.is_disconnected():
return
yield JSONServerSentEvent(
data=WorkflowCopilotStreamErrorUpdate(
type=WorkflowCopilotStreamMessageType.ERROR,
error=exc.detail,
).model_dump(mode="json"),
)
except LLMProviderError as exc: except LLMProviderError as exc:
if await request.is_disconnected():
return
LOG.error( LOG.error(
"LLM provider error", "LLM provider error",
organization_id=organization.organization_id, organization_id=organization.organization_id,
error=str(exc), error=str(exc),
exc_info=True, exc_info=True,
) )
yield JSONServerSentEvent({"error": "Failed to process your request. Please try again."}) yield JSONServerSentEvent(
data=WorkflowCopilotStreamErrorUpdate(
type=WorkflowCopilotStreamMessageType.ERROR,
error="Failed to process your request. Please try again.",
).model_dump(mode="json"),
)
except Exception as exc: except Exception as exc:
if await request.is_disconnected():
return
LOG.error( LOG.error(
"Unexpected error in workflow copilot", "Unexpected error in workflow copilot",
organization_id=organization.organization_id, organization_id=organization.organization_id,
error=str(exc), error=str(exc),
exc_info=True, exc_info=True,
) )
yield JSONServerSentEvent({"error": "An error occurred. Please try again."}) yield JSONServerSentEvent(
data=WorkflowCopilotStreamErrorUpdate(
type=WorkflowCopilotStreamMessageType.ERROR, error="An error occurred. Please try again."
).model_dump(mode="json"),
)
def ping_message_factory() -> ServerSentEvent: def ping_message_factory() -> ServerSentEvent:
return ServerSentEvent(comment="keep-alive") return ServerSentEvent(comment="keep-alive")

View File

@@ -39,14 +39,6 @@ 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 WorkflowCopilotChatResponse(BaseModel):
workflow_copilot_chat_id: str = Field(..., description="The chat ID")
message: str = Field(..., description="The message sent to the user")
updated_workflow_yaml: str | None = Field(None, description="The updated workflow yaml")
request_time: datetime = Field(..., description="When the request was received")
response_time: datetime = Field(..., description="When the assistant message was created")
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")
@@ -56,3 +48,32 @@ 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")
class WorkflowCopilotStreamMessageType(StrEnum):
PROCESSING_UPDATE = "processing_update"
RESPONSE = "response"
ERROR = "error"
class WorkflowCopilotProcessingUpdate(BaseModel):
type: WorkflowCopilotStreamMessageType = Field(
WorkflowCopilotStreamMessageType.PROCESSING_UPDATE, description="Message type"
)
status: str = Field(..., description="Processing status text")
timestamp: datetime = Field(..., description="Server timestamp")
class WorkflowCopilotStreamResponseUpdate(BaseModel):
type: WorkflowCopilotStreamMessageType = Field(
WorkflowCopilotStreamMessageType.RESPONSE, description="Message type"
)
workflow_copilot_chat_id: str = Field(..., description="The chat ID")
message: str = Field(..., description="The message sent to the user")
updated_workflow_yaml: str | None = Field(None, description="The updated workflow yaml")
response_time: datetime = Field(..., description="When the assistant message was created")
class WorkflowCopilotStreamErrorUpdate(BaseModel):
type: WorkflowCopilotStreamMessageType = Field(WorkflowCopilotStreamMessageType.ERROR, description="Message type")
error: str = Field(..., description="Error message")