Workflow Copilot: server update streaming with "cancel" ability (#4456)
This commit is contained in:
committed by
GitHub
parent
6b9ea59e67
commit
9cf1f87514
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user