mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
feat: support multi-file workflow import with duplicate confirmation (#5670)
This commit is contained in:
parent
9457c49d36
commit
9e567c5ab8
1 changed files with 351 additions and 67 deletions
|
|
@ -1,9 +1,18 @@
|
||||||
import { getClient } from "@/api/AxiosClient";
|
import { getClient } from "@/api/AxiosClient";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { UploadIcon } from "@radix-ui/react-icons";
|
import { UploadIcon } from "@radix-ui/react-icons";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useId } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { stringify as convertToYAML } from "yaml";
|
import { parse as parseYAML, stringify as convertToYAML } from "yaml";
|
||||||
import { WorkflowApiResponse } from "./types/workflowTypes";
|
import { WorkflowApiResponse } from "./types/workflowTypes";
|
||||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
import { toast } from "@/components/ui/use-toast";
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
|
@ -18,7 +27,7 @@ import { AxiosError } from "axios";
|
||||||
function isJsonString(str: string): boolean {
|
function isJsonString(str: string): boolean {
|
||||||
try {
|
try {
|
||||||
JSON.parse(str);
|
JSON.parse(str);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -33,6 +42,33 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractTitleFromYaml(yaml: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = parseYAML(yaml);
|
||||||
|
if (parsed && typeof parsed === "object" && "title" in parsed) {
|
||||||
|
const title = (parsed as { title?: unknown }).title;
|
||||||
|
if (typeof title === "string" && title.trim().length > 0) {
|
||||||
|
return title.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateReason =
|
||||||
|
| { kind: "existing"; existingTitle: string }
|
||||||
|
| { kind: "intra-batch" }
|
||||||
|
| { kind: "check-failed" };
|
||||||
|
|
||||||
|
type PreparedYamlImport = {
|
||||||
|
fileName: string;
|
||||||
|
yaml: string;
|
||||||
|
title: string | null;
|
||||||
|
duplicateReason: DuplicateReason | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface ImportWorkflowButtonProps {
|
interface ImportWorkflowButtonProps {
|
||||||
onImportStart?: () => void;
|
onImportStart?: () => void;
|
||||||
selectedFolderId?: string | null;
|
selectedFolderId?: string | null;
|
||||||
|
|
@ -45,8 +81,20 @@ function ImportWorkflowButton({
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const credentialGetter = useCredentialGetter();
|
const credentialGetter = useCredentialGetter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [duplicateConfirmOpen, setDuplicateConfirmOpen] = useState(false);
|
||||||
|
const [pendingDuplicates, setPendingDuplicates] = useState<
|
||||||
|
PreparedYamlImport[]
|
||||||
|
>([]);
|
||||||
|
const [pendingNonDuplicates, setPendingNonDuplicates] = useState<
|
||||||
|
PreparedYamlImport[]
|
||||||
|
>([]);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
|
||||||
const createWorkflowFromYamlMutation = async (yaml: string) => {
|
const createWorkflowFromYaml = async (
|
||||||
|
yaml: string,
|
||||||
|
fileName: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const client = await getClient(credentialGetter);
|
const client = await getClient(credentialGetter);
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
@ -63,28 +111,18 @@ function ImportWorkflowButton({
|
||||||
params,
|
params,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["workflows"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["folders"],
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
variant: "success",
|
|
||||||
title: "Workflow imported",
|
|
||||||
description: "Successfully imported workflow",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Error importing workflow",
|
title: `Error importing ${fileName}`,
|
||||||
description: getErrorMessage(error, "Failed to import workflow"),
|
description: getErrorMessage(error, "Failed to import workflow"),
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWorkflowFromPdfMutation = async (file: File) => {
|
const createWorkflowFromPdf = async (file: File): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
@ -100,65 +138,311 @@ function ImportWorkflowButton({
|
||||||
},
|
},
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
// Notify parent to start polling
|
|
||||||
onImportStart?.();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Import started",
|
|
||||||
description: `Importing ${file.name}...`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "Import Failed",
|
title: `Import Failed: ${file.name}`,
|
||||||
description: getErrorMessage(error, "Failed to import PDF"),
|
description: getErrorMessage(error, "Failed to import PDF"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findExistingWorkflowTitle = async (
|
||||||
|
title: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const pageSize = 50;
|
||||||
|
const maxPages = 5;
|
||||||
|
const normalized = title.trim().toLowerCase();
|
||||||
|
const client = await getClient(credentialGetter);
|
||||||
|
for (let page = 1; page <= maxPages; page++) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("page", String(page));
|
||||||
|
params.append("page_size", String(pageSize));
|
||||||
|
params.append("only_workflows", "true");
|
||||||
|
params.append("search_key", title);
|
||||||
|
const response = await client.get<Array<WorkflowApiResponse>>(
|
||||||
|
"/workflows",
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
const match = response.data.find(
|
||||||
|
(wf) => wf.title.trim().toLowerCase() === normalized,
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
return match.title;
|
||||||
|
}
|
||||||
|
if (response.data.length < pageSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importYamlFiles = async (files: PreparedYamlImport[]) => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = await Promise.all(
|
||||||
|
files.map((f) => createWorkflowFromYaml(f.yaml, f.fileName)),
|
||||||
|
);
|
||||||
|
const successCount = results.filter(Boolean).length;
|
||||||
|
if (successCount > 0) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folders"] });
|
||||||
|
toast({
|
||||||
|
variant: "success",
|
||||||
|
title:
|
||||||
|
successCount === 1
|
||||||
|
? "Workflow imported"
|
||||||
|
: `${successCount} workflows imported`,
|
||||||
|
description:
|
||||||
|
successCount === files.length
|
||||||
|
? "Successfully imported all workflows"
|
||||||
|
: `${successCount} of ${files.length} workflows imported successfully`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFiles = async (fileList: FileList) => {
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
const pdfFiles = files.filter((f) => f.name.toLowerCase().endsWith(".pdf"));
|
||||||
|
const yamlLikeFiles = files.filter(
|
||||||
|
(f) => !f.name.toLowerCase().endsWith(".pdf"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let anyPdfStarted = false;
|
||||||
|
for (const file of pdfFiles) {
|
||||||
|
const ok = await createWorkflowFromPdf(file);
|
||||||
|
if (ok) {
|
||||||
|
anyPdfStarted = true;
|
||||||
|
toast({
|
||||||
|
title: "Import started",
|
||||||
|
description: `Importing ${file.name}...`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyPdfStarted) {
|
||||||
|
onImportStart?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared: PreparedYamlImport[] = await Promise.all(
|
||||||
|
yamlLikeFiles.map(async (file) => {
|
||||||
|
const text = await file.text();
|
||||||
|
const isJson = isJsonString(text);
|
||||||
|
const yaml = isJson ? convertToYAML(JSON.parse(text)) : text;
|
||||||
|
const title = extractTitleFromYaml(yaml);
|
||||||
|
let duplicateReason: DuplicateReason | null = null;
|
||||||
|
if (title) {
|
||||||
|
try {
|
||||||
|
const existing = await findExistingWorkflowTitle(title);
|
||||||
|
if (existing) {
|
||||||
|
duplicateReason = { kind: "existing", existingTitle: existing };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
duplicateReason = { kind: "check-failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fileName: file.name,
|
||||||
|
yaml,
|
||||||
|
title,
|
||||||
|
duplicateReason,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleCounts = new Map<string, number>();
|
||||||
|
for (const p of prepared) {
|
||||||
|
if (!p.title) continue;
|
||||||
|
const key = p.title.trim().toLowerCase();
|
||||||
|
titleCounts.set(key, (titleCounts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const p of prepared) {
|
||||||
|
if (p.duplicateReason !== null || !p.title) continue;
|
||||||
|
const key = p.title.trim().toLowerCase();
|
||||||
|
if ((titleCounts.get(key) ?? 0) > 1) {
|
||||||
|
p.duplicateReason = { kind: "intra-batch" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkFailed = prepared.filter(
|
||||||
|
(p) => p.duplicateReason?.kind === "check-failed",
|
||||||
|
);
|
||||||
|
if (checkFailed.length > 0) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title:
|
||||||
|
checkFailed.length === 1
|
||||||
|
? "Could not verify duplicate"
|
||||||
|
: `Could not verify ${checkFailed.length} duplicates`,
|
||||||
|
description:
|
||||||
|
"Network error checking if these workflows already exist. Review before importing.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = prepared.filter((p) => p.duplicateReason !== null);
|
||||||
|
const nonDuplicates = prepared.filter((p) => p.duplicateReason === null);
|
||||||
|
|
||||||
|
if (duplicates.length === 0) {
|
||||||
|
await importYamlFiles(nonDuplicates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingNonDuplicates(nonDuplicates);
|
||||||
|
setPendingDuplicates(duplicates);
|
||||||
|
setDuplicateConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmImportDuplicates = async () => {
|
||||||
|
const all = [...pendingNonDuplicates, ...pendingDuplicates];
|
||||||
|
setDuplicateConfirmOpen(false);
|
||||||
|
setPendingDuplicates([]);
|
||||||
|
setPendingNonDuplicates([]);
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
await importYamlFiles(all);
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipDuplicates = async () => {
|
||||||
|
const nonDupes = pendingNonDuplicates;
|
||||||
|
setDuplicateConfirmOpen(false);
|
||||||
|
setPendingDuplicates([]);
|
||||||
|
setPendingNonDuplicates([]);
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
await importYamlFiles(nonDupes);
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelImport = () => {
|
||||||
|
setDuplicateConfirmOpen(false);
|
||||||
|
setPendingDuplicates([]);
|
||||||
|
setPendingNonDuplicates([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBusy = isImporting || duplicateConfirmOpen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<>
|
||||||
<Tooltip>
|
<TooltipProvider>
|
||||||
<TooltipTrigger>
|
<Tooltip>
|
||||||
<Label htmlFor={inputId}>
|
<TooltipTrigger>
|
||||||
<input
|
<Label htmlFor={inputId}>
|
||||||
id={inputId}
|
<input
|
||||||
type="file"
|
ref={inputRef}
|
||||||
accept=".yaml,.yml,.json,.pdf"
|
id={inputId}
|
||||||
className="hidden"
|
type="file"
|
||||||
onChange={async (event) => {
|
accept=".yaml,.yml,.json,.pdf"
|
||||||
if (event.target.files && event.target.files[0]) {
|
multiple
|
||||||
const file = event.target.files[0];
|
className="hidden"
|
||||||
const fileName = file.name.toLowerCase();
|
disabled={isBusy}
|
||||||
|
onChange={async (event) => {
|
||||||
if (fileName.endsWith(".pdf")) {
|
const files = event.target.files;
|
||||||
// Handle PDF file
|
if (!files || files.length === 0) {
|
||||||
await createWorkflowFromPdfMutation(file);
|
return;
|
||||||
} else {
|
|
||||||
// Non-pdf files like yaml, json
|
|
||||||
const fileTextContent = await file.text();
|
|
||||||
const isJson = isJsonString(fileTextContent);
|
|
||||||
const content = isJson
|
|
||||||
? convertToYAML(JSON.parse(fileTextContent))
|
|
||||||
: fileTextContent;
|
|
||||||
|
|
||||||
await createWorkflowFromYamlMutation(content);
|
|
||||||
}
|
}
|
||||||
}
|
setIsImporting(true);
|
||||||
}}
|
try {
|
||||||
/>
|
await handleFiles(files);
|
||||||
<div className="flex h-full cursor-pointer items-center gap-2 rounded-md bg-secondary px-4 py-2 font-bold text-secondary-foreground hover:bg-secondary/90">
|
} finally {
|
||||||
<UploadIcon className="h-4 w-4" />
|
if (inputRef.current) {
|
||||||
Import
|
inputRef.current.value = "";
|
||||||
</div>
|
}
|
||||||
</Label>
|
setIsImporting(false);
|
||||||
</TooltipTrigger>
|
}
|
||||||
<TooltipContent>
|
}}
|
||||||
Import a workflow from a YAML, JSON, or PDF file
|
/>
|
||||||
</TooltipContent>
|
<div
|
||||||
</Tooltip>
|
className={`flex h-full items-center gap-2 rounded-md bg-secondary px-4 py-2 font-bold text-secondary-foreground ${
|
||||||
</TooltipProvider>
|
isBusy
|
||||||
|
? "cursor-not-allowed opacity-60"
|
||||||
|
: "cursor-pointer hover:bg-secondary/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UploadIcon className="h-4 w-4" />
|
||||||
|
{isImporting ? "Importing..." : "Import"}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Import one or more workflows from YAML, JSON, or PDF files
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Dialog
|
||||||
|
open={duplicateConfirmOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
handleCancelImport();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{pendingDuplicates.length === 1
|
||||||
|
? "Duplicate workflow detected"
|
||||||
|
: "Duplicate workflows detected"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>
|
||||||
|
{pendingDuplicates.length === 1
|
||||||
|
? "The following file may create a duplicate:"
|
||||||
|
: "The following files may create duplicates:"}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm">
|
||||||
|
{pendingDuplicates.map((dup) => (
|
||||||
|
<li key={dup.fileName}>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{dup.fileName}</span>
|
||||||
|
{dup.title && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
— “{dup.title}”
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{dup.duplicateReason?.kind === "existing" &&
|
||||||
|
"A workflow with this title already exists"}
|
||||||
|
{dup.duplicateReason?.kind === "intra-batch" &&
|
||||||
|
"Another selected file has the same title"}
|
||||||
|
{dup.duplicateReason?.kind === "check-failed" &&
|
||||||
|
"Could not verify if a duplicate exists"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Would you like to import{" "}
|
||||||
|
{pendingDuplicates.length === 1 ? "it" : "them"} anyway?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button variant="secondary" onClick={handleCancelImport}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{pendingNonDuplicates.length > 0 && (
|
||||||
|
<Button variant="outline" onClick={handleSkipDuplicates}>
|
||||||
|
Skip duplicates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleConfirmImportDuplicates}>
|
||||||
|
Import anyway
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue