Compare commits

...
16 Commits
26 changed files with 1078 additions and 532 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ image = Image.debian_slim()
target_image = image if deploy_test else dockerfile_image target_image = image if deploy_test else dockerfile_image
@stub.cls(image=target_image, gpu=config["gpu"] ,volumes=volumes, timeout=60 * 10, container_idle_timeout=60 * 5) @stub.cls(image=target_image, gpu=config["gpu"] ,volumes=volumes, timeout=60 * 10, container_idle_timeout=60)
class ComfyDeployRunner: class ComfyDeployRunner:
@enter() @enter()
+7 -6
View File
@@ -1,5 +1,6 @@
import { AccessType } from "../../../lib/AccessType"; import { AccessType } from "../../../lib/AccessType";
import { MachineList } from "@/components/MachineList"; import { MachineList } from "@/components/MachineList";
import { SubscriptionProvider } from "@/components/useCurrentPlan";
import { db } from "@/db/db"; import { db } from "@/db/db";
import { machinesTable } from "@/db/schema"; import { machinesTable } from "@/db/schema";
import { getCurrentPlanWithAuth } from "@/server/getCurrentPlan"; import { getCurrentPlanWithAuth } from "@/server/getCurrentPlan";
@@ -32,12 +33,12 @@ async function MachineListServer() {
return ( return (
<div className="w-full"> <div className="w-full">
{/* <div>Machines</div> */} <SubscriptionProvider sub={sub}>
<MachineList <MachineList
sub={sub} data={machines}
data={machines} userMetadata={AccessType.parse(user.privateMetadata ?? {})}
userMetadata={AccessType.parse(user.privateMetadata ?? {})} />
/> </SubscriptionProvider>
</div> </div>
); );
} }
+3 -3
View File
@@ -1,12 +1,12 @@
export const metadata = { export const metadata = {
title: 'Quickstart', title: 'Quickstart',
description: description:
'This guide will get you all set up and ready to use the Protocol API. Well cover how to get started an API client and how to make your first API request.', 'This guide will get you all set up and ready to use Comfy Deploy. Well cover how to get started an API client and how to make your first API request.',
} }
# Getting stated # Getting started
Install Comfy Deploy's plugin on your local machine to get started with deploying workflow. Install Comfy Deploy's plugin on your local ComfyUI to get started with deploying workflow.
<CodeGroup> <CodeGroup>
+58
View File
@@ -0,0 +1,58 @@
export const metadata = {
title: 'Comfy Deploy Video tutorials',
description:
'Get started with API integration to run any deploy ComfyUI workflow',
}
**This is a collection of video tutorials on questions that have come up in our [discord channel](https://discord.gg/Zrar7yP6MJ).**
# Comfy Deploy workflow walkthrough (comprehensive)
Local workflow, to production API
- Set inputs with Comfy Deploy `External Text` nodes
- Deploy the workflow
- Create a machine
- Deploy an API endpoint for the workflow
- Test the workflow in Comfy Deploy
<video controls>
<source src="https://pub-1a2bca0642c24fcfb84ce8d8415958d3.r2.dev/comfydeploy_base-sd-setup.mp4" type="video/mp4"/>
Your browser does not support the video tag.
</video>
# Test with Docs
Shows how you can use the <a href='/docs/endpoints'>endpoints docs</a> to test out the API!
<video controls>
<source src="https://pub-1a2bca0642c24fcfb84ce8d8415958d3.r2.dev/comfydeploy_interactive_docs.mp4" type="video/mp4"/>
Your browser does not support the video tag.
</video>
## Intermediate IPAdaptor example run
Shows off a semi-complex workflow working on Comfy Deploy
<video controls>
<source src="https://pub-1a2bca0642c24fcfb84ce8d8415958d3.r2.dev/ipadapters-test.mp4" type="video/mp4"/>
Your browser does not support the video tag.
</video>
## Animate diff on Comfy Deploy
Shows off animate diff working on Comfy Deploy
<video controls>
<source src="https://pub-1a2bca0642c24fcfb84ce8d8415958d3.r2.dev/comfydeploy-animediff.mp4" type="video/mp4"/>
Your browser does not support the video tag.
</video>
## Cloning workflows
What does cloning a workflow look like
<video controls>
<source src="https://pub-1a2bca0642c24fcfb84ce8d8415958d3.r2.dev/comfydeploy-clone-template.mp4" type="video/mp4"/>
Your browser does not support the video tag.
</video>
## [Install custom nodes from any github repo (Loom)](https://www.loom.com/share/c2025c5060e348839ade6a3a9d96441d?sid=6a57f779-0209-4341-a5fd-f99769cdb162)
- This is only neccessary if the custom node is not in the ComfyUI Manager node list
@@ -0,0 +1,24 @@
"use client";
import { getCurrentPlanWithAuth } from "@/server/getCurrentPlan";
import * as React from "react";
import { createContext } from "react";
export type CurrentPlanContextType = Awaited<
ReturnType<typeof getCurrentPlanWithAuth>
>;
export const CurrentPlanContext = createContext<
CurrentPlanContextType | undefined
>(undefined);
export function SubscriptionProvider({
sub,
children,
}: {
sub: CurrentPlanContextType;
children: React.ReactNode;
}) {
return (
<CurrentPlanContext.Provider value={sub}>
{children}
</CurrentPlanContext.Provider>
);
}
+20 -21
View File
@@ -118,15 +118,12 @@ export function DeploymentDisplay({
</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; Install the node comfydeploy SDK
<a <CodeBlock
href="https://github.com/BennyKok/comfyui-deploy-next-example/blob/main/src/lib/comfy-deploy.ts" lang="bash"
className="text-blue-500 hover:underline" code={`npm i comfydeploy`}
target="_blank" />
rel="noreferrer" Initialize your client
>
here
</a>
</div> </div>
<CodeBlock <CodeBlock
lang="js" lang="js"
@@ -200,18 +197,20 @@ function formatCode(
if (inputs && inputs.length > 0) { if (inputs && inputs.length > 0) {
codeTemplate = codeTemplate.replace( codeTemplate = codeTemplate.replace(
"inputs: {}", "inputs: {}",
`inputs: ${JSON.stringify( `inputs: ${
Object.fromEntries( JSON.stringify(
inputs.map((x) => { Object.fromEntries(
return [x?.input_id, ""]; inputs.map((x) => {
}), return [x?.input_id, ""];
), }),
null, ),
2, null,
) 2,
.split("\n") )
.map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line .split("\n")
.join("\n")}`, .map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line
.join("\n")
}`,
); );
} else { } else {
codeTemplate = codeTemplate.replace( codeTemplate = codeTemplate.replace(
+2 -1
View File
@@ -31,6 +31,7 @@ export function InsertModal<
tooltip?: string; tooltip?: string;
disabled?: boolean; disabled?: boolean;
title: React.ReactNode; title: React.ReactNode;
buttonTitle?: React.ReactNode;
description: string; description: string;
dialogClassName?: string; dialogClassName?: string;
serverAction: (data: z.infer<Z>) => Promise<unknown>; serverAction: (data: z.infer<Z>) => Promise<unknown>;
@@ -54,7 +55,7 @@ export function InsertModal<
setOpen(true); setOpen(true);
}} }}
> >
{props.title} {props.buttonTitle ?? props.title}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
+22 -10
View File
@@ -149,9 +149,16 @@ export const columns: ColumnDef<Machine>[] = [
header: () => <div className="text-left">Type</div>, header: () => <div className="text-left">Type</div>,
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="text-left font-medium truncate"> <Badge
className="text-left font-medium truncate"
variant={
row.original.type == "comfy-deploy-serverless"
? "success"
: "outline"
}
>
{row.original.type} {row.original.type}
</div> </Badge>
); );
}, },
}, },
@@ -183,6 +190,7 @@ export const columns: ColumnDef<Machine>[] = [
cell: ({ row }) => { cell: ({ row }) => {
const machine = row.original; const machine = row.original;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sub = useCurrentPlan();
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -291,7 +299,10 @@ export const columns: ColumnDef<Machine>[] = [
fieldType: "models", fieldType: "models",
}, },
gpu: { gpu: {
inputProps: {}, fieldType: "gpuPicker",
inputProps: {
sub: sub,
},
}, },
}} }}
/> />
@@ -319,14 +330,14 @@ export const columns: ColumnDef<Machine>[] = [
}, },
]; ];
import { useCurrentPlan } from "./useCurrentPlan";
export function MachineList({ export function MachineList({
data, data,
userMetadata, userMetadata,
sub,
}: { }: {
data: Machine[]; data: Machine[];
userMetadata: z.infer<typeof AccessType>; userMetadata: z.infer<typeof AccessType>;
sub: Awaited<ReturnType<typeof getCurrentPlanWithAuth>>;
}) { }) {
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -336,6 +347,8 @@ export function MachineList({
React.useState<VisibilityState>({}); React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const sub = useCurrentPlan();
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@@ -390,11 +403,12 @@ export function MachineList({
? `Max ${machineMaxCount} ComfyUI machine for your account, upgrade to unlock more cnfiguration.` ? `Max ${machineMaxCount} ComfyUI machine for your account, upgrade to unlock more cnfiguration.`
: `Max ${machineMaxCount} ComfyUI machine for your account` : `Max ${machineMaxCount} ComfyUI machine for your account`
} }
title={ buttonTitle={
<> <>
New Machine {locked ? <Lock size={14} /> : <Plus size={14} />} New Machine {locked ? <Lock size={14} /> : <Plus size={14} />}
</> </>
} }
title={"New Machine"}
description="Add custom ComfyUI machines to your account." description="Add custom ComfyUI machines to your account."
serverAction={addCustomMachine} serverAction={addCustomMachine}
formSchema={addCustomMachineSchema} formSchema={addCustomMachineSchema}
@@ -420,11 +434,9 @@ export function MachineList({
}, },
}, },
gpu: { gpu: {
fieldType: !userMetadata.betaFeaturesAccess fieldType: "gpuPicker",
? "fallback"
: "select",
inputProps: { inputProps: {
disabled: !userMetadata.betaFeaturesAccess, sub: sub,
}, },
}, },
}} }}
+59 -55
View File
@@ -15,7 +15,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { getAllUserModels as getAllUserModels } from "@/server/getAllUserModel"; import type { getAllUserModels } from "@/server/getAllUserModel";
import type { import type {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
@@ -46,8 +46,10 @@ export const columns: ColumnDef<ModelItemList>[] = [
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || checked={
(table.getIsSomePageRowsSelected() && "indeterminate")} table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Select all"
/> />
@@ -79,12 +81,10 @@ export const columns: ColumnDef<ModelItemList>[] = [
const model = row.original; const model = row.original;
return ( return (
<> <>
{ {/*<a
/*<a
className="hover:underline flex gap-2" className="hover:underline flex gap-2"
href={`/storage/${model.id}`} // TODO href={`/storage/${model.id}`} // TODO
>*/ >*/}
}
<span className="truncate max-w-[200px]"> <span className="truncate max-w-[200px]">
{row.original.model_name} {row.original.model_name}
</span> </span>
@@ -110,9 +110,13 @@ export const columns: ColumnDef<ModelItemList>[] = [
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<Badge <Badge
variant={row.original.status === "failed" variant={
? "red" row.original.status === "failed"
: (row.original.status === "started" ? "yellow" : "green")} ? "red"
: row.original.status === "started"
? "yellow"
: "green"
}
> >
{row.original.status} {row.original.status}
</Badge> </Badge>
@@ -184,10 +188,10 @@ export const columns: ColumnDef<ModelItemList>[] = [
}, },
cell: ({ row }) => { cell: ({ row }) => {
const model_type_map: Record<modelEnumType, any> = { const model_type_map: Record<modelEnumType, any> = {
"checkpoint": "amber", checkpoint: "amber",
"lora": "green", lora: "green",
"embedding": "violet", embedding: "violet",
"vae": "teal", vae: "teal",
}; };
function getBadgeColor(modelType: modelEnumType) { function getBadgeColor(modelType: modelEnumType) {
@@ -257,9 +261,8 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[], [],
); );
const [columnVisibility, setColumnVisibility] = React.useState< const [columnVisibility, setColumnVisibility] =
VisibilityState React.useState<VisibilityState>({});
>({});
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const table = useReactTable({ const table = useReactTable({
@@ -286,10 +289,12 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
<div className="flex flex-row w-full items-center py-4"> <div className="flex flex-row w-full items-center py-4">
<Input <Input
placeholder="Filter workflows..." placeholder="Filter workflows..."
value={(table.getColumn("model_name")?.getFilterValue() as string) ?? value={
""} (table.getColumn("model_name")?.getFilterValue() as string) ?? ""
}
onChange={(event) => onChange={(event) =>
table.getColumn("model_name")?.setFilterValue(event.target.value)} table.getColumn("model_name")?.setFilterValue(event.target.value)
}
className="max-w-sm" className="max-w-sm"
/> />
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
@@ -304,7 +309,7 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
formSchema={downloadUrlModelSchema} formSchema={downloadUrlModelSchema}
fieldConfig={{ fieldConfig={{
url: { url: {
fieldType: "fallback", fieldType: "modelUrlPicker",
inputProps: { required: true }, inputProps: { required: true },
description: ( description: (
<> <>
@@ -313,6 +318,7 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
href="https://www.civitai.com/models" href="https://www.civitai.com/models"
target="_blank" target="_blank"
className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
rel="noreferrer"
> >
civitai.com civitai.com
</a>{" "} </a>{" "}
@@ -324,9 +330,7 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
fieldType: "select", fieldType: "select",
inputProps: { required: true }, inputProps: { required: true },
description: ( description: (
<> <>We'll figure this out if you pick a civitai model</>
We'll figure this out if you pick a civitai model
</>
), ),
}, },
}} }}
@@ -341,10 +345,12 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead key={header.id}> <TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender( {header.isPlaceholder
header.column.columnDef.header, ? null
header.getContext(), : flexRender(
)} header.column.columnDef.header,
header.getContext(),
)}
</TableHead> </TableHead>
); );
})} })}
@@ -352,34 +358,32 @@ export function ModelList({ data }: { data: ModelItemList[] }) {
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length {table.getRowModel().rows?.length ? (
? ( table.getRowModel().rows.map((row) => (
table.getRowModel().rows.map((row) => ( <TableRow
<TableRow key={row.id}
key={row.id} data-state={row.getIsSelected() && "selected"}
data-state={row.getIsSelected() && "selected"} >
> {row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}>
<TableCell key={cell.id}> {flexRender(
{flexRender( cell.column.columnDef.cell,
cell.column.columnDef.cell, cell.getContext(),
cell.getContext(), )}
)} </TableCell>
</TableCell> ))}
))}
</TableRow>
))
)
: (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow> </TableRow>
)} ))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</ScrollArea> </ScrollArea>
+22 -6
View File
@@ -50,7 +50,7 @@ import {
Play, Play,
} from "lucide-react"; } from "lucide-react";
import { parseAsInteger, useQueryState } from "next-usequerystate"; import { parseAsInteger, useQueryState } from "next-usequerystate";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import type { z } from "zod"; import type { z } from "zod";
@@ -60,6 +60,7 @@ import { callServerPromise } from "./callServerPromise";
import fetcher from "./fetcher"; import fetcher from "./fetcher";
import { ButtonAction } from "@/components/ButtonActionLoader"; import { ButtonAction } from "@/components/ButtonActionLoader";
import { editWorkflowOnMachine } from "@/server/editWorkflowOnMachine"; import { editWorkflowOnMachine } from "@/server/editWorkflowOnMachine";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function VersionSelect({ export function VersionSelect({
workflow, workflow,
@@ -126,12 +127,27 @@ export function MachineSelect({
export function useSelectedMachine( export function useSelectedMachine(
machines: Awaited<ReturnType<typeof getMachines>>, machines: Awaited<ReturnType<typeof getMachines>>,
) { ): [string, (v: string) => void] {
const a = useQueryState("machine", { const searchParams = useSearchParams();
defaultValue: machines?.[0]?.id ?? "", const pathname = usePathname();
}); const router = useRouter();
return a; const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
return params.toString();
},
[searchParams],
);
return [
searchParams.get("machine") ?? machines?.[0]?.id ?? "",
(v: string) => {
router.push(pathname + "?" + createQueryString("machine", v));
},
];
} }
type PublicRunStore = { type PublicRunStore = {
@@ -0,0 +1,72 @@
"use client";
import type { AutoFormInputComponentProps } from "../ui/auto-form/types";
import * as React from "react";
import { useDebouncedCallback } from "use-debounce";
import { z } from "zod";
import { CivitalModelSchema, ModelListWrapper } from "./CivitalModelSchema";
import { getUrl, mapModelsList } from "./getUrl";
import { ModelSelector } from "./ModelSelector";
export function CivitaiModelRegistry({
field,
selectMultiple = true,
}: Pick<AutoFormInputComponentProps, "field"> & {
selectMultiple?: boolean;
}) {
const [modelList, setModelList] =
React.useState<z.infer<typeof ModelListWrapper>>();
const [loading, setLoading] = React.useState(false);
const handleSearch = useDebouncedCallback((search) => {
console.log(`Searching... ${search}`);
setLoading(true);
const controller = new AbortController();
fetch(getUrl(search), {
signal: controller.signal,
})
.then((x) => x.json())
.then((a) => {
const list = CivitalModelSchema.parse(a);
console.log(a);
setModelList(mapModelsList(list));
setLoading(false);
});
return () => {
controller.abort();
setLoading(false);
};
}, 300);
React.useEffect(() => {
const controller = new AbortController();
fetch(getUrl(), {
signal: controller.signal,
})
.then((x) => x.json())
.then((a) => {
const list = CivitalModelSchema.parse(a);
setModelList(mapModelsList(list));
});
return () => {
controller.abort();
};
}, []);
return (
<ModelSelector
selectMultiple={selectMultiple}
field={field}
modelList={modelList}
label="Civitai"
onSearch={handleSearch}
shouldFilter={false}
isLoading={loading}
/>
);
}
@@ -0,0 +1,94 @@
"use client";
import { z } from "zod";
export const Model = z.object({
name: z.string(),
type: z.string(),
base: z.string(),
save_path: z.string(),
description: z.string(),
reference: z.string(),
filename: z.string(),
url: z.string(),
});
export const CivitalModelSchema = z.object({
items: z.array(
z.object({
id: z.number(),
name: z.string(),
description: z.string(),
type: z.string(),
creator: z
.object({
username: z.string().nullable(),
image: z.string().nullable().default(null),
})
.nullable(),
tags: z.array(z.string()),
modelVersions: z.array(
z.object({
id: z.number(),
modelId: z.number(),
name: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
status: z.string(),
publishedAt: z.string(),
trainedWords: z.array(z.unknown()),
trainingStatus: z.string().nullable(),
trainingDetails: z.string().nullable(),
baseModel: z.string(),
baseModelType: z.string().nullable(),
earlyAccessTimeFrame: z.number(),
description: z.string().nullable(),
vaeId: z.number().nullable(),
stats: z.object({
downloadCount: z.number(),
ratingCount: z.number(),
rating: z.number(),
}),
files: z.array(
z.object({
id: z.number(),
sizeKB: z.number(),
name: z.string(),
type: z.string(),
downloadUrl: z.string(),
}),
),
images: z.array(
z.object({
id: z.number(),
url: z.string(),
nsfw: z.string(),
width: z.number(),
height: z.number(),
hash: z.string(),
type: z.string(),
metadata: z.object({
hash: z.string(),
width: z.number(),
height: z.number(),
}),
meta: z.any(),
}),
),
downloadUrl: z.string(),
}),
),
}),
),
metadata: z.object({
totalItems: z.number(),
currentPage: z.number(),
pageSize: z.number(),
totalPages: z.number(),
nextPage: z.string().optional(),
}),
});
export const ModelList = z.array(Model);
export const ModelListWrapper = z.object({
models: ModelList,
});
@@ -0,0 +1,43 @@
"use client";
import type { AutoFormInputComponentProps } from "../ui/auto-form/types";
import * as React from "react";
import { z } from "zod";
import { ModelListWrapper } from "./CivitalModelSchema";
import { ModelSelector } from "./ModelSelector";
export function ComfyUIManagerModelRegistry({
field,
selectMultiple = true,
}: Pick<AutoFormInputComponentProps, "field"> & {
selectMultiple?: boolean;
}) {
const [modelList, setModelList] =
React.useState<z.infer<typeof ModelListWrapper>>();
React.useEffect(() => {
const controller = new AbortController();
fetch(
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/model-list.json",
{
signal: controller.signal,
},
)
.then((x) => x.json())
.then((a) => {
setModelList(ModelListWrapper.parse(a));
});
return () => {
controller.abort();
};
}, []);
return (
<ModelSelector
selectMultiple={selectMultiple}
field={field}
modelList={modelList}
label="ComfyUI Manager"
/>
);
}
@@ -1,160 +1,17 @@
"use client"; "use client";
import type { AutoFormInputComponentProps } from "../ui/auto-form/types"; import type { AutoFormInputComponentProps } from "../ui/auto-form/types";
import { LoadingIcon } from "@/components/LoadingIcon";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useRef } from "react"; import { CivitaiModelRegistry } from "./CivitaiModelRegistry";
import { useDebouncedCallback } from "use-debounce"; import { ComfyUIManagerModelRegistry } from "./ComfyUIManagerModelRegistry";
import { z } from "zod";
const Model = z.object({
name: z.string(),
type: z.string(),
base: z.string(),
save_path: z.string(),
description: z.string(),
reference: z.string(),
filename: z.string(),
url: z.string(),
});
export const CivitalModelSchema = z.object({
items: z.array(
z.object({
id: z.number(),
name: z.string(),
description: z.string(),
type: z.string(),
// poi: z.boolean(),
// nsfw: z.boolean(),
// allowNoCredit: z.boolean(),
// allowCommercialUse: z.string(),
// allowDerivatives: z.boolean(),
// allowDifferentLicense: z.boolean(),
// stats: z.object({
// downloadCount: z.number(),
// favoriteCount: z.number(),
// commentCount: z.number(),
// ratingCount: z.number(),
// rating: z.number(),
// tippedAmountCount: z.number(),
// }),
creator: z
.object({
username: z.string().nullable(),
image: z.string().nullable().default(null),
})
.nullable(),
tags: z.array(z.string()),
modelVersions: z.array(
z.object({
id: z.number(),
modelId: z.number(),
name: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
status: z.string(),
publishedAt: z.string(),
trainedWords: z.array(z.unknown()),
trainingStatus: z.string().nullable(),
trainingDetails: z.string().nullable(),
baseModel: z.string(),
baseModelType: z.string().nullable(),
earlyAccessTimeFrame: z.number(),
description: z.string().nullable(),
vaeId: z.number().nullable(),
stats: z.object({
downloadCount: z.number(),
ratingCount: z.number(),
rating: z.number(),
}),
files: z.array(
z.object({
id: z.number(),
sizeKB: z.number(),
name: z.string(),
type: z.string(),
// metadata: z.object({
// fp: z.string().nullable().optional(),
// size: z.string().nullable().optional(),
// format: z.string().nullable().optional(),
// }),
// pickleScanResult: z.string(),
// pickleScanMessage: z.string(),
// virusScanResult: z.string(),
// virusScanMessage: z.string().nullable(),
// scannedAt: z.string(),
// hashes: z.object({
// AutoV1: z.string().nullable().optional(),
// AutoV2: z.string().nullable().optional(),
// SHA256: z.string().nullable().optional(),
// CRC32: z.string().nullable().optional(),
// BLAKE3: z.string().nullable().optional(),
// }),
downloadUrl: z.string(),
// primary: z.boolean().default(false),
})
),
images: z.array(
z.object({
id: z.number(),
url: z.string(),
nsfw: z.string(),
width: z.number(),
height: z.number(),
hash: z.string(),
type: z.string(),
metadata: z.object({
hash: z.string(),
width: z.number(),
height: z.number(),
}),
meta: z.any(),
})
),
downloadUrl: z.string(),
})
),
})
),
metadata: z.object({
totalItems: z.number(),
currentPage: z.number(),
pageSize: z.number(),
totalPages: z.number(),
nextPage: z.string().optional(),
}),
});
const ModelList = z.array(Model);
export const ModelListWrapper = z.object({
models: ModelList,
});
export function ModelPickerView({ export function ModelPickerView({
field, field,
@@ -187,240 +44,3 @@ export function ModelPickerView({
</Accordion> </Accordion>
); );
} }
function mapType(type: string) {
switch (type) {
case "checkpoint":
return "checkpoints";
}
return type;
}
function mapModelsList(
models: z.infer<typeof CivitalModelSchema>
): z.infer<typeof ModelListWrapper> {
return {
models: models.items.flatMap((item) => {
return item.modelVersions.map((v) => {
return {
name: `${item.name} ${v.name} (${v.files[0].name})`,
type: mapType(item.type.toLowerCase()),
base: v.baseModel,
save_path: "default",
description: item.description,
reference: "",
filename: v.files[0].name,
url: v.files[0].downloadUrl,
} as z.infer<typeof Model>;
});
}),
};
}
function getUrl(search?: string) {
const baseUrl = "https://civitai.com/api/v1/models";
const searchParams = {
limit: 5,
} as any;
searchParams["sort"] = "Most Downloaded";
if (search) {
searchParams["query"] = search;
} else {
// sort: "Highest Rated",
}
const url = new URL(baseUrl);
Object.keys(searchParams).forEach((key) =>
url.searchParams.append(key, searchParams[key])
);
return url;
}
export function CivitaiModelRegistry({
field,
}: Pick<AutoFormInputComponentProps, "field">) {
const [modelList, setModelList] =
React.useState<z.infer<typeof ModelListWrapper>>();
const [loading, setLoading] = React.useState(false);
const handleSearch = useDebouncedCallback((search) => {
console.log(`Searching... ${search}`);
setLoading(true);
const controller = new AbortController();
fetch(getUrl(search), {
signal: controller.signal,
})
.then((x) => x.json())
.then((a) => {
const list = CivitalModelSchema.parse(a);
console.log(a);
setModelList(mapModelsList(list));
setLoading(false);
});
return () => {
controller.abort();
setLoading(false);
};
}, 300);
React.useEffect(() => {
const controller = new AbortController();
fetch(getUrl(), {
signal: controller.signal,
})
.then((x) => x.json())
.then((a) => {
const list = CivitalModelSchema.parse(a);
setModelList(mapModelsList(list));
});
return () => {
controller.abort();
};
}, []);
return (
<ModelSelector
field={field}
modelList={modelList}
label="Civitai"
onSearch={handleSearch}
shouldFilter={false}
isLoading={loading}
/>
);
}
export function ComfyUIManagerModelRegistry({
field,
}: Pick<AutoFormInputComponentProps, "field">) {
const [modelList, setModelList] =
React.useState<z.infer<typeof ModelListWrapper>>();
React.useEffect(() => {
const controller = new AbortController();
fetch(
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/model-list.json",
{
signal: controller.signal,
}
)
.then((x) => x.json())
.then((a) => {
setModelList(ModelListWrapper.parse(a));
});
return () => {
controller.abort();
};
}, []);
return (
<ModelSelector
field={field}
modelList={modelList}
label="ComfyUI Manager"
/>
);
}
export function ModelSelector({
field,
modelList,
label,
onSearch,
shouldFilter = true,
isLoading,
}: Pick<AutoFormInputComponentProps, "field"> & {
modelList?: z.infer<typeof ModelListWrapper>;
label: string;
onSearch?: (search: string) => void;
shouldFilter?: boolean;
isLoading?: boolean;
}) {
const value = (field.value as z.infer<typeof ModelList>) ?? [];
const [open, setOpen] = React.useState(false);
function toggleModel(model: z.infer<typeof Model>) {
const prevSelectedModels = value;
if (
prevSelectedModels.some(
(selectedModel) =>
selectedModel.url + selectedModel.name === model.url + model.name
)
) {
field.onChange(
prevSelectedModels.filter(
(selectedModel) =>
selectedModel.url + selectedModel.name !== model.url + model.name
)
);
} else {
field.onChange([...prevSelectedModels, model]);
}
}
const containerRef = useRef<HTMLDivElement>(null);
return (
<div className="" ref={containerRef}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between flex"
>
Add from {label}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[375px] p-0" side="bottom">
<Command shouldFilter={shouldFilter}>
<CommandInput
placeholder="Search models..."
className="h-9"
onValueChange={onSearch}
>
{isLoading && <LoadingIcon />}
</CommandInput>
<CommandEmpty>No models found.</CommandEmpty>
<CommandList className="pointer-events-auto">
<CommandGroup>
{modelList?.models.map((model) => (
<CommandItem
key={model.url + model.name}
value={model.url}
onSelect={() => {
toggleModel(model);
}}
>
{model.name}
<Check
className={cn(
"ml-auto h-4 w-4",
value.some(
(selectedModel) => selectedModel.url === model.url
)
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import type { AutoFormInputComponentProps } from "../ui/auto-form/types";
import { LoadingIcon } from "@/components/LoadingIcon";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { useRef } from "react";
import { z } from "zod";
import { ModelListWrapper, Model, ModelList } from "./CivitalModelSchema";
export function ModelSelector({
field,
modelList,
label,
onSearch,
shouldFilter = true,
isLoading,
selectMultiple = true,
}: Pick<AutoFormInputComponentProps, "field"> & {
modelList?: z.infer<typeof ModelListWrapper>;
label: string;
onSearch?: (search: string) => void;
shouldFilter?: boolean;
isLoading?: boolean;
selectMultiple?: boolean;
}) {
const value = (field.value as z.infer<typeof ModelList>) ?? [];
const [open, setOpen] = React.useState(false);
function toggleModel(model: z.infer<typeof Model>) {
const prevSelectedModels = value;
if (
prevSelectedModels.some(
(selectedModel) =>
selectedModel.url + selectedModel.name === model.url + model.name,
)
) {
field.onChange(
prevSelectedModels.filter(
(selectedModel) =>
selectedModel.url + selectedModel.name !== model.url + model.name,
),
);
} else {
if (!selectMultiple) {
field.onChange([model]);
} else {
field.onChange([...prevSelectedModels, model]);
}
}
}
const containerRef = useRef<HTMLDivElement>(null);
return (
<div className="" ref={containerRef}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between flex"
>
Add from {label}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[375px] p-0" side="bottom">
<Command shouldFilter={shouldFilter}>
<CommandInput
placeholder="Search models..."
className="h-9"
onValueChange={onSearch}
>
{isLoading && <LoadingIcon />}
</CommandInput>
<CommandEmpty>No models found.</CommandEmpty>
<CommandList className="pointer-events-auto">
<CommandGroup>
{modelList?.models.map((model) => (
<CommandItem
key={model.url + model.name}
value={model.url}
onSelect={() => {
toggleModel(model);
}}
>
{model.name}
<Check
className={cn(
"ml-auto h-4 w-4",
value.some(
(selectedModel) => selectedModel.url === model.url,
)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
@@ -8,7 +8,9 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -17,6 +19,22 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -25,7 +43,15 @@ import {
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { findAllDeployments } from "@/server/curdDeploments"; import { findAllDeployments } from "@/server/curdDeploments";
import { Check, ChevronsUpDown } from "lucide-react"; import {
Check,
ChevronsUpDown,
Edit,
ExternalLink,
FolderInput,
MoreVertical,
Plus,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
@@ -39,19 +65,95 @@ export function SnapshotPickerView({
<AccordionItem value="item-1"> <AccordionItem value="item-1">
<AccordionTrigger className="text-sm">Custom Nodes</AccordionTrigger> <AccordionTrigger className="text-sm">Custom Nodes</AccordionTrigger>
<AccordionContent className="flex gap-2 flex-col px-1"> <AccordionContent className="flex gap-2 flex-col px-1">
<SnapshotPresetPicker field={field} /> <div className="flex flex-wrap gap-2 justify-end">
<CustomNodesSelector field={field} /> <CustomNodesSelector field={field} />
<SnapshotPresetPicker field={field} />
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" className="w-fit">
Edit <Edit size={14}></Edit>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] h-full max-h-[600px] grid grid-rows-[auto,1fr,auto]">
<DialogHeader>
<DialogTitle>Edit custom nodes</DialogTitle>
<DialogDescription>
Make advacne changes to the snapshots
</DialogDescription>
</DialogHeader>
<Textarea
className="h-full p-2 max-h-[600px] rounded-md text-xs w-full"
value={JSON.stringify(field.value, null, 2)}
onChange={(e) => {
// Update field.onChange to pass the array of selected models
field.onChange(JSON.parse(e.target.value));
}}
/>
<DialogFooter>
<DialogClose>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{field.value && ( {field.value && (
// <ScrollArea className="w-full bg-gray-100 mx-auto max-w-[500px] rounded-lg"> <div className="flex gap-2 flex-col">
<Textarea {Object.entries(field.value.git_custom_nodes).map(
className="min-h-[150px] max-h-[300px] p-2 rounded-md text-xs w-full" ([key, item]: [string, any], index) => (
value={JSON.stringify(field.value, null, 2)} <Card className="p-4 flex gap-4 items-center justify-between">
onChange={(e) => { <div className="flex gap-4 items-center">
// Update field.onChange to pass the array of selected models <div className="bg-gray-200 aspect-square w-6 h-6 rounded-full text-center">
field.onChange(JSON.parse(e.target.value)); {index + 1}
}} </div>
/> <div>
// </ScrollArea> <a
target="_blank"
href={key}
className="hover:underline flex items-center gap-2"
rel="noreferrer"
>
<ExternalLink size={12} /> {key}
</a>
<div className="text-2xs text-primary/50">
{item.hash}
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild type="button">
<Button type="button" variant={"ghost"}>
<MoreVertical size={12} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
disabled={key.endsWith("comfyui-deploy.git")}
// className="opacity-50"
onClick={() => {
const newNodeList = {
...field.value.git_custom_nodes,
};
delete newNodeList[key];
const nodeList = newNodeList;
const newValue = {
...field.value,
git_custom_nodes: nodeList,
};
field.onChange(newValue);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Card>
),
)}
</div>
)} )}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -65,13 +167,14 @@ function SnapshotPresetPicker({
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [selected, setSelected] = React.useState<string | null>(null); const [selected, setSelected] = React.useState<string | null>(null);
const [frameworks, setFramework] = React.useState< const [frameworks, setFramework] =
{ React.useState<
id: string; {
label: string; id: string;
value: string; label: string;
}[] value: string;
>(); }[]
>();
React.useEffect(() => { React.useEffect(() => {
findAllDeployments().then((a) => { findAllDeployments().then((a) => {
@@ -108,12 +211,13 @@ function SnapshotPresetPicker({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full justify-between flex" className="w-fit justify-between flex"
> >
{selected <FolderInput size={14} />
Import
{/* {selected
? findItem(selected)?.label ? findItem(selected)?.label
: "Select snapshot (From deployments)"} : "Select snapshot (From deployments)"} */}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[375px] p-0"> <PopoverContent className="w-[375px] p-0">
@@ -140,7 +244,7 @@ function SnapshotPresetPicker({
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
field.value === framework.value field.value === framework.value
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>
@@ -199,12 +303,12 @@ function CustomNodesSelector({
const { data, error, isLoading } = useSWR<CustomNodeList>( const { data, error, isLoading } = useSWR<CustomNodeList>(
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/custom-node-list.json", "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/custom-node-list.json",
fetcher fetcher,
); );
const keys = React.useMemo( const keys = React.useMemo(
() => Object.keys(customNodeList), () => Object.keys(customNodeList),
[customNodeList, data] [customNodeList, data],
); );
function findItem(value: string) { function findItem(value: string) {
@@ -213,6 +317,11 @@ function CustomNodesSelector({
return included; return included;
} }
const onChangeRef = React.useRef(field.onChange);
React.useEffect(() => {
onChangeRef.current = field.onChange;
}, [field.onChange]);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -220,10 +329,10 @@ function CustomNodesSelector({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full justify-between flex" className="w-fit justify-between flex"
> >
Add custom nodes - {keys.length} selected <Plus size={14}></Plus> <Badge>{keys.length} </Badge>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> {/* <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> */}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[375px] p-0" side="bottom"> <PopoverContent className="w-[375px] p-0" side="bottom">
@@ -243,7 +352,7 @@ function CustomNodesSelector({
{ {
hash: string; hash: string;
disabled: boolean; disabled: boolean;
pip?: string[] pip?: string[];
} }
>; >;
const x = customNodeList; const x = customNodeList;
@@ -256,7 +365,7 @@ function CustomNodesSelector({
const repoName = extractRepoName(currentValue); const repoName = extractRepoName(currentValue);
const id = toast.loading(`Fetching repo info...`); const id = toast.loading(`Fetching repo info...`);
const repo = await fetch( const repo = await fetch(
`https://api.github.com/repos/${repoName}` `https://api.github.com/repos/${repoName}`,
) )
.then((x) => x.json()) .then((x) => x.json())
.then((x) => { .then((x) => {
@@ -267,14 +376,16 @@ function CustomNodesSelector({
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.dismiss(id); toast.dismiss(id);
toast.error(`Failed to fetch repo info ${e.message}`); toast.error(
`Failed to fetch repo info ${e.message}`,
);
return null; return null;
}); });
if (!repo) return; if (!repo) return;
const branch = repo.default_branch; const branch = repo.default_branch;
const branchInfo = await fetch( const branchInfo = await fetch(
`https://api.github.com/repos/${repoName}/branches/${branch}` `https://api.github.com/repos/${repoName}/branches/${branch}`,
) )
.then((x) => x.json()) .then((x) => x.json())
.then((x) => BranchInfoSchema.parse(x)) .then((x) => BranchInfoSchema.parse(x))
@@ -282,7 +393,7 @@ function CustomNodesSelector({
console.error(e); console.error(e);
toast.dismiss(id); toast.dismiss(id);
toast.error( toast.error(
`Failed to fetch branch info ${e.message}` `Failed to fetch branch info ${e.message}`,
); );
return null; return null;
}); });
@@ -291,19 +402,27 @@ function CustomNodesSelector({
if (!branchInfo) return; if (!branchInfo) return;
const value: Record<string, any> = {
hash: branchInfo?.commit.sha,
disabled: false,
};
if (framework.pip) {
value["pip"] = framework.pip;
}
nodeList = { nodeList = {
[currentValue]: {
hash: branchInfo?.commit.sha,
disabled: false,
pip: framework.pip
},
...x, ...x,
[currentValue]: value,
}; };
} }
field.onChange({
const newValue = {
...field.value, ...field.value,
git_custom_nodes: nodeList, git_custom_nodes: nodeList,
}); };
field.onChange(newValue);
}} }}
> >
{framework.title} {framework.title}
@@ -312,7 +431,7 @@ function CustomNodesSelector({
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
findItem(framework.reference) findItem(framework.reference)
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { z } from "zod";
import {
CivitalModelSchema,
ModelListWrapper,
Model,
} from "./CivitalModelSchema";
function mapType(type: string) {
switch (type) {
case "checkpoint":
return "checkpoints";
}
return type;
}
export function mapModelsList(
models: z.infer<typeof CivitalModelSchema>,
): z.infer<typeof ModelListWrapper> {
return {
models: models.items.flatMap((item) => {
return item.modelVersions.map((v) => {
return {
name: `${item.name} ${v.name} (${v.files[0].name})`,
type: mapType(item.type.toLowerCase()),
base: v.baseModel,
save_path: "default",
description: item.description,
reference: "",
filename: v.files[0].name,
// Quick hack to get the download url back as normal url
url: `https://civitai.com/models/${v.modelId}?modelVersionId=${v.id}`, //v.files[0].downloadUrl,
} as z.infer<typeof Model>;
});
}),
};
}
export function getUrl(search?: string) {
const baseUrl = "https://civitai.com/api/v1/models";
const searchParams = {
limit: 5,
} as any;
searchParams["sort"] = "Most Downloaded";
if (search) {
searchParams["query"] = search;
} else {
// sort: "Highest Rated",
}
const url = new URL(baseUrl);
Object.keys(searchParams).forEach((key) =>
url.searchParams.append(key, searchParams[key]),
);
return url;
}
@@ -0,0 +1,98 @@
import { AutoFormInputComponentProps } from "@/components/ui/auto-form/types";
import { getBaseSchema } from "@/components/ui/auto-form/utils";
import { Badge } from "@/components/ui/badge";
import {
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Lock } from "lucide-react";
import * as z from "zod";
export default function AutoFormGPUPicker({
label,
isRequired,
field,
fieldConfigItem,
zodItem,
}: AutoFormInputComponentProps) {
const baseValues = (getBaseSchema(zodItem) as unknown as z.ZodEnum<any>)._def
.values;
let values: [string, string][] = [];
if (!Array.isArray(baseValues)) {
values = Object.entries(baseValues);
} else {
values = baseValues.map((value) => [value, value]);
}
function findItem(value: any) {
return values.find((item) => item[0] === value);
}
const plan = fieldConfigItem.inputProps?.sub?.plan;
const enabledGPU = ["T4"];
const planMapping: Record<string, string> = {
A10G: "pro",
A100: "enterprise",
};
if (plan == "pro") {
enabledGPU.push("A10G");
} else if (plan == "enterprise") {
enabledGPU.push("A10G");
enabledGPU.push("A100");
}
return (
<FormItem>
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue
className="w-full"
placeholder={fieldConfigItem.inputProps?.placeholder}
>
{field.value ? findItem(field.value)?.[1] : "Select an option"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{values.map(([value, label]) => {
const enabled = enabledGPU.includes(value);
return (
<SelectItem value={label} key={value} disabled={!enabled}>
{label}
{!enabled && (
<span className="mx-2 inline-flex items-center justify-center gap-2">
<Badge className="capitalize">{planMapping[value]}</Badge>{" "}
plan required
<Lock size={14}></Lock>
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormControl>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,97 @@
"use client";
import type { AutoFormInputComponentProps } from "../ui/auto-form/types";
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
import { LoadingIcon } from "@/components/LoadingIcon";
// import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import * as React from "react";
import { Suspense } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { CivitaiModelRegistry } from "./CivitaiModelRegistry";
import { ComfyUIManagerModelRegistry } from "./ComfyUIManagerModelRegistry";
import { Input } from "@/components/ui/input";
import { ModelList } from "@/components/custom-form/CivitalModelSchema";
import { z } from "zod";
export default function AutoFormModelsPickerUrl({
label,
isRequired,
field,
fieldConfigItem,
zodItem,
fieldProps,
}: AutoFormInputComponentProps) {
return (
<FormItem>
{fieldConfigItem.inputProps?.showLabel && (
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
)}
<FormControl>
<Suspense fallback={<LoadingIcon />}>
<ModelPickerView field={field} fieldProps={fieldProps} />
</Suspense>
</FormControl>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
function ModelPickerView({
field,
fieldProps,
}: Pick<AutoFormInputComponentProps, "field" | "fieldProps">) {
const customOverride = React.useMemo(() => {
const customOnChange = (value: z.infer<typeof ModelList>) => {
const model = value[0];
field.onChange(model?.url);
};
return {
...field,
onChange: customOnChange,
value: field.value
? [
{
url: field.value,
},
]
: [],
};
}, [field]);
return (
<div className="flex gap-2 flex-col px-1">
<ComfyUIManagerModelRegistry
field={customOverride}
selectMultiple={false}
/>
<CivitaiModelRegistry field={customOverride} selectMultiple={false} />
<Input
// className="min-h-[150px] max-h-[300px] p-2 rounded-lg text-xs w-full"
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e.target.value);
}}
type="text"
/>
</div>
);
}
@@ -1,3 +1,5 @@
"use client";
import type { AutoFormInputComponentProps } from "../ui/auto-form/types"; import type { AutoFormInputComponentProps } from "../ui/auto-form/types";
import { import {
FormControl, FormControl,
@@ -7,10 +9,19 @@ import {
FormMessage, FormMessage,
} from "../ui/form"; } from "../ui/form";
import { LoadingIcon } from "@/components/LoadingIcon"; import { LoadingIcon } from "@/components/LoadingIcon";
import { ModelPickerView } from "@/components/custom-form/ModelPickerView";
// import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; // import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import * as React from "react"; import * as React from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { ComfyUIManagerModelRegistry } from "./ComfyUIManagerModelRegistry";
import { ExternalLink } from "lucide-react";
export default function AutoFormModelsPicker({ export default function AutoFormModelsPicker({
label, label,
@@ -27,6 +38,7 @@ export default function AutoFormModelsPicker({
{isRequired && <span className="text-destructive"> *</span>} {isRequired && <span className="text-destructive"> *</span>}
</FormLabel> </FormLabel>
)} )}
<FormControl> <FormControl>
<Suspense fallback={<LoadingIcon />}> <Suspense fallback={<LoadingIcon />}>
<ModelPickerView field={field} /> <ModelPickerView field={field} />
@@ -35,7 +47,54 @@ export default function AutoFormModelsPicker({
{fieldConfigItem.description && ( {fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription> <FormDescription>{fieldConfigItem.description}</FormDescription>
)} )}
<FormDescription>
{" "}
<div className="text-sm">
Models are moving to{" "}
<a
href="/storage"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline"
>
<ExternalLink size={12} />
Storage
</a>
</div>
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
} }
function ModelPickerView({
field,
}: Pick<AutoFormInputComponentProps, "field">) {
return (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger className="text-sm">
Models (ComfyUI Manager)
</AccordionTrigger>
<AccordionContent>
<div className="flex gap-2 flex-col px-1">
<ComfyUIManagerModelRegistry field={field} />
{/* <CivitaiModelRegistry field={field} /> */}
{/* <span>{field.value.length} selected</span> */}
{field.value && (
<ScrollArea className="w-full bg-gray-100 mx-auto rounded-lg mt-2">
<Textarea
className="min-h-[150px] max-h-[300px] p-2 rounded-lg text-xs w-full"
value={JSON.stringify(field.value, null, 2)}
onChange={(e) => {
field.onChange(JSON.parse(e.target.value));
}}
/>
</ScrollArea>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
+4
View File
@@ -242,6 +242,10 @@ export const navigation: Array<NavGroup> = [
title: "API", title: "API",
links: [{ title: "Endpoints", href: "/docs/endpoints" }], links: [{ title: "Endpoints", href: "/docs/endpoints" }],
}, },
{
title: "Video Tutorials",
links: [{ title: "Archive", href: "/docs/videos" }],
},
]; ];
export function Navigation(props: React.ComponentPropsWithoutRef<"nav">) { export function Navigation(props: React.ComponentPropsWithoutRef<"nav">) {
@@ -1,3 +1,4 @@
import AutoFormGPUPicker from "@/components/custom-form/gpu-picker";
import AutoFormCheckbox from "./fields/checkbox"; import AutoFormCheckbox from "./fields/checkbox";
import AutoFormDate from "./fields/date"; import AutoFormDate from "./fields/date";
import AutoFormEnum from "./fields/enum"; import AutoFormEnum from "./fields/enum";
@@ -8,6 +9,7 @@ import AutoFormSwitch from "./fields/switch";
import AutoFormTextarea from "./fields/textarea"; import AutoFormTextarea from "./fields/textarea";
import AutoFormModelsPicker from "@/components/custom-form/model-picker"; import AutoFormModelsPicker from "@/components/custom-form/model-picker";
import AutoFormSnapshotPicker from "@/components/custom-form/snapshot-picker"; import AutoFormSnapshotPicker from "@/components/custom-form/snapshot-picker";
import AutoFormModelsPickerUrl from "@/components/custom-form/model-picker-url-only";
export const INPUT_COMPONENTS = { export const INPUT_COMPONENTS = {
checkbox: AutoFormCheckbox, checkbox: AutoFormCheckbox,
@@ -22,6 +24,8 @@ export const INPUT_COMPONENTS = {
// Customs // Customs
snapshot: AutoFormSnapshotPicker, snapshot: AutoFormSnapshotPicker,
models: AutoFormModelsPicker, models: AutoFormModelsPicker,
gpuPicker: AutoFormGPUPicker,
modelUrlPicker: AutoFormModelsPickerUrl,
}; };
/** /**
+2
View File
@@ -1,3 +1,4 @@
import type { getCurrentPlanWithAuth } from "@/server/getCurrentPlan";
import type { INPUT_COMPONENTS } from "./config"; import type { INPUT_COMPONENTS } from "./config";
import type { ControllerRenderProps, FieldValues } from "react-hook-form"; import type { ControllerRenderProps, FieldValues } from "react-hook-form";
import type * as z from "zod"; import type * as z from "zod";
@@ -6,6 +7,7 @@ export type FieldConfigItem = {
description?: React.ReactNode; description?: React.ReactNode;
inputProps?: React.InputHTMLAttributes<HTMLInputElement> & { inputProps?: React.InputHTMLAttributes<HTMLInputElement> & {
showLabel?: boolean; showLabel?: boolean;
sub?: Awaited<ReturnType<typeof getCurrentPlanWithAuth>>;
}; };
fieldType?: fieldType?:
| keyof typeof INPUT_COMPONENTS | keyof typeof INPUT_COMPONENTS
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { getCurrentPlanWithAuth } from "@/server/getCurrentPlan";
import * as React from "react";
import { createContext, useContext } from "react";
type CurrentPlanContextType = Awaited<
ReturnType<typeof getCurrentPlanWithAuth>
>;
const CurrentPlanContext = createContext<CurrentPlanContextType | undefined>(
undefined,
);
export function SubscriptionProvider({
sub,
children,
}: {
sub: CurrentPlanContextType;
children: React.ReactNode;
}) {
return (
<CurrentPlanContext.Provider value={sub}>
{children}
</CurrentPlanContext.Provider>
);
}
export const useCurrentPlan = (): CurrentPlanContextType => {
const context = useContext(CurrentPlanContext);
// if (context === undefined) {
// throw new Error("useCurrentPlan must be used within a CurrentPlanProvider");
// }
return context;
};
+1 -1
View File
@@ -17,7 +17,7 @@ export const insertCustomMachineSchema = createInsertSchema(machinesTable, {
comfyui: "d0165d819afe76bd4e6bdd710eb5f3e571b6a804", comfyui: "d0165d819afe76bd4e6bdd710eb5f3e571b6a804",
git_custom_nodes: { git_custom_nodes: {
"https://github.com/BennyKok/comfyui-deploy.git": { "https://github.com/BennyKok/comfyui-deploy.git": {
hash: "43fe0a384aa5fa9e141d4a264b2ed40a73b817bc", hash: "a838cb7ad425e5652c3931fbafdc886b53c48a22",
disabled: false, disabled: false,
}, },
}, },
+5 -1
View File
@@ -3,6 +3,7 @@ import { and, desc, eq, isNull, or } from "drizzle-orm";
import { subscriptionStatusTable } from "@/db/schema"; import { subscriptionStatusTable } from "@/db/schema";
import { APIKeyUserType } from "@/server/APIKeyBodyRequest"; import { APIKeyUserType } from "@/server/APIKeyBodyRequest";
import { auth } from "@clerk/nextjs"; import { auth } from "@clerk/nextjs";
import "server-only";
export async function getCurrentPlanWithAuth() { export async function getCurrentPlanWithAuth() {
const { userId, orgId } = auth(); const { userId, orgId } = auth();
@@ -23,7 +24,10 @@ export async function getCurrentPlan({ user_id, org_id }: APIKeyUserType) {
eq(subscriptionStatusTable.user_id, user_id), eq(subscriptionStatusTable.user_id, user_id),
org_id org_id
? eq(subscriptionStatusTable.org_id, org_id) ? eq(subscriptionStatusTable.org_id, org_id)
: or(isNull(subscriptionStatusTable.org_id), eq(subscriptionStatusTable.org_id, "")), : or(
isNull(subscriptionStatusTable.org_id),
eq(subscriptionStatusTable.org_id, ""),
),
), ),
orderBy: desc(subscriptionStatusTable.created_at), orderBy: desc(subscriptionStatusTable.created_at),
}); });