flow / src /components /panels /NodeLibrary.tsx
mrfakename's picture
Initial Flow app
fb57ab3 verified
Raw History Blame Contribute Delete
22.2 kB
import { Badge, Button, Tabs } from "@cloudflare/kumo";
import { prettyApi } from "../../lib/gradio";
import {
ArrowClockwiseIcon,
ArrowSquareOutIcon,
CaretDownIcon,
CaretRightIcon,
CheckIcon,
CrosshairIcon,
EyeIcon,
EyeSlashIcon,
FunctionIcon,
MagnifyingGlassIcon,
PlusIcon,
PushPinIcon,
PushPinSlashIcon,
SpinnerIcon,
TrashIcon,
WarningIcon,
LockSimpleIcon,
} from "@phosphor-icons/react";
import { useReactFlow } from "@xyflow/react";
import { useMemo, useRef, useState, type DragEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import { MOD } from "../../lib/format";
import { fnToDef } from "../../lib/groups";
import { hardwareLabel, isUsable, stageLabel } from "../../lib/hub";
import { allDefs, defAccent, getDef, TYPE_TOKEN, typeColor } from "../../lib/registry";
import type { CanvasNode, NodeDef, SpaceEntry } from "../../lib/types";
import { TEMPLATES } from "../../lib/workflows";
import { useCanvasStore } from "../../store/useCanvasStore";
import { spaceAction } from "../NodeError";
import { useLibraryStore } from "../../store/useLibraryStore";
import { useContextMenu, type MenuItem } from "../ContextMenu";
/* Adding nodes ----------------------------------------------------------- */
function useAddNode() {
const addNode = useCanvasStore((s) => s.addNode);
const addStep = useCanvasStore((s) => s.addStep);
const view = useCanvasStore((s) => s.view);
const snapToGrid = useCanvasStore((s) => s.snapToGrid);
const { screenToFlowPosition } = useReactFlow();
return (def: NodeDef) => {
if (view === "pipeline") {
addStep(def.type);
return;
}
const rect = document.querySelector(".react-flow")?.getBoundingClientRect();
const center = rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const p = screenToFlowPosition(center);
const jitter = (Math.random() - 0.5) * 80;
let pos = { x: p.x - (def.width ?? 260) / 2 + jitter, y: p.y - 60 + jitter };
if (snapToGrid) pos = { x: Math.round(pos.x / 20) * 20, y: Math.round(pos.y / 20) * 20 };
addNode(def.type, pos);
};
}
function useDragGhost() {
const ghostRef = useRef<HTMLDivElement>(null);
const onDragStart = (e: DragEvent, def: NodeDef) => {
e.dataTransfer.setData("application/kumo-node", def.type);
e.dataTransfer.effectAllowed = "copy";
const ghost = ghostRef.current;
if (ghost) {
ghost.textContent = def.title;
ghost.style.setProperty("--node-accent", typeColor(defAccent(def)));
e.dataTransfer.setDragImage(ghost, 16, 16);
}
};
return { ghostRef, onDragStart };
}
/* Workflow tab ----------------------------------------------------------- */
const STATUS_TEXT: Record<string, string> = { queued: "Queued", running: "Running", done: "Done", error: "Failed" };
/**
* Everything about *this* workflow: a dock of the nodes you reach for most,
* the Spaces it depends on (and whether they're awake), and an outline of the
* nodes on the canvas so a failing one is one click away. Finding new Spaces
* happens in the explorer (⌘K), not here.
*/
function WorkflowTab() {
const nodes = useCanvasStore((s) => s.nodes);
const order = useCanvasStore((s) => s.order);
const spaces = useCanvasStore((s) => s.spaces);
const groupDefs = useCanvasStore(useShallow((s) => s.functions));
const openPalette = useCanvasStore((s) => s.openPalette);
const focusNode = useCanvasStore((s) => s.focusNode);
const removeSpace = useCanvasStore((s) => s.removeSpace);
const refreshSpace = useCanvasStore((s) => s.refreshSpace);
const deleteFunction = useCanvasStore((s) => s.deleteFunction);
const pinned = useLibraryStore((s) => s.pinned);
const hidden = useLibraryStore((s) => s.hidden);
const pin = useLibraryStore((s) => s.pin);
const unpin = useLibraryStore((s) => s.unpin);
const hide = useLibraryStore((s) => s.hide);
const unhide = useLibraryStore((s) => s.unhide);
const onAdd = useAddNode();
const { ghostRef, onDragStart } = useDragGhost();
const ctx = useContextMenu();
const [openSpaces, setOpenSpaces] = useState<Record<string, boolean>>({});
const [showHidden, setShowHidden] = useState(false);
const dock = useMemo(() => {
const all = [...allDefs(), ...groupDefs.map(fnToDef)];
const byType = new Map(all.map((d) => [d.type, d]));
const pins = pinned.map((t) => byType.get(t)).filter((d): d is NodeDef => !!d);
const dockable = (d: NodeDef) => d.category === "Inputs" || d.category === "Utilities";
const inputs = all.filter((d) => dockable(d) && !pinned.includes(d.type) && !hidden.includes(d.type));
const groups = all.filter((d) => d.category === "Functions" && !pinned.includes(d.type));
const hiddenInputs = all.filter((d) => dockable(d) && hidden.includes(d.type));
return { pins, inputs, groups, hiddenInputs };
}, [groupDefs, pinned, hidden]);
const entries = useMemo(() => Object.values(spaces).sort((a, b) => a.summary.title.localeCompare(b.summary.title)), [spaces]);
const usedBy = useMemo(() => {
const m = new Map<string, number>();
for (const n of nodes) {
const sid = getDef(n.data.defType).space?.id;
if (sid) m.set(sid, (m.get(sid) ?? 0) + 1);
}
return m;
}, [nodes]);
const outline = useMemo(() => {
const rank = new Map(order.map((id, i) => [id, i]));
const sorted = [...nodes].sort((a, b) => (rank.get(a.id) ?? 1e9) - (rank.get(b.id) ?? 1e9));
const children = new Map<string, CanvasNode[]>();
const roots: CanvasNode[] = [];
for (const n of sorted) {
if (n.parentId) children.set(n.parentId, [...(children.get(n.parentId) ?? []), n]);
else roots.push(n);
}
return { roots, children };
}, [nodes, order]);
const defMenu = (e: React.MouseEvent, def: NodeDef) => {
const isPinned = pinned.includes(def.type);
const items: MenuItem[] = [
{ label: "Add to canvas", icon: PlusIcon, onSelect: () => onAdd(def) },
isPinned ? { label: "Remove from dock", icon: PushPinSlashIcon, onSelect: () => unpin(def.type) } : { label: "Pin to dock", icon: PushPinIcon, onSelect: () => pin(def.type) },
];
if ((def.category === "Inputs" || def.category === "Utilities") && !isPinned) items.push(hidden.includes(def.type) ? { label: "Show in dock", icon: EyeIcon, onSelect: () => unhide(def.type) } : { label: "Hide from dock", icon: EyeSlashIcon, onSelect: () => hide(def.type) });
if (def.space) {
items.push(
hidden.includes(def.type) ? { label: "Show endpoint", icon: EyeIcon, onSelect: () => unhide(def.type) } : { label: "Hide endpoint", icon: EyeSlashIcon, onSelect: () => hide(def.type) },
{ label: "Open Space on Hugging Face", icon: ArrowSquareOutIcon, href: `https://huggingface.co/spaces/${def.space.id}`, separator: true },
);
}
if (def.category === "Functions") items.push({ label: "Delete group", icon: TrashIcon, danger: true, separator: true, onSelect: () => deleteFunction(def.type.slice(3)) });
ctx.open(e, items);
};
const spaceMenu = (e: React.MouseEvent, sp: SpaceEntry) => {
const own = sp.defs;
const allHidden = own.length > 0 && own.every((d) => hidden.includes(d.type));
const used = usedBy.get(sp.summary.id) ?? 0;
ctx.open(e, [
{ label: "Reload endpoints", icon: ArrowClockwiseIcon, onSelect: () => void refreshSpace(sp.summary.id) },
{ label: "Open on Hugging Face", icon: ArrowSquareOutIcon, href: `https://huggingface.co/spaces/${sp.summary.id}` },
allHidden ? { label: "Show all endpoints", icon: EyeIcon, separator: true, onSelect: () => own.forEach((d) => unhide(d.type)) } : { label: "Hide all endpoints", icon: EyeSlashIcon, separator: true, onSelect: () => own.forEach((d) => hide(d.type)) },
{ label: used ? `Remove Space (${used} node${used === 1 ? "" : "s"} use it)` : "Remove Space", icon: TrashIcon, danger: true, disabled: used > 0, onSelect: () => removeSpace(sp.summary.id) },
]);
};
const Chip = ({ def, pinnedChip, dim }: { def: NodeDef; pinnedChip?: boolean; dim?: boolean }) => {
const sp = def.space ? spaces[def.space.id] : undefined;
return (
<button
type="button"
className="dock-chip"
data-dim={dim || undefined}
style={{ "--chip-accent": typeColor(defAccent(def)) } as React.CSSProperties}
draggable
onDragStart={(e) => onDragStart(e, def)}
onClick={() => onAdd(def)}
onContextMenu={(e) => defMenu(e, def)}
title={`${def.description || def.title}\nClick to add · drag onto the canvas · right-click for options`}
>
{sp ? <span className="dock-chip__emoji">{sp.summary.emoji}</span> : def.category === "Functions" ? <FunctionIcon size={12} /> : <i className="dock-chip__swatch" />}
<span className="truncate">{def.title}</span>
{pinnedChip && <PushPinIcon size={9} weight="fill" className="dock-chip__pin" />}
</button>
);
};
const NodeRow = ({ n, depth }: { n: CanvasNode; depth: number }) => {
const def = getDef(n.data.defType);
const sp = def.space ? spaces[def.space.id] : undefined;
const kids = outline.children.get(n.id) ?? [];
const st = n.data.status;
return (
<>
<button
type="button"
className="wf-node"
data-status={st}
data-selected={n.selected || undefined}
data-mode={n.data.mode !== "always" ? n.data.mode : undefined}
style={{ paddingLeft: 10 + depth * 14 }}
onClick={() => focusNode(n.id)}
title={n.data.error ?? n.data.statusText ?? `${n.data.title ?? def.title} · click to jump to it`}
>
<span className="wf-node__status">
{st === "running" ? <SpinnerIcon size={12} className="animate-spin" /> : st === "done" ? <CheckIcon size={12} weight="bold" /> : st === "error" ? <WarningIcon size={12} weight="bold" /> : <i className="wf-node__dot" />}
</span>
{sp ? <span className="wf-node__emoji">{sp.summary.emoji}</span> : n.type === "group" ? <FunctionIcon size={12} className="text-kumo-subtle" /> : <i className="wf-node__swatch" style={{ background: typeColor(defAccent(def)) }} />}
<span className="truncate">{n.data.title ?? def.title}</span>
<span className="wf-node__meta">
{st === "running" && n.data.progress > 0 ? `${Math.round(n.data.progress * 100)}%` : n.data.mode === "bypass" ? "skipped" : n.data.mode === "never" ? "off" : (STATUS_TEXT[st] ?? "")}
</span>
<CrosshairIcon size={12} className="wf-node__jump" />
</button>
{st === "error" && n.data.error && (
<div className="wf-node__error" style={{ marginLeft: 10 + depth * 14 }} onClick={() => focusNode(n.id)}>
{n.data.error}
</div>
)}
{kids.map((k) => (
<NodeRow key={k.id} n={k} depth={depth + 1} />
))}
</>
);
};
return (
<div className="min-h-0 flex-1 overflow-y-auto pb-4">
<div className="@container px-3">
<Button size="sm" variant="primary" icon={MagnifyingGlassIcon} className="w-full justify-center whitespace-nowrap" onClick={() => openPalette(null)} title={`Find a Space or node (${MOD} K)`}>
<span className="@max-[210px]:hidden">Find a Space or node</span>
<span className="@min-[210px]:hidden">Find</span>
</Button>
</div>
<h3 className="wf-h">Quick add</h3>
<div className="dock">
{dock.pins.map((d) => (
<Chip key={d.type} def={d} pinnedChip />
))}
{dock.inputs.map((d) => (
<Chip key={d.type} def={d} />
))}
{dock.groups.map((d) => (
<Chip key={d.type} def={d} />
))}
{showHidden && dock.hiddenInputs.map((d) => <Chip key={d.type} def={d} dim />)}
{dock.hiddenInputs.length > 0 && (
<button type="button" className="dock-chip dock-chip--ghost" onClick={() => setShowHidden((v) => !v)}>
{showHidden ? <EyeSlashIcon size={11} /> : <EyeIcon size={11} />}
{dock.hiddenInputs.length} hidden
</button>
)}
</div>
<p className="wf-hint">Click adds to the canvas, or drag one exactly where you want it. Pin anything from the explorer and it lands here.</p>
<h3 className="wf-h">
Spaces in this workflow
{entries.length > 0 && <span className="wf-h__count">{entries.length}</span>}
</h3>
{entries.length === 0 ? (
<p className="wf-hint">None yet. Every Space you add from the explorer shows up here with its status and endpoints.</p>
) : (
entries.map((sp) => {
const visible = sp.defs.filter((d) => !hidden.includes(d.type));
const isOpen = openSpaces[sp.summary.id] ?? false;
const used = usedBy.get(sp.summary.id) ?? 0;
const usable = isUsable(sp.summary.stage);
const fix = sp.status === "error" || (sp.status === "ready" && sp.summary.stage !== "RUNNING" && sp.summary.stage !== "UNKNOWN") ? spaceAction(sp.summary.id, sp, sp.error ?? "") : null;
const hw = hardwareLabel(sp.summary.hardware);
return (
<div key={sp.summary.id} className="wf-space" data-open={isOpen || undefined}>
<div
className="wf-space__row"
role="button"
tabIndex={0}
aria-expanded={isOpen}
onClick={() => setOpenSpaces((o) => ({ ...o, [sp.summary.id]: !isOpen }))}
onKeyDown={(e) => e.key === "Enter" && setOpenSpaces((o) => ({ ...o, [sp.summary.id]: !isOpen }))}
onContextMenu={(e) => spaceMenu(e, sp)}
>
<span className="wf-space__toggle" aria-hidden>
{isOpen ? <CaretDownIcon size={10} weight="bold" /> : <CaretRightIcon size={10} weight="bold" />}
</span>
<span className="wf-space__emoji">{sp.summary.emoji}</span>
<div className="min-w-0 flex-1">
<div className="wf-space__title">
{sp.summary.title}
{sp.summary.private && <LockSimpleIcon size={10} weight="bold" className="ml-1 inline-block align-[-1px] opacity-70" aria-label="Private" />}
</div>
<div className="wf-space__meta">
{sp.status === "loading" ? (
<>
<SpinnerIcon size={10} className="animate-spin" /> Loading endpoints…
</>
) : sp.status === "error" ? (
<span className="text-kumo-danger">{sp.error ?? "Failed to load"}</span>
) : (
<>
<span className="stage-dot" data-stage={sp.summary.stage} />
<span data-bad={!usable || undefined}>{stageLabel(sp.summary.stage)}</span>
{hw && (
<span className="hw-tag" data-zero={sp.summary.zeroGpu}>
{hw}
</span>
)}
<span className="opacity-70">· {used ? `${used} on canvas` : "unused"}</span>
</>
)}
</div>
</div>
{sp.status === "loading" ? null : fix ? (
fix.href ? (
<a className="wf-space__fix" href={fix.href} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
{fix.label}
</a>
) : (
<Button size="xs" variant="ghost" aria-label={fix.label} title={fix.label} onClick={(e) => { e.stopPropagation(); fix.run?.(); }}>
{fix.label}
</Button>
)
) : sp.status === "error" || !usable ? (
<Button size="xs" variant="ghost" shape="square" icon={ArrowClockwiseIcon} aria-label="Check again" title="Check again" onClick={(e) => { e.stopPropagation(); void refreshSpace(sp.summary.id); }} />
) : visible.length === 1 ? (
<Button size="xs" variant="ghost" shape="square" icon={PlusIcon} aria-label="Add to canvas" title="Add to canvas" onClick={(e) => { e.stopPropagation(); onAdd(visible[0]); }} />
) : null}
</div>
{isOpen && (
<div className="wf-space__endpoints">
{sp.defs.length === 0 && <p className="wf-hint">No callable endpoints.</p>}
{sp.defs.map((d) => {
const off = hidden.includes(d.type);
return (
<div
key={d.type}
className="lib-item"
data-hidden={off || undefined}
draggable={!off}
onDragStart={(e) => onDragStart(e, d)}
onClick={() => !off && onAdd(d)}
onContextMenu={(e) => defMenu(e, d)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && !off && onAdd(d)}
title={`${d.description}\nClick to add · drag onto the canvas`}
>
<span className="truncate">{d.space && sp.defs.length > 1 ? prettyApi(d.space.apiName) : d.title}</span>
<span className="lib-item__dots">
{Array.from(new Set(d.outputs.map((o) => o.type))).map((t) => (
<i key={t} className={`port-color-${TYPE_TOKEN[t]}`} title={t} />
))}
</span>
<span className="lib-item__actions">
<button
type="button"
className="lib-item__btn"
data-active={pinned.includes(d.type) || undefined}
aria-label={pinned.includes(d.type) ? "Unpin from dock" : "Pin to dock"}
title={pinned.includes(d.type) ? "Unpin from dock" : "Pin to dock"}
onClick={(e) => {
e.stopPropagation();
(pinned.includes(d.type) ? unpin : pin)(d.type);
}}
>
<PushPinIcon size={12} weight={pinned.includes(d.type) ? "fill" : "regular"} />
</button>
<button
type="button"
className="lib-item__btn"
aria-label={off ? "Show endpoint" : "Hide endpoint"}
title={off ? "Show" : "Hide"}
onClick={(e) => {
e.stopPropagation();
(off ? unhide : hide)(d.type);
}}
>
{off ? <EyeIcon size={12} /> : <EyeSlashIcon size={12} />}
</button>
</span>
</div>
);
})}
</div>
)}
</div>
);
})
)}
{nodes.length > 0 && (
<>
<h3 className="wf-h">
On the canvas
<span className="wf-h__count">{nodes.length}</span>
</h3>
<div className="wf-outline">
{outline.roots.map((n) => (
<NodeRow key={n.id} n={n} depth={0} />
))}
</div>
</>
)}
<div ref={ghostRef} className="drag-ghost" aria-hidden />
{ctx.menu}
</div>
);
}
/* Templates tab --------------------------------------------------------- */
function TemplatesTab() {
const workflowId = useCanvasStore((s) => s.workflowId);
const loadTemplate = useCanvasStore((s) => s.loadTemplate);
const { fitView } = useReactFlow();
const load = async (id: string) => {
await loadTemplate(id);
requestAnimationFrame(() => fitView({ padding: 0.18, duration: 400, maxZoom: 1 }));
};
return (
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-3">
{TEMPLATES.map((t) => {
const current = t.id === workflowId;
return (
<div key={t.id} className="rounded-lg p-3 ring-1 ring-kumo-line transition-colors hover:bg-kumo-tint">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-kumo-strong">{t.name}</span>
{current && <Badge variant="info">Open</Badge>}
</div>
<p className="mt-1 text-xs leading-relaxed text-kumo-subtle">{t.description}</p>
<div className="mt-2.5 flex items-center justify-between gap-2">
<span className="truncate font-mono text-[10.5px] text-kumo-placeholder">{t.spaces.join(" · ")}</span>
<Button size="xs" variant={current ? "secondary" : "primary"} onClick={() => void load(t.id)}>
{current ? "Reset" : "Load"}
</Button>
</div>
</div>
);
})}
</div>
);
}
export function NodeLibrary() {
const tab = useCanvasStore((s) => s.libraryTab);
const setLibraryTab = useCanvasStore((s) => s.setLibraryTab);
return (
<div className="flex h-full flex-col">
<div className="px-3 pt-3 pb-2">
<Tabs
variant="segmented"
size="sm"
className="w-full"
listClassName="w-full"
value={tab}
onValueChange={(t) => setLibraryTab(t as "workflow" | "templates")}
tabs={[
{ value: "workflow", label: "Workflow" },
{ value: "templates", label: "Templates" },
]}
/>
</div>
{tab === "templates" ? <TemplatesTab /> : <WorkflowTab />}
</div>
);
}