feat: add opening directly from comfy deploy -> machines

This commit is contained in:
BennyKok 2024-01-23 09:33:53 +08:00
parent c0450b58d5
commit 52d6e07eeb
12 changed files with 808 additions and 405 deletions

View File

@ -9,6 +9,62 @@ const ext = {
init(app) { init(app) {
addButton(); addButton();
const queryParams = new URLSearchParams(window.location.search);
const workflow_version_id = queryParams.get("workflow_version_id");
const auth_token = queryParams.get("auth_token");
const org_display = queryParams.get("org_display");
const origin = queryParams.get("origin");
if (!workflow_version_id) {
console.error("No workflow_version_id provided in query parameters.");
} else {
const data = getData();
let endpoint = data.endpoint;
let apiKey = data.apiKey;
// If there is auth token override it
if (auth_token) {
apiKey = auth_token;
endpoint = origin;
saveData({
displayName: org_display,
endpoint: origin,
apiKey: auth_token,
displayName: org_display,
});
}
loadingDialog.showLoading(
"Loading workflow from " + org_display,
"Please wait...",
);
fetch(endpoint + "/api/workflow-version/" + workflow_version_id, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + apiKey,
},
})
.then(async (res) => {
const data = await res.json();
const { workflow, error } = data;
if (error) {
infoDialog.showMessage("Unable to load this workflow", error);
return;
}
/** @type {LGraph} */
app.loadGraphData(workflow);
})
.catch((e) => infoDialog.showMessage("Error", e.message))
.finally(() => {
loadingDialog.close();
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
});
}
}, },
registerCustomNodes() { registerCustomNodes() {
@ -191,7 +247,7 @@ function addButton() {
endpoint = endpoint.slice(0, -1); endpoint = endpoint.slice(0, -1);
} }
const apiRoute = endpoint + "/api/upload"; const apiRoute = endpoint + "/api/workflow";
// const userId = apiKey // const userId = apiKey
try { try {
const body = { const body = {
@ -280,14 +336,16 @@ export class InfoDialog extends ComfyDialog {
this.element.classList.add("comfy-normal-modal"); this.element.classList.add("comfy-normal-modal");
this.element.style.paddingBottom = "20px"; this.element.style.paddingBottom = "20px";
} }
button = undefined;
createButtons() { createButtons() {
return [ this.button = $el("button", {
$el("button", { type: "button",
type: "button", textContent: "Close",
textContent: "Close", onclick: () => this.close(),
onclick: () => this.close(), });
}), return [this.button];
];
} }
close() { close() {
@ -317,6 +375,58 @@ export class InfoDialog extends ComfyDialog {
</div> </div>
`); `);
} }
loadingIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#888888" stroke-linecap="round" stroke-width="2"><path stroke-dasharray="60" stroke-dashoffset="60" stroke-opacity=".3" d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="1.3s" values="60;0"/></path><path stroke-dasharray="15" stroke-dashoffset="15" d="M12 3C16.9706 3 21 7.02944 21 12"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="15;0"/><animateTransform attributeName="transform" dur="1.5s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></g></svg>`;
showLoading(title, message) {
this.show(`
<div style="width: 400px; display: flex; gap: 18px; flex-direction: column; overflow: unset">
<h3 style="margin: 0px; display: flex; align-items: center; justify-content: center;">${title} ${this.loadingIcon}</h3>
<label>
${message}
</label>
</div>
`);
}
}
export class LoadingDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("comfy-normal-modal");
// this.element.style.paddingBottom = "20px";
}
createButtons() {
return [];
}
close() {
this.element.style.display = "none";
}
show(html) {
this.textElement.style["white-space"] = "normal";
this.textElement.style.color = "white";
this.textElement.style.marginTop = "0px";
if (typeof html === "string") {
this.textElement.innerHTML = html;
} else {
this.textElement.replaceChildren(html);
}
this.element.style.display = "flex";
this.element.style.zIndex = 1001;
}
loadingIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#888888" stroke-linecap="round" stroke-width="2"><path stroke-dasharray="60" stroke-dashoffset="60" stroke-opacity=".3" d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="1.3s" values="60;0"/></path><path stroke-dasharray="15" stroke-dashoffset="15" d="M12 3C16.9706 3 21 7.02944 21 12"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="15;0"/><animateTransform attributeName="transform" dur="1.5s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></g></svg>`;
showLoading(title, message) {
this.show(`
<div style="width: 400px; display: flex; gap: 18px; flex-direction: column; overflow: unset">
<h3 style="margin: 0px; display: flex; align-items: center; justify-content: center; gap: 4px;">${title} ${this.loadingIcon}</h3>
</div>
`);
}
} }
export class InputDialog extends InfoDialog { export class InputDialog extends InfoDialog {
@ -444,9 +554,15 @@ export class ConfirmDialog extends InfoDialog {
} }
export const inputDialog = new InputDialog(); export const inputDialog = new InputDialog();
export const loadingDialog = new LoadingDialog();
export const infoDialog = new InfoDialog(); export const infoDialog = new InfoDialog();
export const confirmDialog = new ConfirmDialog(); export const confirmDialog = new ConfirmDialog();
/**
* Retrieves deployment data from local storage or defaults.
* @param {string} [environment] - The environment to get the data for.
* @returns {{endpoint: string, apiKey: string, displayName: string, environment?: string}} The deployment data.
*/
function getData(environment) { function getData(environment) {
const deployOption = const deployOption =
environment || localStorage.getItem("comfy_deploy_env") || "cloud"; environment || localStorage.getItem("comfy_deploy_env") || "cloud";
@ -469,6 +585,17 @@ function getData(environment) {
}; };
} }
/**
* Retrieves deployment data from local storage or defaults.
* @param {{endpoint: string, apiKey: string, displayName: string, environment?: string}} [data] - The environment to get the data for.
*/
function saveData(data) {
localStorage.setItem(
"comfy_deploy_env_data_" + data.environment,
JSON.stringify(data),
);
}
export class ConfigDialog extends ComfyDialog { export class ConfigDialog extends ComfyDialog {
container = null; container = null;
poll = null; poll = null;
@ -527,15 +654,12 @@ export class ConfigDialog extends ComfyDialog {
const endpoint = this.container.querySelector("#endpoint").value; const endpoint = this.container.querySelector("#endpoint").value;
const apiKey = api_key ?? this.container.querySelector("#apiKey").value; const apiKey = api_key ?? this.container.querySelector("#apiKey").value;
const data = { saveData({
endpoint, endpoint,
apiKey, apiKey,
displayName, displayName,
}; environment: deployOption,
localStorage.setItem( });
"comfy_deploy_env_data_" + deployOption,
JSON.stringify(data),
);
this.close(); this.close();
} }

View File

@ -8,7 +8,8 @@ import { handle } from "hono/vercel";
import { app } from "../../../../routes/app"; import { app } from "../../../../routes/app";
import { registerWorkflowUploadRoute } from "@/routes/registerWorkflowUploadRoute"; import { registerWorkflowUploadRoute } from "@/routes/registerWorkflowUploadRoute";
import { registerGetAuthResponse } from "@/routes/registerGetAuthResponse"; import { registerGetAuthResponse } from "@/routes/registerGetAuthResponse";
import { registerGetWorkflowRoute } from "@/routes/registerGetWorkflow";
import { cors } from "hono/cors";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const maxDuration = 300; // 5 minutes export const maxDuration = 300; // 5 minutes
@ -18,17 +19,24 @@ declare module "hono" {
} }
} }
async function checkAuth(c: Context, next: Next) { async function checkAuth(c: Context, next: Next, headers?: HeadersInit) {
const token = c.req.raw.headers.get("Authorization")?.split(" ")?.[1]; // Assuming token is sent as "Bearer your_token" const token = c.req.raw.headers.get("Authorization")?.split(" ")?.[1]; // Assuming token is sent as "Bearer your_token"
const userData = token ? parseJWT(token) : undefined; const userData = token ? parseJWT(token) : undefined;
if (!userData || token === undefined) { if (!userData || token === undefined) {
return c.text("Invalid or expired token", 401); return c.text("Invalid or expired token", {
status: 401,
headers: headers,
});
} }
// If the key has expiration, this is a temporary key and not in our db, so we can skip checking // If the key has expiration, this is a temporary key and not in our db, so we can skip checking
if (userData.exp === undefined) { if (userData.exp === undefined) {
const revokedKey = await isKeyRevoked(token); const revokedKey = await isKeyRevoked(token);
if (revokedKey) return c.text("Revoked token", 401); if (revokedKey)
return c.text("Revoked token", {
status: 401,
headers: headers,
});
} }
c.set("apiKeyTokenData", userData); c.set("apiKeyTokenData", userData);
@ -36,9 +44,29 @@ async function checkAuth(c: Context, next: Next) {
await next(); await next();
} }
async function checkAuthCORS(c: Context, next: Next) {
return checkAuth(c, next, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
}
app.use("/run", checkAuth); app.use("/run", checkAuth);
app.use("/upload-url", checkAuth); app.use("/upload-url", checkAuth);
app.use("/upload-workflow", checkAuth);
const corsHandler = cors({
origin: "*",
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
});
// CORS Check
app.use("/workflow", corsHandler, checkAuth);
app.use("/workflow-version/*", corsHandler, checkAuth);
// create run endpoint // create run endpoint
registerCreateRunRoute(app); registerCreateRunRoute(app);
@ -47,9 +75,12 @@ registerGetOutputRoute(app);
// file upload endpoint // file upload endpoint
registerUploadRoute(app); registerUploadRoute(app);
registerWorkflowUploadRoute(app); // Anon
registerGetAuthResponse(app); registerGetAuthResponse(app);
registerWorkflowUploadRoute(app);
registerGetWorkflowRoute(app);
// The OpenAPI documentation will be available at /doc // The OpenAPI documentation will be available at /doc
app.doc("/doc", { app.doc("/doc", {
openapi: "3.0.0", openapi: "3.0.0",
@ -76,3 +107,4 @@ const handler = handle(app);
export const GET = handler; export const GET = handler;
export const POST = handler; export const POST = handler;
export const OPTIONS = handler;

View File

@ -2,12 +2,13 @@ import { CreateShareButton } from "@/components/CreateShareButton";
import { MachinesWSMain } from "@/components/MachinesWS"; import { MachinesWSMain } from "@/components/MachinesWS";
import { VersionDetails } from "@/components/VersionDetails"; import { VersionDetails } from "@/components/VersionDetails";
import { import {
CopyWorkflowVersion, CopyWorkflowVersion,
CreateDeploymentButton, CreateDeploymentButton,
MachineSelect, MachineSelect,
RunWorkflowButton, OpenEditButton,
VersionSelect, RunWorkflowButton,
ViewWorkflowDetailsButton, VersionSelect,
ViewWorkflowDetailsButton,
} from "@/components/VersionSelect"; } from "@/components/VersionSelect";
import { import {
Card, Card,
@ -48,6 +49,7 @@ export default async function Page({
<CreateShareButton workflow={workflow} machines={machines} /> <CreateShareButton workflow={workflow} machines={machines} />
<CopyWorkflowVersion workflow={workflow} /> <CopyWorkflowVersion workflow={workflow} />
<ViewWorkflowDetailsButton workflow={workflow} /> <ViewWorkflowDetailsButton workflow={workflow} />
<OpenEditButton workflow={workflow} machines={machines} />
</div> </div>
<VersionDetails workflow={workflow} /> <VersionDetails workflow={workflow} />

View File

@ -1,5 +1,5 @@
import { setInitialUserData } from "../../../lib/setInitialUserData"; import { setInitialUserData } from "../../../lib/setInitialUserData";
import { getAllUserWorkflow } from "../../../server/getAllUserWorkflow"; import { getAllUserWorkflow } from "../../../server/crudWorkflow";
import { WorkflowList } from "@/components/WorkflowList"; import { WorkflowList } from "@/components/WorkflowList";
import { db } from "@/db/db"; import { db } from "@/db/db";
import { usersTable } from "@/db/schema"; import { usersTable } from "@/db/schema";

View File

@ -1,11 +1,11 @@
import { CodeBlock } from "@/components/CodeBlock"; import { CodeBlock } from "@/components/CodeBlock";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { TableRow } from "@/components/ui/table"; import { TableRow } from "@/components/ui/table";
@ -83,145 +83,145 @@ const run = await client.getRun(run_id);
`; `;
export function DeploymentDisplay({ export function DeploymentDisplay({
deployment, deployment,
domain, domain,
}: { }: {
deployment: Awaited<ReturnType<typeof findAllDeployments>>[0]; deployment: Awaited<ReturnType<typeof findAllDeployments>>[0];
domain: string; domain: string;
}) { }) {
const workflowInput = getInputsFromWorkflow(deployment.version); const workflowInput = getInputsFromWorkflow(deployment.version);
if (deployment.environment === "public-share") { if (deployment.environment === "public-share") {
return <SharePageDeploymentRow deployment={deployment} />; return <SharePageDeploymentRow deployment={deployment} />;
} }
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild className="appearance-none hover:cursor-pointer"> <DialogTrigger asChild className="appearance-none hover:cursor-pointer">
<TableRow> <TableRow>
<DeploymentRow deployment={deployment} /> <DeploymentRow deployment={deployment} />
</TableRow> </TableRow>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="capitalize"> <DialogTitle className="capitalize">
{deployment.environment} Deployment {deployment.environment} Deployment
</DialogTitle> </DialogTitle>
<DialogDescription>Code for your deployment client</DialogDescription> <DialogDescription>Code for your deployment client</DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className="max-h-[600px] pr-4"> <ScrollArea className="max-h-[600px] pr-4">
<Tabs defaultValue="client" className="w-full gap-2 text-sm"> <Tabs defaultValue="client" className="w-full gap-2 text-sm">
<TabsList className="grid w-fit grid-cols-3 mb-2"> <TabsList className="grid w-fit grid-cols-3 mb-2">
<TabsTrigger value="client">Server Client</TabsTrigger> <TabsTrigger value="client">Server Client</TabsTrigger>
<TabsTrigger value="js">NodeJS Fetch</TabsTrigger> <TabsTrigger value="js">NodeJS Fetch</TabsTrigger>
<TabsTrigger value="curl">CURL</TabsTrigger> <TabsTrigger value="curl">CURL</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="flex flex-col gap-2 !mt-0" value="client"> <TabsContent className="flex flex-col gap-2 !mt-0" value="client">
<div> <div>
Copy and paste the ComfyDeployClient form&nbsp; Copy and paste the ComfyDeployClient form&nbsp;
<a <a
href="https://github.com/BennyKok/comfyui-deploy-next-example/blob/main/src/lib/comfy-deploy.ts" href="https://github.com/BennyKok/comfyui-deploy-next-example/blob/main/src/lib/comfy-deploy.ts"
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
here here
</a> </a>
</div> </div>
<CodeBlock <CodeBlock
lang="js" lang="js"
code={formatCode( code={formatCode(
domain == "https://www.comfydeploy.com" domain == "https://www.comfydeploy.com"
? jsClientSetupTemplateHostedVersion ? jsClientSetupTemplateHostedVersion
: jsClientSetupTemplate, : jsClientSetupTemplate,
deployment, deployment,
domain, domain,
workflowInput, workflowInput,
)} )}
/> />
Create a run via deployment id Create a run via deployment id
<CodeBlock <CodeBlock
lang="js" lang="js"
code={formatCode( code={formatCode(
workflowInput && workflowInput.length > 0 workflowInput && workflowInput.length > 0
? jsClientCreateRunTemplate ? jsClientCreateRunTemplate
: jsClientCreateRunNoInputsTemplate, : jsClientCreateRunNoInputsTemplate,
deployment, deployment,
domain, domain,
workflowInput, workflowInput,
)} )}
/> />
Check the status of the run, and retrieve the outputs Check the status of the run, and retrieve the outputs
<CodeBlock <CodeBlock
lang="js" lang="js"
code={formatCode( code={formatCode(
clientTemplate_checkStatus, clientTemplate_checkStatus,
deployment, deployment,
domain, domain,
)} )}
/> />
</TabsContent> </TabsContent>
<TabsContent className="flex flex-col gap-2 !mt-0" value="js"> <TabsContent className="flex flex-col gap-2 !mt-0" value="js">
Trigger the workflow Trigger the workflow
<CodeBlock <CodeBlock
lang="js" lang="js"
code={formatCode(jsTemplate, deployment, domain, workflowInput)} code={formatCode(jsTemplate, deployment, domain, workflowInput)}
/> />
Check the status of the run, and retrieve the outputs Check the status of the run, and retrieve the outputs
<CodeBlock <CodeBlock
lang="js" lang="js"
code={formatCode(jsTemplate_checkStatus, deployment, domain)} code={formatCode(jsTemplate_checkStatus, deployment, domain)}
/> />
</TabsContent> </TabsContent>
<TabsContent className="flex flex-col gap-2 !mt-2" value="curl"> <TabsContent className="flex flex-col gap-2 !mt-2" value="curl">
<CodeBlock <CodeBlock
lang="bash" lang="bash"
code={formatCode(curlTemplate, deployment, domain)} code={formatCode(curlTemplate, deployment, domain)}
/> />
<CodeBlock <CodeBlock
lang="bash" lang="bash"
code={formatCode(curlTemplate_checkStatus, deployment, domain)} code={formatCode(curlTemplate_checkStatus, deployment, domain)}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</ScrollArea> </ScrollArea>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
function formatCode( function formatCode(
codeTemplate: string, codeTemplate: string,
deployment: Awaited<ReturnType<typeof findAllDeployments>>[0], deployment: Awaited<ReturnType<typeof findAllDeployments>>[0],
domain: string, domain: string,
inputs?: ReturnType<typeof getInputsFromWorkflow>, inputs?: ReturnType<typeof getInputsFromWorkflow>,
inputsTabs?: number, inputsTabs?: number,
) { ) {
if (inputs && inputs.length > 0) { if (inputs && inputs.length > 0) {
codeTemplate = codeTemplate.replace( codeTemplate = codeTemplate.replace(
"inputs: {}", "inputs: {}",
`inputs: ${JSON.stringify( `inputs: ${JSON.stringify(
Object.fromEntries( Object.fromEntries(
inputs.map((x) => { inputs.map((x) => {
return [x?.input_id, ""]; return [x?.input_id, ""];
}), }),
), ),
null, null,
2, 2,
) )
.split("\n") .split("\n")
.map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line .map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line
.join("\n")}`, .join("\n")}`,
); );
} else { } else {
codeTemplate = codeTemplate.replace( codeTemplate = codeTemplate.replace(
` `
inputs: {}`, inputs: {}`,
"", "",
); );
} }
return codeTemplate return codeTemplate
.replace("<URL>", `${domain ?? "http://localhost:3000"}/api/run`) .replace("<URL>", `${domain ?? "http://localhost:3000"}/api/run`)
.replace("<ID>", deployment.id) .replace("<ID>", deployment.id)
.replace("<URLONLY>", domain ?? "http://localhost:3000"); .replace("<URLONLY>", domain ?? "http://localhost:3000");
} }

View File

@ -41,7 +41,14 @@ import { checkStatus, createRun } from "@/server/createRun";
import { createDeployments } from "@/server/curdDeploments"; import { createDeployments } from "@/server/curdDeploments";
import type { getMachines } from "@/server/curdMachine"; import type { getMachines } from "@/server/curdMachine";
import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion"; import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
import { Copy, ExternalLink, Info, MoreVertical, Play } from "lucide-react"; import {
Copy,
Edit,
ExternalLink,
Info,
MoreVertical,
Play,
} from "lucide-react";
import { parseAsInteger, useQueryState } from "next-usequerystate"; import { parseAsInteger, useQueryState } from "next-usequerystate";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -51,6 +58,8 @@ import { create } from "zustand";
import { workflowVersionInputsToZod } from "../lib/workflowVersionInputsToZod"; import { workflowVersionInputsToZod } from "../lib/workflowVersionInputsToZod";
import { callServerPromise } from "./callServerPromise"; import { callServerPromise } from "./callServerPromise";
import fetcher from "./fetcher"; import fetcher from "./fetcher";
import { ButtonAction } from "@/components/ButtonActionLoader";
import { editWorkflowOnMachine } from "@/server/editWorkflowOnMachine";
export function VersionSelect({ export function VersionSelect({
workflow, workflow,
@ -116,13 +125,13 @@ export function MachineSelect({
} }
export function useSelectedMachine( export function useSelectedMachine(
machines: Awaited<ReturnType<typeof getMachines>>, machines: Awaited<ReturnType<typeof getMachines>>,
) { ) {
const a = useQueryState("machine", { const a = useQueryState("machine", {
defaultValue: machines?.[0]?.id ?? "", defaultValue: machines?.[0]?.id ?? "",
}); });
return a; return a;
} }
type PublicRunStore = { type PublicRunStore = {
@ -219,7 +228,7 @@ export function RunWorkflowButton({
const schema = useMemo(() => { const schema = useMemo(() => {
const workflow_version = getWorkflowVersionFromVersionIndex( const workflow_version = getWorkflowVersionFromVersionIndex(
workflow, workflow,
version version,
); );
if (!workflow_version) return null; if (!workflow_version) return null;
@ -233,7 +242,7 @@ export function RunWorkflowButton({
const val = Object.keys(values).length > 0 ? values : undefined; const val = Object.keys(values).length > 0 ? values : undefined;
const workflow_version_id = workflow?.versions.find( const workflow_version_id = workflow?.versions.find(
(x) => x.version === version (x) => x.version === version,
)?.id; )?.id;
console.log(workflow_version_id); console.log(workflow_version_id);
if (!workflow_version_id) return; if (!workflow_version_id) return;
@ -248,7 +257,7 @@ export function RunWorkflowButton({
machine_id: machine, machine_id: machine,
inputs: val, inputs: val,
runOrigin: "manual", runOrigin: "manual",
}) }),
); );
// console.log(res.json()); // console.log(res.json());
setIsLoading(false); setIsLoading(false);
@ -317,7 +326,7 @@ export function CreateDeploymentButton({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const workflow_version_id = workflow?.versions.find( const workflow_version_id = workflow?.versions.find(
(x) => x.version === version (x) => x.version === version,
)?.id; )?.id;
return ( return (
<DropdownMenu> <DropdownMenu>
@ -337,8 +346,8 @@ export function CreateDeploymentButton({
workflow.id, workflow.id,
workflow_version_id, workflow_version_id,
machine, machine,
"production" "production",
) ),
); );
setIsLoading(false); setIsLoading(false);
}} }}
@ -355,8 +364,8 @@ export function CreateDeploymentButton({
workflow.id, workflow.id,
workflow_version_id, workflow_version_id,
machine, machine,
"staging" "staging",
) ),
); );
setIsLoading(false); setIsLoading(false);
}} }}
@ -368,6 +377,49 @@ export function CreateDeploymentButton({
); );
} }
export function OpenEditButton({
workflow,
machines,
}: {
workflow: Awaited<ReturnType<typeof findFirstTableWithVersion>>;
machines: Awaited<ReturnType<typeof getMachines>>;
}) {
const [version] = useQueryState("version", {
defaultValue: workflow?.versions[0].version ?? 1,
...parseAsInteger,
});
const [machine] = useSelectedMachine(machines);
const workflow_version_id = workflow?.versions.find(
(x) => x.version == version,
)?.id;
const [isLoading, setIsLoading] = useState(false);
return (
workflow_version_id &&
machine && (
<Button
className="gap-2"
onClick={async () => {
setIsLoading(true);
const url = await callServerPromise(
editWorkflowOnMachine(workflow_version_id, machine),
);
if (url && typeof url !== "object") {
window.open(url, "_blank");
} else if (url && typeof url === "object" && url.error) {
console.error(url.error);
}
setIsLoading(false);
}}
// asChild
variant="outline"
>
Edit {isLoading ? <LoadingIcon /> : <Edit size={14} />}
</Button>
)
);
}
export function CopyWorkflowVersion({ export function CopyWorkflowVersion({
workflow, workflow,
}: { }: {
@ -378,7 +430,7 @@ export function CopyWorkflowVersion({
...parseAsInteger, ...parseAsInteger,
}); });
const workflow_version = workflow?.versions.find( const workflow_version = workflow?.versions.find(
(x) => x.version === version (x) => x.version === version,
); );
return ( return (
<DropdownMenu> <DropdownMenu>
@ -402,7 +454,7 @@ export function CopyWorkflowVersion({
}); });
navigator.clipboard.writeText( navigator.clipboard.writeText(
JSON.stringify(workflow_version?.workflow) JSON.stringify(workflow_version?.workflow),
); );
toast("Copied to clipboard"); toast("Copied to clipboard");
}} }}
@ -412,7 +464,7 @@ export function CopyWorkflowVersion({
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
JSON.stringify(workflow_version?.workflow_api) JSON.stringify(workflow_version?.workflow_api),
); );
toast("Copied to clipboard"); toast("Copied to clipboard");
}} }}
@ -426,7 +478,7 @@ export function CopyWorkflowVersion({
export function getWorkflowVersionFromVersionIndex( export function getWorkflowVersionFromVersionIndex(
workflow: Awaited<ReturnType<typeof findFirstTableWithVersion>>, workflow: Awaited<ReturnType<typeof findFirstTableWithVersion>>,
version: number version: number,
) { ) {
const workflow_version = workflow?.versions.find((x) => x.version == version); const workflow_version = workflow?.versions.find((x) => x.version == version);
@ -452,7 +504,7 @@ export function ViewWorkflowDetailsButton({
isLoading: isNodesIndexLoading, isLoading: isNodesIndexLoading,
} = useSWR( } = useSWR(
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json", "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json",
fetcher fetcher,
); );
const groupedByAuxName = useMemo(() => { const groupedByAuxName = useMemo(() => {
@ -462,7 +514,7 @@ export function ViewWorkflowDetailsButton({
const workflow_version = getWorkflowVersionFromVersionIndex( const workflow_version = getWorkflowVersionFromVersionIndex(
workflow, workflow,
version version,
); );
const api = workflow_version?.workflow_api; const api = workflow_version?.workflow_api;
@ -473,7 +525,7 @@ export function ViewWorkflowDetailsButton({
.map(([_, value]) => { .map(([_, value]) => {
const classType = value.class_type; const classType = value.class_type;
const classTypeData = Object.entries(data).find(([_, nodeArray]) => const classTypeData = Object.entries(data).find(([_, nodeArray]) =>
nodeArray[0].includes(classType) nodeArray[0].includes(classType),
); );
return classTypeData ? { node: value, classTypeData } : null; return classTypeData ? { node: value, classTypeData } : null;
}) })
@ -503,7 +555,7 @@ export function ViewWorkflowDetailsButton({
node: z.infer<typeof workflowAPINodeType>[]; node: z.infer<typeof workflowAPINodeType>[];
url: string; url: string;
} }
> >,
); );
// console.log(groupedByAuxName); // console.log(groupedByAuxName);
@ -544,7 +596,8 @@ export function ViewWorkflowDetailsButton({
<a <a
href={group.url} href={group.url}
target="_blank" target="_blank"
className="hover:underline" rel="noreferrer" className="hover:underline"
rel="noreferrer"
> >
{key} {key}
<ExternalLink <ExternalLink

View File

@ -9,7 +9,7 @@ import {
timestamp, timestamp,
uuid, uuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod"; import { z } from "zod";
export const dbSchema = pgSchema("comfyui_deploy"); export const dbSchema = pgSchema("comfyui_deploy");
@ -35,6 +35,8 @@ export const workflowTable = dbSchema.table("workflows", {
updated_at: timestamp("updated_at").defaultNow().notNull(), updated_at: timestamp("updated_at").defaultNow().notNull(),
}); });
export const workflowSchema = createSelectSchema(workflowTable);
export const workflowRelations = relations(workflowTable, ({ many, one }) => ({ export const workflowRelations = relations(workflowTable, ({ many, one }) => ({
user: one(usersTable, { user: one(usersTable, {
fields: [workflowTable.user_id], fields: [workflowTable.user_id],
@ -79,6 +81,7 @@ export const workflowVersionTable = dbSchema.table("workflow_versions", {
created_at: timestamp("created_at").defaultNow().notNull(), created_at: timestamp("created_at").defaultNow().notNull(),
updated_at: timestamp("updated_at").defaultNow().notNull(), updated_at: timestamp("updated_at").defaultNow().notNull(),
}); });
export const workflowVersionSchema = createSelectSchema(workflowVersionTable);
export const workflowVersionRelations = relations( export const workflowVersionRelations = relations(
workflowVersionTable, workflowVersionTable,

View File

@ -0,0 +1,86 @@
import { workflowVersionSchema } from "@/db/schema";
import type { App } from "@/routes/app";
import { authError } from "@/routes/authError";
import { getWorkflowVersion } from "@/server/crudWorkflow";
import { z, createRoute } from "@hono/zod-openapi";
const route = createRoute({
method: "get",
path: "/workflow-version/:version_id",
tags: ["comfyui"],
summary: "Get comfyui workflow",
description: "Use this to retrieve comfyui workflow by id",
request: {
params: z.object({
version_id: z.string(),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: workflowVersionSchema,
},
},
description: "Retrieve the output",
},
500: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Error when uploading the workflow",
},
...authError,
},
});
export const registerGetWorkflowRoute = (app: App) => {
return app.openapi(route, async (c) => {
const { version_id } = c.req.valid("param");
const apiUser = c.get("apiKeyTokenData")!;
if (!apiUser.user_id)
return c.json(
{
error: "Invalid user_id",
},
{
status: 500,
},
);
try {
const workflow_version = await getWorkflowVersion(apiUser, version_id);
if (workflow_version) {
return c.json(workflow_version, {
status: 200,
});
} else {
return c.json(
{
error: "No version found",
},
{
status: 500,
},
);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return c.json(
{
error: errorMessage,
},
{
statusText: "Invalid request",
status: 500,
},
);
}
});
};

View File

@ -1,4 +1,10 @@
import { snapshotType, workflowAPIType, workflowType } from "@/db/schema"; import { db } from "@/db/db";
import {
snapshotType,
workflowAPIType,
workflowTable,
workflowType,
} from "@/db/schema";
import type { App } from "@/routes/app"; import type { App } from "@/routes/app";
import { authError } from "@/routes/authError"; import { authError } from "@/routes/authError";
import { import {
@ -6,10 +12,11 @@ import {
createNewWorkflowVersion, createNewWorkflowVersion,
} from "@/server/createNewWorkflow"; } from "@/server/createNewWorkflow";
import { z, createRoute } from "@hono/zod-openapi"; import { z, createRoute } from "@hono/zod-openapi";
import { and, eq } from "drizzle-orm";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/upload-workflow", path: "/workflow",
tags: ["comfyui"], tags: ["comfyui"],
summary: "Upload workflow from ComfyUI", summary: "Upload workflow from ComfyUI",
description: description:
@ -106,6 +113,30 @@ export const registerWorkflowUploadRoute = (app: App) => {
workflow_id = _workflow_id; workflow_id = _workflow_id;
version = _version; version = _version;
} else if (workflow_id) { } else if (workflow_id) {
const workflow = await db
.select()
.from(workflowTable)
.where(
and(
eq(workflowTable.id, workflow_id),
eq(workflowTable.user_id, user_id),
eq(workflowTable.org_id, org_id),
),
);
if (workflow.length === 0) {
return c.json(
{
error: "Invalid workflow_id",
},
{
status: 500,
statusText: "Invalid workflow_id",
headers: corsHeaders,
},
);
}
// Case 2 update workflow // Case 2 update workflow
const { version: _version } = await createNewWorkflowVersion({ const { version: _version } = await createNewWorkflowVersion({
workflow_id: workflow_id, workflow_id: workflow_id,

View File

@ -4,8 +4,10 @@ import {
workflowTable, workflowTable,
workflowVersionTable, workflowVersionTable,
} from "@/db/schema"; } from "@/db/schema";
import { APIKeyUserType } from "@/server/APIKeyBodyRequest";
import { auth } from "@clerk/nextjs"; import { auth } from "@clerk/nextjs";
import { and, desc, eq, isNull } from "drizzle-orm"; import { and, desc, eq, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
export async function getAllUserWorkflow() { export async function getAllUserWorkflow() {
const { userId, orgId } = await auth(); const { userId, orgId } = await auth();
@ -51,3 +53,29 @@ export async function getAllUserWorkflow() {
return workflow; return workflow;
} }
export async function getWorkflowVersion(
apiUser: APIKeyUserType,
version_id: string,
) {
const { org_id, user_id } = apiUser;
if (!user_id) {
throw new Error("No user id");
}
const parentWorkflow = await db.query.workflowTable.findFirst({
where:
org_id != undefined
? eq(workflowTable.org_id, org_id)
: and(eq(workflowTable.user_id, user_id), isNull(workflowTable.org_id)),
});
if (!parentWorkflow) {
throw new Error("No workflow found");
}
return db.query.workflowVersionTable.findFirst({
where: eq(workflowVersionTable.id, version_id),
});
}

View File

@ -16,270 +16,271 @@ import "server-only";
import { validate as isValidUUID } from "uuid"; import { validate as isValidUUID } from "uuid";
import type { z } from "zod"; import type { z } from "zod";
export async function createDeployments( export async function createDeployments(
workflow_id: string, workflow_id: string,
version_id: string, version_id: string,
machine_id: string, machine_id: string,
environment: DeploymentType["environment"], environment: DeploymentType["environment"],
) { ) {
const { userId, orgId } = auth(); const { userId, orgId } = auth();
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id");
if (!machine_id) { if (!machine_id) {
throw new Error("No machine id provided"); throw new Error("No machine id provided");
} }
// Same environment and same workflow // Same environment and same workflow
const existingDeployment = await db.query.deploymentsTable.findFirst({ const existingDeployment = await db.query.deploymentsTable.findFirst({
where: and( where: and(
eq(deploymentsTable.workflow_id, workflow_id), eq(deploymentsTable.workflow_id, workflow_id),
eq(deploymentsTable.environment, environment), eq(deploymentsTable.environment, environment),
), ),
}); });
if (existingDeployment) { if (existingDeployment) {
await db await db
.update(deploymentsTable) .update(deploymentsTable)
.set({ .set({
workflow_id, workflow_id,
workflow_version_id: version_id, workflow_version_id: version_id,
machine_id, machine_id,
org_id: orgId, org_id: orgId,
}) })
.where(eq(deploymentsTable.id, existingDeployment.id)); .where(eq(deploymentsTable.id, existingDeployment.id));
} else { } else {
const workflow = await db.query.workflowTable.findFirst({ const workflow = await db.query.workflowTable.findFirst({
where: eq(workflowTable.id, workflow_id), where: eq(workflowTable.id, workflow_id),
with: { with: {
user: { user: {
columns: { columns: {
name: true, name: true,
}, },
}, },
}, },
}); });
if (!workflow) throw new Error("No workflow found"); if (!workflow) throw new Error("No workflow found");
const userName = workflow.org_id const userName = workflow.org_id
? await clerkClient.organizations ? await clerkClient.organizations
.getOrganization({ .getOrganization({
organizationId: workflow.org_id, organizationId: workflow.org_id,
}) })
.then((x) => x.name) .then((x) => x.name)
: workflow.user.name; : workflow.user.name;
await db.insert(deploymentsTable).values({ await db.insert(deploymentsTable).values({
user_id: userId, user_id: userId,
workflow_id, workflow_id,
workflow_version_id: version_id, workflow_version_id: version_id,
machine_id, machine_id,
environment, environment,
org_id: orgId, org_id: orgId,
share_slug: slugify(`${userName} ${workflow.name}`), share_slug: slugify(`${userName} ${workflow.name}`),
}); });
} }
revalidatePath(`/${workflow_id}`); revalidatePath(`/${workflow_id}`);
return { return {
message: `Successfully created deployment for ${environment}`, message: `Successfully created deployment for ${environment}`,
}; };
} }
export async function findAllDeployments() { export async function findAllDeployments() {
const { userId, orgId } = auth(); const { userId, orgId } = auth();
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id");
const deployments = await db.query.workflowTable.findMany({ const deployments = await db.query.workflowTable.findMany({
where: and( where: and(
orgId orgId
? eq(workflowTable.org_id, orgId) ? eq(workflowTable.org_id, orgId)
: and(eq(workflowTable.user_id, userId), isNull(workflowTable.org_id)), : and(eq(workflowTable.user_id, userId), isNull(workflowTable.org_id)),
), ),
columns: { columns: {
name: true, name: true,
}, },
with: { with: {
deployments: { deployments: {
columns: { columns: {
environment: true, environment: true,
}, },
with: { with: {
version: { version: {
columns: { columns: {
id: true, id: true,
snapshot: true, snapshot: true,
}, },
}, },
}, },
}, },
}, },
}); });
return deployments; return deployments;
} }
export async function findSharedDeployment(workflow_id: string) { export async function findSharedDeployment(workflow_id: string) {
const deploymentData = await db.query.deploymentsTable.findFirst({ const deploymentData = await db.query.deploymentsTable.findFirst({
where: and( where: and(
eq(deploymentsTable.environment, "public-share"), eq(deploymentsTable.environment, "public-share"),
isValidUUID(workflow_id) isValidUUID(workflow_id)
? eq(deploymentsTable.id, workflow_id) ? eq(deploymentsTable.id, workflow_id)
: eq(deploymentsTable.share_slug, workflow_id), : eq(deploymentsTable.share_slug, workflow_id),
), ),
with: { with: {
user: true, user: true,
machine: true, machine: true,
workflow: { workflow: {
columns: { columns: {
name: true, name: true,
org_id: true, org_id: true,
user_id: true, user_id: true,
}, },
}, },
version: true, version: true,
}, },
}); });
return deploymentData; return deploymentData;
} }
export const removePublicShareDeployment = withServerPromise( export const removePublicShareDeployment = withServerPromise(
async (deployment_id: string) => { async (deployment_id: string) => {
const [removed] = await db const [removed] = await db
.delete(deploymentsTable) .delete(deploymentsTable)
.where( .where(
and( and(
eq(deploymentsTable.environment, "public-share"), eq(deploymentsTable.environment, "public-share"),
eq(deploymentsTable.id, deployment_id), eq(deploymentsTable.id, deployment_id),
), ),
).returning(); )
.returning();
// revalidatePath( // revalidatePath(
// `/workflows/${removed.workflow_id}` // `/workflows/${removed.workflow_id}`
// ) // )
}, },
); );
export const cloneWorkflow = withServerPromise( export const cloneWorkflow = withServerPromise(
async (deployment_id: string) => { async (deployment_id: string) => {
const deployment = await db.query.deploymentsTable.findFirst({ const deployment = await db.query.deploymentsTable.findFirst({
where: and( where: and(
eq(deploymentsTable.environment, "public-share"), eq(deploymentsTable.environment, "public-share"),
eq(deploymentsTable.id, deployment_id), eq(deploymentsTable.id, deployment_id),
), ),
with: { with: {
version: true, version: true,
workflow: true, workflow: true,
}, },
}); });
if (!deployment) throw new Error("No deployment found"); if (!deployment) throw new Error("No deployment found");
const { userId, orgId } = auth(); const { userId, orgId } = auth();
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id");
await createNewWorkflow({ await createNewWorkflow({
user_id: userId, user_id: userId,
org_id: orgId, org_id: orgId,
workflow_name: `${deployment.workflow.name} (Cloned)`, workflow_name: `${deployment.workflow.name} (Cloned)`,
workflowData: { workflowData: {
workflow: deployment.version.workflow, workflow: deployment.version.workflow,
workflow_api: deployment?.version.workflow_api, workflow_api: deployment?.version.workflow_api,
snapshot: deployment?.version.snapshot, snapshot: deployment?.version.snapshot,
}, },
}); });
redirect(`/workflows/${deployment.workflow.id}`); redirect(`/workflows/${deployment.workflow.id}`);
return { return {
message: "Successfully cloned workflow", message: "Successfully cloned workflow",
}; };
}, },
); );
export const cloneMachine = withServerPromise(async (deployment_id: string) => { export const cloneMachine = withServerPromise(async (deployment_id: string) => {
const deployment = await db.query.deploymentsTable.findFirst({ const deployment = await db.query.deploymentsTable.findFirst({
where: and( where: and(
eq(deploymentsTable.environment, "public-share"), eq(deploymentsTable.environment, "public-share"),
eq(deploymentsTable.id, deployment_id), eq(deploymentsTable.id, deployment_id),
), ),
with: { with: {
machine: true, machine: true,
}, },
}); });
if (!deployment) throw new Error("No deployment found"); if (!deployment) throw new Error("No deployment found");
if (deployment.machine.type !== "comfy-deploy-serverless") if (deployment.machine.type !== "comfy-deploy-serverless")
throw new Error("Can only clone comfy-deploy-serverlesss"); throw new Error("Can only clone comfy-deploy-serverlesss");
const { userId, orgId } = auth(); const { userId, orgId } = auth();
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id");
await addCustomMachine({ await addCustomMachine({
gpu: deployment.machine.gpu, gpu: deployment.machine.gpu,
models: deployment.machine.models, models: deployment.machine.models,
snapshot: deployment.machine.snapshot, snapshot: deployment.machine.snapshot,
name: `${deployment.machine.name} (Cloned)`, name: `${deployment.machine.name} (Cloned)`,
type: "comfy-deploy-serverless", type: "comfy-deploy-serverless",
}); });
return { return {
message: "Successfully cloned workflow", message: "Successfully cloned workflow",
}; };
}); });
export async function findUserShareDeployment(share_id: string) { export async function findUserShareDeployment(share_id: string) {
const { userId, orgId } = auth(); const { userId, orgId } = auth();
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id");
const [deployment] = await db const [deployment] = await db
.select() .select()
.from(deploymentsTable) .from(deploymentsTable)
.where( .where(
and( and(
isValidUUID(share_id) isValidUUID(share_id)
? eq(deploymentsTable.id, share_id) ? eq(deploymentsTable.id, share_id)
: eq(deploymentsTable.share_slug, share_id), : eq(deploymentsTable.share_slug, share_id),
eq(deploymentsTable.environment, "public-share"), eq(deploymentsTable.environment, "public-share"),
orgId orgId
? eq(deploymentsTable.org_id, orgId) ? eq(deploymentsTable.org_id, orgId)
: and( : and(
eq(deploymentsTable.user_id, userId), eq(deploymentsTable.user_id, userId),
isNull(deploymentsTable.org_id), isNull(deploymentsTable.org_id),
), ),
), ),
); );
if (!deployment) throw new Error("No deployment found"); if (!deployment) throw new Error("No deployment found");
return deployment; return deployment;
} }
export const updateSharePageInfo = withServerPromise( export const updateSharePageInfo = withServerPromise(
async ({ async ({
id, id,
...data ...data
}: z.infer<typeof publicShareDeployment> & { }: z.infer<typeof publicShareDeployment> & {
id: string; id: string;
}) => { }) => {
const { userId } = auth(); const { userId } = auth();
if (!userId) return { error: "No user id" }; if (!userId) return { error: "No user id" };
console.log(data); console.log(data);
const [deployment] = await db const [deployment] = await db
.update(deploymentsTable) .update(deploymentsTable)
.set(data) .set(data)
.where( .where(
and( and(
eq(deploymentsTable.environment, "public-share"), eq(deploymentsTable.environment, "public-share"),
eq(deploymentsTable.id, id), eq(deploymentsTable.id, id),
), ),
) )
.returning(); .returning();
return { message: "Info Updated" }; return { message: "Info Updated" };
}, },
); );

View File

@ -0,0 +1,43 @@
"use server";
import { getMachineById } from "@/server/curdMachine";
import { auth } from "@clerk/nextjs";
import jwt from "jsonwebtoken";
import { getOrgOrUserDisplayName } from "@/server/getOrgOrUserDisplayName";
import { withServerPromise } from "@/server/withServerPromise";
import "server-only";
import { headers } from "next/headers";
export const editWorkflowOnMachine = withServerPromise(
async (workflow_version_id: string, machine_id: string) => {
const { userId, orgId } = auth();
const headersList = headers();
const host = headersList.get("host") || "";
const protocol = headersList.get("x-forwarded-proto") || "";
const domain = `${protocol}://${host}`;
if (!userId) {
throw new Error("No user id");
}
const machine = await getMachineById(machine_id);
const expireTime = "1w";
const token = jwt.sign(
{ user_id: userId, org_id: orgId },
process.env.JWT_SECRET!,
{
expiresIn: expireTime,
},
);
const userName = await getOrgOrUserDisplayName(orgId, userId);
return `${
machine.endpoint
}?workflow_version_id=${workflow_version_id}&auth_token=${token}&org_display=${encodeURIComponent(
userName,
)}&origin=${encodeURIComponent(domain)}`;
},
);