Spaces:
Running
Running
Download src/components/panels/NodeLibrary.tsx from mrfakename/flow: direct link, hf CLI and curl.
- Browser
- Download file 22.2 kB
-
https://huggingface.co/spaces/mrfakename/flow/resolve/main/src/components/panels/NodeLibrary.tsx
- Command line
-
hf download hf://spaces/mrfakename/flow/src/components/panels/NodeLibrary.tsx
-
curl -L -o NodeLibrary.tsx https://huggingface.co/spaces/mrfakename/flow/resolve/main/src/components/panels/NodeLibrary.tsx
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> | |
| ); | |
| } | |