项目文件夹

文件
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

2896 行
82 KiB
Svelte

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<script lang="ts">
import { setContext } from "svelte";
import { fade } from "svelte/transition";
import WorkflowNodeSF from "./WorkflowNodeSF.svelte";
import WorkflowBottomBar from "./WorkflowBottomBar.svelte";
import type { BoundFnTemplate } from "./WorkflowBottomBar.svelte";
import NodeModelPicker from "./NodeModelPicker.svelte";
import WorkflowEmptyState from "./WorkflowEmptyState.svelte";
import WorkflowApiPanel from "./WorkflowApiPanel.svelte";
import CheckIcon from "./icons/CheckIcon.svelte";
import LayoutIcon from "./icons/LayoutIcon.svelte";
import InfoIcon from "./icons/InfoIcon.svelte";
import CodeIcon from "./icons/CodeIcon.svelte";
import {
MODALITIES,
DATASET_MODALITY,
ALL_MODALITY,
portMeta,
modalityForPort
} from "./workflow-modalities";
import type { ModalityConfig } from "./workflow-modalities";
import { fetchSpaceApi } from "./space-api";
import {
workflow,
addNode,
moveNode,
addEdge,
removeEdge,
updateNodeData,
removeNode,
replaceNodeSource,
switch_endpoint,
hydrate_endpoints,
sanitize_for_save,
revoke_blob_urls
} from "./workflow-store";
import { migrateToV2, toLegacyShape } from "./workflow-migration";
import { PORT_COLOR, ports_compatible } from "./workflow-types";
import type {
PortType,
Port,
WFNode,
WFEdge,
NodeStatus,
NodeRole,
Workflow
} from "./workflow-types";
import { executeWorkflow } from "./workflow-executor";
import { stream_text_generation } from "./inference-stream";
import {
findFreeSpot as findFreeSpotImpl,
countSubgraphs,
topoSort,
resolveCurrentInputs as resolveCurrentInputsImpl,
computeStaleNodes,
buildUpstreamSubgraph as buildUpstreamSubgraphImpl
} from "./workflow-graph";
import { LIBRARY, getComponentForPortType } from "./node-library";
import { createHFAuth } from "./hf-auth.svelte";
import { load_viewport, save_viewport } from "./viewport-persistence";
/**
* A node template's role for the v2 store. v1-style templates from LIBRARY/
* picker use `kind: "component" | "transform"` — map them to v2 roles here.
* Subjects aren't auto-created by templates today; they're created when an
* edge wires into a reference (future enhancement). For now, components default
* to reference.
*/
function templateRole(template: any): NodeRole {
if (template?.role) return template.role;
if (template?.kind === "transform") return "operator";
return "reference";
}
let {
server = {},
initialValue = null
}: { server?: Record<string, any>; initialValue?: string | null } = $props();
const auth = createHFAuth(() => server);
// Sessions without the write token (share-link visitors, tunnelled
// requests) get a view-only canvas: they can run the workflow and fill in
// input values, but not change its structure or persist anything. The
// server independently rejects unauthorized saves — this is UX, not the
// security boundary. Stays editable until the server answers so the owner
// doesn't see controls flash out and back in.
const readOnly = $derived(auth.writeAccessKnown && !auth.canWrite);
// Why this session can't edit — surfaced on the "Run only" badge (hover and
// click). Differs by deployment: locally the fix is opening the write-token
// edit link; on a Space it's signing in as an account that owns the Space —
// unless the Space has no OAuth enabled, in which case no one can sign in to
// edit and the developer must enable it.
const readOnlyReason = $derived(
auth.isHFSpace
? auth.oauthAvailable
? "Run-only: you can run this workflow but not edit it. Sign in with a Hugging Face account that owns this Space (or has write access to it) to make changes. Alternatively, duplicate this Space under your own account to edit your own copy."
: "Run-only: editing is disabled because this Space doesn't have OAuth enabled, so the owner can't sign in to authenticate. To allow editing, add `hf_oauth: true` to the Space's README metadata and redeploy. Alternatively, duplicate this Space under your own account to edit your own copy."
: "Run-only: you can run this workflow but not edit it. This session is missing the write token — open the edit link printed in the terminal to make changes."
);
// Flash a brief "Saved" confirmation after each successful autosave. The
// timer is cleared on each new save so rapid edits coalesce into a single
// lingering checkmark rather than flickering.
let saveIndicator = $state(false);
let saveIndicatorTimer: ReturnType<typeof setTimeout> | null = null;
// Serialized form of what's currently persisted on the server. Autosave
// compares against this so loading the workflow into the store on page load
// (and any no-op change) doesn't trigger a redundant save + "Saved" flash.
let lastSavedSerialized: string | null = null;
function flashSaved(): void {
saveIndicator = true;
if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer);
saveIndicatorTimer = setTimeout(() => {
saveIndicator = false;
}, 1400);
}
$effect(() => {
void auth.init();
});
$effect(() => {
if (!initialValue) return;
try {
const parsed = JSON.parse(initialValue);
// Migration handles both v1 (legacy workflow.json files) and v2.
const v2 = migrateToV2(parsed);
workflow.set(v2);
// Baseline the persisted state so the load itself isn't autosaved.
lastSavedSerialized = JSON.stringify(sanitize_for_save(v2));
} catch {}
});
$effect(() => {
const wf = $workflow;
// Wait for the write-access answer before autosaving — the optimistic
// editable window would otherwise fire saves the backend rejects.
if (!server?.save_workflow || !auth.writeAccessKnown || !auth.canWrite)
return;
const serialized = JSON.stringify(sanitize_for_save(wf));
if (lastSavedSerialized === null) {
// No persisted baseline yet (e.g. a brand-new workflow with no saved
// file): adopt the current state instead of saving it on load.
lastSavedSerialized = serialized;
return;
}
// Nothing changed since the last save/load — don't re-save or flash.
if (serialized === lastSavedSerialized) return;
const timer = setTimeout(() => {
server
.save_workflow([serialized])
.then(() => {
lastSavedSerialized = serialized;
flashSaved();
})
.catch(() => {});
}, 500);
return () => clearTimeout(timer);
});
// Pull the bind=[…] list from the server once on mount so the
// Functions button in the bottom bar can offer them as add-able
// nodes. Silently no-op if the server doesn't expose it (older
// gradio backends).
$effect(() => {
if (!server?.list_bound_fns) return;
void server
.list_bound_fns()
.then((raw: string) => {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) boundFns = parsed as BoundFnTemplate[];
} catch {
/* ignore malformed */
}
})
.catch(() => {});
});
$effect(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener("keyup", handle_keyup);
return () => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("keyup", handle_keyup);
};
});
function handle_keyup(e: KeyboardEvent): void {
if (e.code === "Space") spaceHeld = false;
}
// ─── Canvas state ───────────────────────────────────────────────────────────
let viewport = $state(load_viewport($workflow.name));
let lastViewportName = $state($workflow.name);
$effect(() => {
if ($workflow.name !== lastViewportName) {
lastViewportName = $workflow.name;
viewport = load_viewport($workflow.name);
}
});
$effect(() => {
const name = $workflow.name;
const v = viewport;
const timer = setTimeout(() => save_viewport(name, v), 250);
return () => clearTimeout(timer);
});
// Pointer interaction state: track which kind of drag is happening so
// pointermove/up know what to do. Mutually exclusive — at most one mode.
type DragMode =
| { kind: "pan"; startX: number; startY: number; vx: number; vy: number }
| {
kind: "node";
nodeId: string;
startClientX: number;
startClientY: number;
startNodeX: number;
startNodeY: number;
moved: boolean;
groupStart: Map<string, { x: number; y: number }>;
}
| {
kind: "connection";
fromNodeId: string;
fromPortId: string;
type: PortType;
reversed: boolean;
cursorCanvasX: number;
cursorCanvasY: number;
startCanvasX: number;
startCanvasY: number;
}
| {
kind: "marquee";
startCanvasX: number;
startCanvasY: number;
endCanvasX: number;
endCanvasY: number;
additive: boolean;
moved: boolean;
};
let dragMode = $state<DragMode | null>(null);
let spaceHeld = $state(false);
let last_node_drag_moved = false;
// ─── App state ──────────────────────────────────────────────────────────────
let canvasEl: HTMLDivElement;
let rootEl: HTMLDivElement;
let running = $state(false);
let abortController: AbortController | null = null;
let nodeStatus: Record<string, NodeStatus> = $state({});
let nodeErrors: Record<string, string> = $state({});
/**
* Bound Python functions advertised by the server (`list_bound_fns`).
* Populates the bottom-bar Functions button so users can re-add an
* fn node after deleting it without re-launching the app.
*/
let boundFns = $state<BoundFnTemplate[]>([]);
/**
* Snapshot of resolved inputs at the moment a node last completed. Used
* to flag nodes as stale when their current inputs differ from the
* snapshot — i.e. an upstream value changed but this node hasn't been
* re-run yet. Pattern lifted from gradio-app/daggr.
*/
let nodeInputSnapshots: Record<string, string> = $state({});
let editingName = $state(false);
let selectedNodeIds = $state<Set<string>>(new Set());
let selectedEdgeIds = $state<Set<string>>(new Set());
const selectedNodeId = $derived(
selectedNodeIds.size === 1
? (selectedNodeIds.values().next().value ?? null)
: null
);
let showShortcuts = $state(false);
let showUserMenu = $state(false);
let showApiPanel = $state(false);
// Popover shown when the "Run only" badge is clicked, explaining why editing
// is disabled and how to enable it.
let showAccessInfo = $state(false);
let nameInput: HTMLInputElement = $state()!;
// Human-readable explanation of how the current user is authenticated,
// shown in the popover when they click their avatar. The "local" case also
// tells them logout happens from the CLI, since the UI can't clear a token
// it never stored (it's the host's `huggingface-cli login` token).
const authExplanation = $derived.by(() => {
if (auth.source === "local")
return "Signed in with the Hugging Face token saved on this machine (via `huggingface-cli login`). To sign out, run `huggingface-cli logout` in your terminal.";
if (auth.source === "oauth")
return "Signed in with your Hugging Face account.";
if (auth.source === "pat")
return "Signed in with a Hugging Face token you provided.";
return "";
});
type Pending = {
from_node_id: string;
from_port_id: string;
type: PortType;
reversed?: boolean;
};
let activeConnection: Pending | null = $state(null);
interface WfToast {
id: number;
message: string;
type: "info" | "warning" | "error" | "success" | "pro";
action?: { label: string; href?: string; onClick?: () => void };
}
let toasts: WfToast[] = $state([]);
let toastCounter = 0;
function showToast(
msg: string,
ms = 3000,
type: "info" | "warning" | "error" | "success" | "pro" = "info",
action?: WfToast["action"]
): void {
const id = ++toastCounter;
toasts = [...toasts, { id, message: msg, type, action }].slice(-3);
if (ms > 0) setTimeout(() => dismissToast(id), ms);
}
function dismissToast(id: number): void {
toasts = toasts.filter((t) => t.id !== id);
}
// v1 shape for read paths; writes go through v2 store actions.
const legacyView = $derived(toLegacyShape($workflow));
const gridTile = $derived.by(() => {
let tile = 22 * viewport.zoom;
while (tile < 16) tile *= 2;
return tile;
});
const nodeCount = $derived(legacyView.nodes.length);
const hasTransforms = $derived($workflow.operators.length > 0);
const edgeCount = $derived($workflow.edges.length);
const subgraphCount = $derived(
countSubgraphs(legacyView.nodes, $workflow.edges)
);
const connectedPortsSet = $derived(() => {
const set = new Set<string>();
for (const e of $workflow.edges) {
set.add(`${e.from_node_id}:${e.from_port_id}:output`);
set.add(`${e.to_node_id}:${e.to_port_id}:input`);
}
return set;
});
interface ActivePicker {
mode: "create" | "update";
modality: ModalityConfig;
nodeId?: string;
anchorX?: number;
anchorY?: number;
initialSubtab?: string;
}
let activePicker: ActivePicker | null = $state(null);
interface PendingDrop {
from_node_id: string;
from_port_id: string;
type: PortType;
x: number;
y: number;
reversed?: boolean;
positionOnly?: boolean;
}
let pendingDrop: PendingDrop | null = $state(null);
interface DropOption {
kind: "model" | "component";
label: string;
subtab?: string;
}
interface DropChoice {
clientX: number;
clientY: number;
modelOptions: DropOption[];
componentOptions: DropOption[];
/** true → user dragged from an input port (needs a Source);
* false → user dragged from an output port (needs an Output sink). */
reversed: boolean;
}
let dropChoice: DropChoice | null = $state(null);
interface DoubleClickMenu {
clientX: number;
clientY: number;
canvasX: number;
canvasY: number;
}
let doubleClickMenu: DoubleClickMenu | null = $state(null);
function inputOutputLabel(type: PortType, reversed: boolean): string {
const base = portMeta(type)?.label ?? "File";
// When dragging from an input (reversed), media types want an "Upload"
// affordance; scalar types reuse their own label as the source widget.
if (reversed) {
const scalar =
type === "text" ||
type === "number" ||
type === "json" ||
type === "boolean";
return scalar ? base : "Upload";
}
return base;
}
// ─── Context for node components ────────────────────────────────────────────
const wfCtx = $state({
pending: null as Pending | null,
nodeStatus: {} as Record<string, NodeStatus>,
nodeErrors: {} as Record<string, string>,
staleNodes: new Set<string>(),
connectedPorts: new Set<string>(),
readOnly: false,
ondatachange: updateNodeData,
onremove: (id: string) => {
if (!readOnly) removeNode(id);
},
onopenpicker: (id: string) => {
if (!readOnly) openPickerForNode(id);
},
onswitchendpoint: (id: string, endpointName: string) => {
if (!readOnly) switch_endpoint(id, endpointName);
},
onhydratendpoints: async (id: string, spaceId: string) => {
try {
const info = await fetchSpaceApi(spaceId);
if (!info.endpoints || info.endpoints.length === 0) {
showToast(`${spaceId} has no usable endpoints`, 4000, "warning");
return;
}
hydrate_endpoints(id, info.endpoints);
if (info.endpoints.length === 1) {
showToast(`${spaceId} only exposes one endpoint`, 3000);
}
} catch (err) {
showToast(
err instanceof Error ? err.message : "Failed to load endpoints",
4000,
"error"
);
}
},
onrunnode: (id: string) => void runNode(id),
onselect: (id: string, additive = false) => selectNode(id, additive),
onnodepointerdown: (e: PointerEvent, id: string) => startNodeDrag(e, id),
onportpointerdown: (
e: PointerEvent,
nodeId: string,
portId: string,
type: PortType,
isInput: boolean
) => startConnection(e, nodeId, portId, type, isInput),
onpopout: (
nodeId: string,
portId: string,
type: PortType,
isInput: boolean
) => {
if (readOnly) return;
const node = legacyView.nodes.find((n) => n.id === nodeId);
if (!node) return;
const template = getComponentForPortType(type);
if (!template) return;
if (isInput) {
const { x, y } = clampToViewport(
node.x - 260,
node.y,
template.width,
template.height
);
const newId = addNode("reference", template, x, y);
addEdge({
from_node_id: newId,
from_port_id: "out",
to_node_id: nodeId,
to_port_id: portId,
type
});
updateNodeData(nodeId, portId, "");
} else {
const { x, y } = clampToViewport(
node.x + node.width + 40,
node.y,
template.width,
template.height
);
const newId = addNode("reference", template, x, y);
addEdge({
from_node_id: nodeId,
from_port_id: portId,
to_node_id: newId,
to_port_id: "in",
type
});
}
}
});
setContext("wf", wfCtx);
$effect(() => {
wfCtx.pending = activeConnection;
});
$effect(() => {
wfCtx.nodeStatus = nodeStatus;
});
$effect(() => {
wfCtx.nodeErrors = nodeErrors;
});
$effect(() => {
wfCtx.staleNodes = staleNodes;
});
$effect(() => {
wfCtx.connectedPorts = connectedPortsSet();
});
$effect(() => {
wfCtx.readOnly = readOnly;
});
// ─── Custom canvas event handlers ───────────────────────────────────────────
function clientToCanvas(
clientX: number,
clientY: number
): { x: number; y: number } {
const r = canvasEl?.getBoundingClientRect();
if (!r) return { x: 0, y: 0 };
return {
x: (clientX - r.left - viewport.x) / viewport.zoom,
y: (clientY - r.top - viewport.y) / viewport.zoom
};
}
function clampToViewport(
x: number,
y: number,
w: number,
h: number
): { x: number; y: number } {
if (!canvasEl) return { x, y };
const r = canvasEl.getBoundingClientRect();
const pad = 20;
const minX = -viewport.x / viewport.zoom + pad;
const maxX = (r.width - viewport.x) / viewport.zoom - w - pad;
const minY = -viewport.y / viewport.zoom + pad;
const maxY = (r.height - viewport.y) / viewport.zoom - h - pad;
return {
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y))
};
}
function onCanvasPointerDown(e: PointerEvent): void {
if (e.button !== 0 && e.button !== 1) return;
const target = e.target as HTMLElement;
if (
target.closest(
".node-pos-wrap, .edge-path, .picker-panel, .drop-menu, .add-node-menu, .bottom-bar, .zoom-controls, .toolbar"
)
) {
return;
}
const pan_requested = e.button === 1 || spaceHeld;
if (pan_requested) {
dragMode = {
kind: "pan",
startX: e.clientX,
startY: e.clientY,
vx: viewport.x,
vy: viewport.y
};
canvasEl.setPointerCapture(e.pointerId);
return;
}
const { x, y } = clientToCanvas(e.clientX, e.clientY);
dragMode = {
kind: "marquee",
startCanvasX: x,
startCanvasY: y,
endCanvasX: x,
endCanvasY: y,
additive: e.shiftKey,
moved: false
};
canvasEl.setPointerCapture(e.pointerId);
}
function onWheel(e: WheelEvent): void {
if (!canvasEl) return;
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const r = canvasEl.getBoundingClientRect();
const cx = e.clientX - r.left;
const cy = e.clientY - r.top;
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const oldZoom = viewport.zoom;
const newZoom = Math.max(0.15, Math.min(4, oldZoom * factor));
viewport = {
x: cx - (cx - viewport.x) * (newZoom / oldZoom),
y: cy - (cy - viewport.y) * (newZoom / oldZoom),
zoom: newZoom
};
return;
}
viewport = {
...viewport,
x: viewport.x - e.deltaX,
y: viewport.y - e.deltaY
};
}
function startNodeDrag(e: PointerEvent, nodeId: string): void {
if (e.button !== 0 || readOnly) return;
const node = legacyView.nodes.find((n) => n.id === nodeId);
if (!node) return;
e.stopPropagation();
const drag_whole_group =
selectedNodeIds.has(nodeId) && selectedNodeIds.size > 1;
const groupStart = new Map<string, { x: number; y: number }>();
if (drag_whole_group) {
for (const n of legacyView.nodes) {
if (selectedNodeIds.has(n.id)) {
groupStart.set(n.id, { x: n.x, y: n.y });
}
}
} else {
groupStart.set(nodeId, { x: node.x, y: node.y });
}
dragMode = {
kind: "node",
nodeId,
startClientX: e.clientX,
startClientY: e.clientY,
startNodeX: node.x,
startNodeY: node.y,
moved: false,
groupStart
};
canvasEl?.setPointerCapture(e.pointerId);
}
function startConnection(
e: PointerEvent,
nodeId: string,
portId: string,
type: PortType,
isInput: boolean
): void {
if (e.button !== 0 || readOnly) return;
e.stopPropagation();
const { x, y } = clientToCanvas(e.clientX, e.clientY);
dragMode = {
kind: "connection",
fromNodeId: nodeId,
fromPortId: portId,
type,
reversed: isInput,
cursorCanvasX: x,
cursorCanvasY: y,
startCanvasX: x,
startCanvasY: y
};
activeConnection = {
from_node_id: nodeId,
from_port_id: portId,
type,
reversed: isInput
};
canvasEl?.setPointerCapture(e.pointerId);
}
function onCanvasPointerMove(e: PointerEvent): void {
if (!dragMode) return;
if (dragMode.kind === "pan") {
viewport = {
...viewport,
x: dragMode.vx + (e.clientX - dragMode.startX),
y: dragMode.vy + (e.clientY - dragMode.startY)
};
} else if (dragMode.kind === "node") {
const dx = (e.clientX - dragMode.startClientX) / viewport.zoom;
const dy = (e.clientY - dragMode.startClientY) / viewport.zoom;
if (Math.abs(dx) > 0 || Math.abs(dy) > 0) dragMode.moved = true;
if (dragMode.groupStart.size > 1) {
for (const [id, start] of dragMode.groupStart) {
moveNode(id, start.x + dx, start.y + dy);
}
} else {
moveNode(
dragMode.nodeId,
dragMode.startNodeX + dx,
dragMode.startNodeY + dy
);
}
} else if (dragMode.kind === "connection") {
const { x, y } = clientToCanvas(e.clientX, e.clientY);
dragMode = { ...dragMode, cursorCanvasX: x, cursorCanvasY: y };
} else if (dragMode.kind === "marquee") {
const { x, y } = clientToCanvas(e.clientX, e.clientY);
const moved =
dragMode.moved ||
Math.abs(x - dragMode.startCanvasX) > 2 ||
Math.abs(y - dragMode.startCanvasY) > 2;
dragMode = { ...dragMode, endCanvasX: x, endCanvasY: y, moved };
}
}
function onCanvasPointerUp(e: PointerEvent): void {
if (!dragMode) return;
const mode = dragMode;
if (mode.kind === "marquee") {
if (mode.moved) {
const hit_nodes = nodes_in_rect(
mode.startCanvasX,
mode.startCanvasY,
mode.endCanvasX,
mode.endCanvasY
);
const hit_edges = edges_in_rect(
mode.startCanvasX,
mode.startCanvasY,
mode.endCanvasX,
mode.endCanvasY
);
if (mode.additive) {
const next_nodes = new Set(selectedNodeIds);
for (const id of hit_nodes) next_nodes.add(id);
const next_edges = new Set(selectedEdgeIds);
for (const id of hit_edges) next_edges.add(id);
selectedNodeIds = next_nodes;
selectedEdgeIds = next_edges;
} else {
selectedNodeIds = hit_nodes;
selectedEdgeIds = hit_edges;
}
} else if (!mode.additive) {
clear_selection();
}
dragMode = null;
try {
canvasEl?.releasePointerCapture(e.pointerId);
} catch {}
return;
}
if (mode.kind === "connection") {
activeConnection = null;
const dx =
Math.abs(mode.cursorCanvasX - mode.startCanvasX) * viewport.zoom;
const dy =
Math.abs(mode.cursorCanvasY - mode.startCanvasY) * viewport.zoom;
if (dx < 4 && dy < 4) {
const direction = mode.reversed ? "input" : "output";
const isConnected = connectedPortsSet().has(
`${mode.fromNodeId}:${mode.fromPortId}:${direction}`
);
if (!isConnected) {
const srcNode = legacyView.nodes.find(
(n) => n.id === mode.fromNodeId
);
const template = getComponentForPortType(mode.type);
if (srcNode && template) {
if (mode.reversed) {
const { x, y } = clampToViewport(
srcNode.x - 260,
srcNode.y,
template.width,
template.height
);
const newId = addNode("reference", template, x, y);
addEdge({
from_node_id: newId,
from_port_id: "out",
to_node_id: mode.fromNodeId,
to_port_id: mode.fromPortId,
type: mode.type
});
updateNodeData(mode.fromNodeId, mode.fromPortId, "");
} else {
const { x, y } = clampToViewport(
srcNode.x + srcNode.width + 40,
srcNode.y,
template.width,
template.height
);
const newId = addNode("reference", template, x, y);
addEdge({
from_node_id: mode.fromNodeId,
from_port_id: mode.fromPortId,
to_node_id: newId,
to_port_id: "in",
type: mode.type
});
}
}
dragMode = null;
try {
canvasEl?.releasePointerCapture(e.pointerId);
} catch {}
return;
}
}
const targetEl = document.elementFromPoint(
e.clientX,
e.clientY
) as HTMLElement | null;
// 1. Precise hit on a port handle — use it directly.
const portEl = targetEl?.closest("[data-port-id]") as HTMLElement | null;
if (portEl) {
const toNodeId = portEl.getAttribute("data-node-id");
const toPortId = portEl.getAttribute("data-port-id");
const toIsInput =
portEl.getAttribute("data-port-direction") === "input";
if (toNodeId && toPortId && toNodeId !== mode.fromNodeId) {
tryCreateEdge(
mode.fromNodeId,
mode.fromPortId,
mode.reversed,
toNodeId,
toPortId,
toIsInput
);
}
dragMode = null;
try {
canvasEl?.releasePointerCapture(e.pointerId);
} catch {}
return;
}
// 2. Fuzzy hit — dropped on a node body, not its port. Find the first
// compatible free port on that node and use it. This is what users
// usually mean: "connect to that node", not "to that 12px circle."
const nodeWrap = targetEl?.closest(
"[data-node-id]"
) as HTMLElement | null;
const droppedNodeId = nodeWrap?.getAttribute("data-node-id");
if (droppedNodeId && droppedNodeId !== mode.fromNodeId) {
const autoPort = pickCompatiblePort(
droppedNodeId,
mode.type,
mode.reversed
);
if (autoPort) {
tryCreateEdge(
mode.fromNodeId,
mode.fromPortId,
mode.reversed,
droppedNodeId,
autoPort.id,
autoPort.direction === "input"
);
dragMode = null;
try {
canvasEl?.releasePointerCapture(e.pointerId);
} catch {}
return;
}
}
// 3. Empty drop — open the drop-choice menu so the user can spawn a new node.
const r = canvasEl?.getBoundingClientRect();
if (r) {
const drop: PendingDrop = {
from_node_id: mode.fromNodeId,
from_port_id: mode.fromPortId,
type: mode.type,
x: mode.cursorCanvasX,
y: mode.cursorCanvasY,
reversed: mode.reversed
};
const srcPort = findSourcePort(
mode.fromNodeId,
mode.fromPortId,
mode.reversed
);
const hasChoices = !!(srcPort?.choices && srcPort.choices.length > 0);
const rootRect = rootEl?.getBoundingClientRect();
const menuW = 220,
menuH = 360;
const rawX = rootRect ? e.clientX - rootRect.left : e.clientX - r.left;
const rawY = rootRect ? e.clientY - rootRect.top : e.clientY - r.top;
const containerW = rootRect ? rootRect.width : r.width;
const containerH = rootRect ? rootRect.height : r.height;
const choice: DropChoice = {
clientX: Math.min(rawX, containerW - menuW - 8),
clientY: Math.min(rawY, containerH - menuH - 8),
modelOptions: hasChoices ? [] : buildModelOptions(mode.type),
componentOptions: hasChoices
? [{ kind: "component", label: srcPort!.label }]
: buildComponentOptions(mode.type, mode.reversed),
reversed: mode.reversed
};
setTimeout(() => {
pendingDrop = drop;
dropChoice = choice;
}, 0);
}
}
if (mode.kind === "node" && mode.moved) {
last_node_drag_moved = true;
setTimeout(() => (last_node_drag_moved = false), 0);
}
dragMode = null;
try {
canvasEl?.releasePointerCapture(e.pointerId);
} catch {}
}
/**
* Choose the best port on `nodeId` for a connection of `type` whose source
* is reversed/forward. Forward = we're dragging an output, so we want an
* unconnected input on the target; reversed = we're dragging an input, so
* we want any output on the target. Returns null if nothing fits.
*/
function pickCompatiblePort(
nodeId: string,
type: PortType,
reversed: boolean
): { id: string; direction: "input" | "output" } | null {
const target = legacyView.nodes.find((n) => n.id === nodeId);
if (!target) return null;
if (reversed) {
const out = target.outputs.find((p) => ports_compatible(p.type, type));
return out ? { id: out.id, direction: "output" } : null;
}
const inPort = target.inputs.find(
(p) =>
ports_compatible(type, p.type) &&
!$workflow.edges.some(
(e) => e.to_node_id === nodeId && e.to_port_id === p.id
)
);
return inPort ? { id: inPort.id, direction: "input" } : null;
}
/**
* Cache of measured port-handle centers in canvas-space. Re-populated after
* any node change via the effect below; canvas coords are invariant to
* pan/zoom so we don't need to re-measure on viewport changes.
*/
let portPositions = $state(new Map<string, { x: number; y: number }>());
function portKey(
nodeId: string,
portId: string,
direction: "input" | "output"
): string {
return `${nodeId}:${portId}:${direction}`;
}
$effect(() => {
legacyView.nodes;
// Defer until after Svelte commits this render so the handle elements
// have their final positions in the DOM.
const raf = requestAnimationFrame(() => {
if (!canvasEl) return;
const cr = canvasEl.getBoundingClientRect();
const next = new Map<string, { x: number; y: number }>();
canvasEl.querySelectorAll<HTMLElement>("[data-port-id]").forEach((el) => {
const nodeId = el.getAttribute("data-node-id");
const portId = el.getAttribute("data-port-id");
const dir = el.getAttribute("data-port-direction") as
| "input"
| "output"
| null;
if (!nodeId || !portId || !dir) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2 - cr.left;
const cy = r.top + r.height / 2 - cr.top;
next.set(portKey(nodeId, portId, dir), {
x: (cx - viewport.x) / viewport.zoom,
y: (cy - viewport.y) / viewport.zoom
});
});
portPositions = next;
});
return () => cancelAnimationFrame(raf);
});
/**
* Canvas-space position of a port handle. Uses the measured DOM position
* when available; falls back to a coarse estimate for the first paint
* before the measurement effect has run (handles are ~24px outside the
* node edge, so the heuristic offsets X by ±18 to hit the handle center).
*/
function portPos(
nodeId: string,
portId: string,
direction: "input" | "output"
): { x: number; y: number } | null {
const measured = portPositions.get(portKey(nodeId, portId, direction));
if (measured) return measured;
const node = legacyView.nodes.find((n) => n.id === nodeId);
if (!node) return null;
const ports = direction === "input" ? node.inputs : node.outputs;
const i = ports.findIndex((p) => p.id === portId);
if (i < 0) return null;
const headerHeight = 36;
const rowHeight = 30;
const y = node.y + headerHeight + i * rowHeight + rowHeight / 2;
const x = direction === "input" ? node.x - 18 : node.x + node.width + 18;
return { x, y };
}
function bezier(
a: { x: number; y: number },
b: { x: number; y: number }
): string {
const dx = Math.max(40, Math.abs(b.x - a.x) * 0.5);
return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
}
function edgePath(edge: WFEdge): string | null {
const a = portPos(edge.from_node_id, edge.from_port_id, "output");
const b = portPos(edge.to_node_id, edge.to_port_id, "input");
if (!a || !b) return null;
return bezier(a, b);
}
function connectionPreviewPath(
mode: Extract<DragMode, { kind: "connection" }>
): string | null {
const port = portPos(
mode.fromNodeId,
mode.fromPortId,
mode.reversed ? "input" : "output"
);
if (!port) return null;
const cursor = { x: mode.cursorCanvasX, y: mode.cursorCanvasY };
return mode.reversed ? bezier(cursor, port) : bezier(port, cursor);
}
function tryCreateEdge(
fromId: string,
fromPort: string,
fromIsInput: boolean,
toId: string,
toPort: string,
toIsInput: boolean
): void {
if (fromIsInput === toIsInput) return; // both ends same direction → invalid
// Normalize: edges always flow output → input
const [sourceId, sourcePort, targetId, targetPort] = fromIsInput
? [toId, toPort, fromId, fromPort]
: [fromId, fromPort, toId, toPort];
const sourceNode = legacyView.nodes.find((n) => n.id === sourceId);
const targetNode = legacyView.nodes.find((n) => n.id === targetId);
if (!sourceNode || !targetNode) return;
const outPort = sourceNode.outputs.find((p) => p.id === sourcePort);
const inPort = targetNode.inputs.find((p) => p.id === targetPort);
if (!outPort || !inPort || !ports_compatible(outPort.type, inPort.type))
return;
addEdge({
from_node_id: sourceId,
from_port_id: sourcePort,
to_node_id: targetId,
to_port_id: targetPort,
type: outPort.type
});
}
function findSourcePort(
nodeId: string,
portId: string,
reversed: boolean
): Port | undefined {
const node = legacyView.nodes.find((n) => n.id === nodeId);
return reversed
? node?.inputs.find((p) => p.id === portId)
: node?.outputs.find((p) => p.id === portId);
}
function buildModelOptions(type: PortType): DropOption[] {
const modality = modalityForPort(type);
if (!modality) return [];
return modality.subtabs
.filter((st) => st.key !== "all")
.map((st) => ({
kind: "model" as const,
label: st.label,
subtab: st.key
}));
}
function buildComponentOptions(
type: PortType,
reversed: boolean
): DropOption[] {
return [
{ kind: "component" as const, label: inputOutputLabel(type, reversed) }
];
}
function handleDropChoiceModel(subtab?: string): void {
dropChoice = null;
if (!pendingDrop) return;
const modality = modalityForPort(pendingDrop.type);
if (!modality) return;
activePicker = { mode: "create", modality, initialSubtab: subtab };
}
function handleDropChoiceUpload(): void {
dropChoice = null;
const drop = pendingDrop;
pendingDrop = null;
if (!drop) return;
const typedComponents: Record<string, any> = {};
for (const c of LIBRARY.components) {
typedComponents[c.outputs[0]?.type ?? "any"] = c;
}
const base = typedComponents[drop.type] ?? LIBRARY.components[0];
const sourcePort = findSourcePort(
drop.from_node_id,
drop.from_port_id,
drop.reversed ?? false
);
const choiceInfo =
sourcePort?.choices && sourcePort.choices.length > 0
? {
choices: sourcePort.choices,
multiselect: !!sourcePort.multiselect
}
: null;
const template = choiceInfo
? {
...base,
label: sourcePort?.label || base.label,
inputs: base.inputs.map((p: Port) => ({ ...p, ...choiceInfo })),
outputs: base.outputs.map((p: Port) => ({ ...p, ...choiceInfo }))
}
: base;
const rawX = drop.reversed
? drop.x - (template.width ?? 200) - 80
: drop.x - (template.width ?? 200) / 2;
const { x, y } = findFreeSpot(rawX, drop.y - 45);
// reversed=true → user dragged from an input port; they need a Source
// (reference) supplying that value. reversed=false → user dragged from
// an output port; they need a Sink (subject) displaying that value.
const role: "reference" | "subject" = drop.reversed
? "reference"
: "subject";
const newId = addNode(role, template, x, y);
const newNode = legacyView.nodes.find((n) => n.id === newId);
if (drop.reversed) {
const outputPort = newNode?.outputs.find((p: any) =>
ports_compatible(p.type, drop.type)
);
if (outputPort) {
addEdge({
from_node_id: newId,
from_port_id: outputPort.id,
to_node_id: drop.from_node_id,
to_port_id: drop.from_port_id,
type: drop.type
});
}
} else {
const inputPort = newNode?.inputs.find((p: any) =>
ports_compatible(drop.type, p.type)
);
if (inputPort) {
addEdge({
from_node_id: drop.from_node_id,
from_port_id: drop.from_port_id,
to_node_id: newId,
to_port_id: inputPort.id,
type: drop.type
});
}
}
}
function handleCanvasDoubleClick(e: MouseEvent): void {
if (readOnly) return;
const target = e.target as HTMLElement;
if (
target.closest(".node-pos-wrap") ||
target.closest(".drop-menu") ||
target.closest(".picker-panel")
)
return;
const r = canvasEl?.getBoundingClientRect();
if (!r) return;
setTimeout(() => {
doubleClickMenu = {
clientX: e.clientX - r.left,
clientY: e.clientY - r.top,
canvasX: (e.clientX - r.left - viewport.x) / viewport.zoom,
canvasY: (e.clientY - r.top - viewport.y) / viewport.zoom
};
}, 0);
}
function handleDoubleClickInputNode(portType: PortType): void {
if (!doubleClickMenu) return;
const { canvasX, canvasY } = doubleClickMenu;
doubleClickMenu = null;
const typedComponents: Record<string, any> = {};
for (const c of LIBRARY.components) {
typedComponents[c.outputs[0]?.type ?? "any"] = c;
}
const template = typedComponents[portType] ?? LIBRARY.components[0];
addNode(
"reference",
template,
canvasX - (template.width ?? 200) / 2,
canvasY - 45
);
}
function handleDoubleClickUpload(): void {
if (!doubleClickMenu) return;
const { canvasX, canvasY } = doubleClickMenu;
doubleClickMenu = null;
// Attach the input to body briefly. Safari (and some popup
// blockers) refuse to open the OS file-picker dialog when
// `input.click()` is called on a detached element, even from
// a real user gesture.
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,audio/*,video/*";
input.style.display = "none";
document.body.appendChild(input);
const cleanup = (): void => {
try {
document.body.removeChild(input);
} catch {
/* noop */
}
};
input.onchange = () => {
const file = input.files?.[0];
if (!file) {
cleanup();
return;
}
let portType: PortType = "file";
if (file.type.startsWith("image/")) portType = "image";
else if (file.type.startsWith("audio/")) portType = "audio";
else if (file.type.startsWith("video/")) portType = "video";
const typedComponents: Record<string, any> = {};
for (const c of LIBRARY.components) {
typedComponents[c.outputs[0]?.type ?? "any"] = c;
}
const template = typedComponents[portType] ?? LIBRARY.components[0];
const { x: fx, y: fy } = findFreeSpot(
canvasX - (template.width ?? 200) / 2,
canvasY - 45
);
const newId = addNode("reference", template, fx, fy);
const url = URL.createObjectURL(file);
const portId = template.outputs[0]?.id;
if (portId && newId) {
updateNodeData(newId, portId, {
url,
path: file.name,
name: file.name
} as any);
}
cleanup();
};
// `cancel` fires when the user dismisses the dialog without picking.
input.oncancel = cleanup;
input.click();
}
function handleDoubleClickSpaceModel(modality: ModalityConfig): void {
if (!doubleClickMenu) return;
const { canvasX, canvasY } = doubleClickMenu;
doubleClickMenu = null;
pendingDrop = {
from_node_id: "",
from_port_id: "",
type: "any" as PortType,
x: canvasX,
y: canvasY,
positionOnly: true
};
activePicker = { mode: "create", modality };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function resolveCurrentInputs(node: WFNode): Record<string, unknown> {
return resolveCurrentInputsImpl(node, legacyView.nodes, $workflow.edges);
}
const staleNodes = $derived(
computeStaleNodes(
legacyView.nodes,
$workflow.edges,
nodeStatus,
nodeInputSnapshots
)
);
function canvasCenter(): { x: number; y: number } {
if (!canvasEl) return { x: 200, y: 200 };
const r = canvasEl.getBoundingClientRect();
return {
x: (r.width / 2 - viewport.x) / viewport.zoom - 150,
y: (r.height / 2 - viewport.y) / viewport.zoom - 60
};
}
/**
* Walk diagonally from (x, y) in 28px steps until an open spot is found
* that doesn't visually overlap an existing node. Used by any code path
* that drops a fresh node at a fixed fallback position (canvas center,
* picker create, etc.) — without this, successive adds stack identically.
*/
function findFreeSpot(x: number, y: number): { x: number; y: number } {
return findFreeSpotImpl(legacyView.nodes, x, y);
}
async function addTemplateToCanvas(
template: any,
x?: number,
y?: number
): Promise<string | null> {
if (readOnly) return null;
if (
template.source === "space" &&
template.space_id &&
template.inputs.length === 0
) {
showToast(`Connecting to ${template.space_id}...`);
try {
const apiInfo = await fetchSpaceApi(template.space_id);
template.inputs = apiInfo.inputs;
template.outputs = apiInfo.outputs;
template.endpoint = apiInfo.endpoint;
template.width = apiInfo.width;
} catch (err) {
showToast(
err instanceof Error ? err.message : "Failed to connect to Space",
5000,
"error"
);
return null;
}
}
if (x === undefined || y === undefined) {
const nodes = legacyView.nodes;
x =
nodes.length > 0
? Math.max(...nodes.map((n) => n.x + n.width)) + 80
: 200;
y = nodes.length > 0 ? nodes[nodes.length - 1].y : 150;
}
return addNode(templateRole(template), template, x, y);
}
async function handleDrop(e: DragEvent): Promise<void> {
e.preventDefault();
if (readOnly) return;
const raw = e.dataTransfer?.getData("node-template");
if (!raw) return;
const template = JSON.parse(raw);
const r = canvasEl.getBoundingClientRect();
const x = (e.clientX - r.left - viewport.x) / viewport.zoom - 100;
const y = (e.clientY - r.top - viewport.y) / viewport.zoom - 45;
await addTemplateToCanvas(template, x, y);
}
function revokeAllBlobUrls(nodes: WFNode[]): void {
for (const node of nodes) revoke_blob_urls(node.data);
}
let clearConfirm = $state(false);
function clearWorkflow(): void {
if (legacyView.nodes.length === 0 || readOnly) return;
clearConfirm = true;
}
function confirmClearWorkflow(): void {
clearConfirm = false;
if (readOnly) return;
revokeAllBlobUrls(legacyView.nodes);
workflow.set({
schema_version: "2",
name: $workflow.name,
runtime: { default: "client" },
references: [],
operators: [],
subjects: [],
edges: [],
view: { default: "canvas" }
});
}
function autoLayout(): void {
const sorted = topoSort(legacyView.nodes, $workflow.edges);
const edges = $workflow.edges;
const depth = new Map<string, number>();
for (const node of sorted) {
const maxParent = edges
.filter((e) => e.to_node_id === node.id)
.map((e) => depth.get(e.from_node_id) ?? 0)
.reduce((max, d) => Math.max(max, d + 1), 0);
depth.set(node.id, maxParent);
}
const columns = new Map<number, WFNode[]>();
for (const node of sorted) {
const d = depth.get(node.id) ?? 0;
if (!columns.has(d)) columns.set(d, []);
columns.get(d)!.push(node);
}
const gap = 30;
const colGap = 80;
// Auto-layout produces { id -> {x, y} }; apply to v2 arrays without
// changing role membership.
const positions = new Map<string, { x: number; y: number }>();
let xOffset = 80;
for (const [_, col] of [...columns.entries()].sort((a, b) => a[0] - b[0])) {
let yOffset = 80;
let maxWidth = 0;
for (const node of col) {
positions.set(node.id, { x: xOffset, y: yOffset });
yOffset += node.height + gap;
maxWidth = Math.max(maxWidth, node.width);
}
xOffset += maxWidth + colGap;
}
workflow.update((wf) => {
const reposition = <T extends { id: string; x: number; y: number }>(
n: T
): T => {
const p = positions.get(n.id);
return p ? { ...n, x: p.x, y: p.y } : n;
};
return {
...wf,
references: wf.references.map(reposition),
operators: wf.operators.map(reposition),
subjects: wf.subjects.map(reposition)
};
});
}
async function runNode(targetId: string): Promise<void> {
if (running) return;
await runWorkflow(buildUpstreamSubgraphImpl($workflow, targetId));
}
async function runWorkflow(target?: Workflow): Promise<void> {
if (running) return;
running = true;
const wfToRun = target ?? $workflow;
// Clear status only for nodes we're about to run, so already-finished
// nodes outside the target subgraph keep their snapshots + state.
const runningIds = new Set([
...wfToRun.references.map((n) => n.id),
...wfToRun.operators.map((n) => n.id),
...wfToRun.subjects.map((n) => n.id)
]);
nodeStatus = Object.fromEntries(
Object.entries(nodeStatus).filter(([id]) => !runningIds.has(id))
);
nodeErrors = Object.fromEntries(
Object.entries(nodeErrors).filter(([id]) => !runningIds.has(id))
);
abortController = new AbortController();
if (
legacyView.nodes.some((n) => n.source === "space" && n.space_id) &&
!auth.token
) {
showToast(
"Running as guest — GPU Spaces may hit quota limits. Sign in with HuggingFace for your own compute.",
5000,
"warning"
);
}
const callSpaceWithToken = server?.call_space
? async (spaceId: string, endpoint: string, argsJson: string) =>
server.call_space([spaceId, endpoint, argsJson, auth.token])
: undefined;
const callModelWithToken = server?.call_model
? async (
modelId: string,
pipelineTag: string,
argsJson: string,
provider?: string
) =>
server.call_model([
modelId,
pipelineTag,
argsJson,
auth.token,
provider ?? ""
])
: undefined;
const fetchDatasetWithToken = server?.fetch_dataset
? async (
datasetId: string,
config: string,
split: string,
offset: string,
length: string
) =>
server.fetch_dataset([
datasetId,
config,
split,
offset,
length,
auth.token
])
: undefined;
const callFnWithToken = server?.call_fn
? async (fnName: string, argsJson: string) =>
server.call_fn([fnName, argsJson])
: undefined;
await executeWorkflow(
wfToRun,
(nodeId, status, error, errorType) => {
nodeStatus = { ...nodeStatus, [nodeId]: status };
if (status === "done") {
const node = legacyView.nodes.find((n) => n.id === nodeId);
if (node) {
nodeInputSnapshots = {
...nodeInputSnapshots,
[nodeId]: JSON.stringify(resolveCurrentInputs(node))
};
}
}
if (error) {
const node = legacyView.nodes.find((n) => n.id === nodeId);
const label =
node?.label ?? node?.space_id ?? node?.model_id ?? "Node";
if (errorType === "quota" || errorType === "gpu") {
const headline =
errorType === "quota"
? "GPU quota exceeded"
: "GPU unavailable — try again in a minute";
nodeErrors = { ...nodeErrors, [nodeId]: headline };
const cta = auth.getQuotaCTA();
showToast(
`${label}: ${headline}. ${cta.suffix}`,
0,
"pro",
cta.action
);
} else {
nodeErrors = { ...nodeErrors, [nodeId]: error };
showToast(`${label}: ${error}`, 5000, "error");
}
}
},
(nodeId, portId, value) => {
updateNodeData(nodeId, portId, value);
},
abortController.signal,
callSpaceWithToken,
callModelWithToken,
fetchDatasetWithToken,
callFnWithToken,
auth.token
? (modelId, prompt, provider, signal, onChunk) =>
stream_text_generation({
modelId,
prompt,
provider,
hfToken: auth.token,
signal: signal ?? undefined,
onChunk
})
: undefined
);
running = false;
abortController = null;
const hasErrors = Object.values(nodeStatus).some((s) => s === "error");
showToast(
hasErrors ? "Workflow finished with errors" : "Workflow complete",
hasErrors ? 5000 : 3000,
hasErrors ? "error" : "success"
);
setTimeout(() => {
nodeStatus = Object.fromEntries(
Object.entries(nodeStatus).filter(([_, s]) => s === "error")
);
}, 3000);
}
function stopWorkflow(): void {
abortController?.abort();
running = false;
abortController = null;
nodeStatus = Object.fromEntries(
Object.entries(nodeStatus).map(([id, s]) => [
id,
s === "running" ? "idle" : s
])
);
showToast("Stopped", 3000, "warning");
}
function handleGlobalClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
if (dropChoice && !target?.closest(".drop-menu")) {
dropChoice = null;
pendingDrop = null;
}
if (doubleClickMenu && !target?.closest(".add-node-menu")) {
doubleClickMenu = null;
}
if (
showShortcuts &&
!target?.closest(".shortcuts-panel") &&
!target?.closest(".zoom-controls")
) {
showShortcuts = false;
}
if (activePicker && !target?.closest(".picker-panel")) {
activePicker = null;
}
if (showUserMenu && !target?.closest(".toolbar-user-wrap")) {
showUserMenu = false;
}
if (showAccessInfo && !target?.closest(".access-info-wrap")) {
showAccessInfo = false;
}
}
function zoomToFit(): void {
const nodes = legacyView.nodes;
if (nodes.length === 0) {
viewport = { x: 0, y: 0, zoom: 1 };
return;
}
const padding = 60;
const minX = Math.min(...nodes.map((n) => n.x));
const minY = Math.min(...nodes.map((n) => n.y));
const maxX = Math.max(...nodes.map((n) => n.x + n.width));
const maxY = Math.max(...nodes.map((n) => n.y + n.height));
const contentW = maxX - minX + padding * 2;
const contentH = maxY - minY + padding * 2;
if (!canvasEl) {
viewport = { x: 0, y: 0, zoom: 1 };
return;
}
const r = canvasEl.getBoundingClientRect();
const newZoom = Math.min(
Math.max(Math.min(r.width / contentW, r.height / contentH), 0.15),
2
);
viewport = {
x: (r.width - contentW * newZoom) / 2 - (minX - padding) * newZoom,
y: (r.height - contentH * newZoom) / 2 - (minY - padding) * newZoom,
zoom: newZoom
};
}
function selectNode(id: string, additive = false): void {
if (last_node_drag_moved) return;
selectedNodeIds = additive
? toggle_set(selectedNodeIds, id)
: new Set([id]);
selectedEdgeIds = new Set();
}
function clear_selection(): void {
selectedNodeIds = new Set();
selectedEdgeIds = new Set();
}
function toggle_set<T>(set: Set<T>, value: T): Set<T> {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
}
function rects_intersect(
ax: number,
ay: number,
aw: number,
ah: number,
bx: number,
by: number,
bw: number,
bh: number
): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
function nodes_in_rect(
x1: number,
y1: number,
x2: number,
y2: number
): Set<string> {
const rx = Math.min(x1, x2);
const ry = Math.min(y1, y2);
const rw = Math.abs(x2 - x1);
const rh = Math.abs(y2 - y1);
const out = new Set<string>();
for (const n of legacyView.nodes) {
if (rects_intersect(n.x, n.y, n.width, n.height, rx, ry, rw, rh)) {
out.add(n.id);
}
}
return out;
}
function edges_in_rect(
x1: number,
y1: number,
x2: number,
y2: number
): Set<string> {
const rx = Math.min(x1, x2);
const ry = Math.min(y1, y2);
const rw = Math.abs(x2 - x1);
const rh = Math.abs(y2 - y1);
const out = new Set<string>();
const point_in = (px: number, py: number): boolean =>
px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
for (const e of $workflow.edges) {
const a = portPos(e.from_node_id, e.from_port_id, "output");
const b = portPos(e.to_node_id, e.to_port_id, "input");
if (!a || !b) continue;
const dx = Math.max(40, Math.abs(b.x - a.x) * 0.5);
const c1x = a.x + dx;
const c1y = a.y;
const c2x = b.x - dx;
const c2y = b.y;
let hit = false;
for (let i = 0; i <= 12; i++) {
const t = i / 12;
const mt = 1 - t;
const px =
mt * mt * mt * a.x +
3 * mt * mt * t * c1x +
3 * mt * t * t * c2x +
t * t * t * b.x;
const py =
mt * mt * mt * a.y +
3 * mt * mt * t * c1y +
3 * mt * t * t * c2y +
t * t * t * b.y;
if (point_in(px, py)) {
hit = true;
break;
}
}
if (hit) out.add(e.id);
}
return out;
}
function duplicateNode(id: string): void {
const node = legacyView.nodes.find((n) => n.id === id);
if (!node) return;
const { id: _, data: __, ...template } = node;
const new_id = addNode(
templateRole(template),
template,
node.x + 40,
node.y + 40
);
selectedNodeIds = new Set([new_id]);
selectedEdgeIds = new Set();
}
function handleKeydown(e: KeyboardEvent): void {
if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey) &&
!running &&
hasTransforms
) {
e.preventDefault();
runWorkflow();
return;
}
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.code === "Space") {
spaceHeld = true;
e.preventDefault();
}
if (
(e.key === "Delete" || e.key === "Backspace") &&
!readOnly &&
(selectedNodeIds.size > 0 || selectedEdgeIds.size > 0)
) {
e.preventDefault();
const node_ids = [...selectedNodeIds];
const edge_ids = [...selectedEdgeIds];
selectedNodeIds = new Set();
selectedEdgeIds = new Set();
requestAnimationFrame(() => {
for (const id of node_ids) removeNode(id);
for (const id of edge_ids) removeEdge(id);
});
}
if (e.key === "d" && (e.metaKey || e.ctrlKey) && selectedNodeId) {
e.preventDefault();
if (!readOnly) duplicateNode(selectedNodeId);
}
if (e.key === "f" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
zoomToFit();
}
if (
e.key === "a" &&
(e.metaKey || e.ctrlKey) &&
legacyView.nodes.length > 0
) {
e.preventDefault();
selectedNodeIds = new Set(legacyView.nodes.map((n) => n.id));
selectedEdgeIds = new Set($workflow.edges.map((edge) => edge.id));
}
if (e.key === "0" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
viewport = { x: 0, y: 0, zoom: 1 };
}
if (e.key === "Escape") {
clear_selection();
activeConnection = null;
activePicker = null;
pendingDrop = null;
dropChoice = null;
doubleClickMenu = null;
clearConfirm = false;
}
if (e.key === "Enter" && clearConfirm && !readOnly) {
e.preventDefault();
confirmClearWorkflow();
}
}
function openPicker(modality: ModalityConfig): void {
activePicker = { mode: "create", modality };
}
function openPickerForNode(nodeId: string): void {
const node = legacyView.nodes.find((n) => n.id === nodeId);
if (!node) return;
// Components no longer open the picker — only transform nodes can have
// their model/space swapped. Guard so any stale callers no-op cleanly.
if (node.kind !== "transform") return;
let modality: ModalityConfig;
if (node.dataset_id) {
modality = DATASET_MODALITY;
} else {
const cat = node.pipeline_tag
? getModalityForPipelineTag(node.pipeline_tag)
: "image";
modality = MODALITIES.find((m) => m.category === cat) ?? MODALITIES[0];
}
let anchorX: number | undefined;
let anchorY: number | undefined;
if (canvasEl && rootEl) {
const rootRect = rootEl.getBoundingClientRect();
const canvasRect = canvasEl.getBoundingClientRect();
const canvasOffsetX = canvasRect.left - rootRect.left;
const canvasOffsetY = canvasRect.top - rootRect.top;
const panelWidth = Math.min(940, rootRect.width - 8);
const panelHeight = Math.min(720, rootRect.height - 160);
const nodeRight =
canvasOffsetX +
node.x * viewport.zoom +
viewport.x +
node.width * viewport.zoom;
const nodeLeft = canvasOffsetX + node.x * viewport.zoom + viewport.x;
const nodeTop = canvasOffsetY + node.y * viewport.zoom + viewport.y;
anchorX = nodeRight + 12;
if (anchorX + panelWidth > rootRect.width - 4) {
anchorX = nodeLeft - panelWidth - 12;
}
anchorX = Math.max(4, Math.min(anchorX, rootRect.width - panelWidth - 4));
anchorY = Math.max(
4,
Math.min(nodeTop, rootRect.height - panelHeight - 8)
);
}
activePicker = {
mode: "update",
modality,
nodeId,
anchorX,
anchorY
};
}
function getModalityForPipelineTag(tag: string): string {
const map: Record<string, string> = {
"text-to-image": "image",
"image-to-image": "image",
"text-to-audio": "audio",
"text-to-speech": "audio",
"automatic-speech-recognition": "audio",
"text-to-video": "video",
"image-to-video": "video",
"text-to-3d": "3d",
"image-to-3d": "3d",
"text-generation": "text",
summarization: "text",
translation: "text"
};
return map[tag] ?? "image";
}
const SUBGRAPH_PORT_TYPES = new Set([
"image",
"audio",
"video",
"text",
"file",
"gallery",
"boolean",
"number"
]);
async function handlePickerCreate(template: any): Promise<void> {
activePicker = null;
const drop = pendingDrop;
pendingDrop = null;
let x: number, y: number;
if (drop) {
x = drop.reversed
? drop.x - (template.width ?? 200) - 80
: drop.x - (template.width ?? 200) / 2;
y = drop.y - 60;
} else {
({ x, y } = findFreeSpot(canvasCenter().x, canvasCenter().y));
}
const newId = await addTemplateToCanvas({ ...template }, x, y);
if (!newId) return;
// For spaces added fresh to the canvas (not wired from an existing port),
// auto-create input + output components to form a ready-to-run subgraph
const isSpaceFresh =
template.source === "space" && (!drop || drop.positionOnly);
if (isSpaceFresh) {
const spaceNode = legacyView.nodes.find((n) => n.id === newId);
if (spaceNode) {
const compGap = 24;
const compH = 180;
// Skip ports with `choices` — they render an inline dropdown in
// the node body, so an auto-wired reference would be redundant
// (and worse, the reference is a plain textbox).
const typedInputs = spaceNode.inputs.filter(
(p) =>
SUBGRAPH_PORT_TYPES.has(p.type) &&
!(p.choices && p.choices.length > 0)
);
const requiredInputs = typedInputs.filter((p) => p.required !== false);
const inputPorts =
requiredInputs.length > 0 ? requiredInputs : typedInputs;
const outputPorts = spaceNode.outputs.filter((p) =>
SUBGRAPH_PORT_TYPES.has(p.type)
);
const inTotal = inputPorts.length * (compH + compGap) - compGap;
const inStartY = y + (spaceNode.height ?? 90) / 2 - inTotal / 2;
inputPorts.forEach((port, i) => {
const comp = getComponentForPortType(port.type);
if (!comp) return;
const { x: cx, y: cy } = findFreeSpot(
x - 220 - 80,
inStartY + i * (compH + compGap)
);
const cId = addNode("reference", comp, cx, cy);
addEdge({
from_node_id: cId,
from_port_id: "out",
to_node_id: newId,
to_port_id: port.id,
type: port.type
});
});
const outTotal = outputPorts.length * (compH + compGap) - compGap;
const outStartY = y + (spaceNode.height ?? 90) / 2 - outTotal / 2;
outputPorts.forEach((port, i) => {
const comp = getComponentForPortType(port.type);
if (!comp) return;
const { x: cx, y: cy } = findFreeSpot(
x + (spaceNode.width ?? 280) + 80,
outStartY + i * (compH + compGap)
);
const cId = addNode("subject", comp, cx, cy);
addEdge({
from_node_id: newId,
from_port_id: port.id,
to_node_id: cId,
to_port_id: "in",
type: port.type
});
});
}
return;
}
// Wiring from an existing port drag
if (drop && !drop.positionOnly) {
const newNode = legacyView.nodes.find((n) => n.id === newId);
if (drop.reversed) {
const outputPort = newNode?.outputs.find((p: any) =>
ports_compatible(p.type, drop.type)
);
if (outputPort) {
addEdge({
from_node_id: newId,
from_port_id: outputPort.id,
to_node_id: drop.from_node_id,
to_port_id: drop.from_port_id,
type: drop.type
});
}
} else {
const inputPort = newNode?.inputs.find((p: any) =>
ports_compatible(drop.type, p.type)
);
if (inputPort) {
addEdge({
from_node_id: drop.from_node_id,
from_port_id: drop.from_port_id,
to_node_id: newId,
to_port_id: inputPort.id,
type: drop.type
});
}
}
}
}
function handlePickerUpdate(nodeId: string, template: any): void {
if (!readOnly) replaceNodeSource(nodeId, template);
activePicker = null;
}
function addInputNode(portType: string, cx?: number, cy?: number): void {
if (readOnly) return;
let pos: { x: number; y: number };
if (cx !== undefined && cy !== undefined) {
const r = canvasEl?.getBoundingClientRect();
pos = r
? {
x: (cx - r.left - viewport.x) / viewport.zoom,
y: (cy - r.top - viewport.y) / viewport.zoom
}
: canvasCenter();
} else {
pos = canvasCenter();
}
const typedComponents: Record<string, any> = {};
for (const c of LIBRARY.components) {
typedComponents[c.outputs[0]?.type ?? "any"] = c;
}
const template = typedComponents[portType] ?? LIBRARY.components[0];
const half = (template.width ?? 200) / 2;
const { x, y } = findFreeSpot(pos.x - half, pos.y - 45);
addNode("reference", template, x, y);
}
/**
* Spawn a Python fn node from a server-advertised bound function.
* Wire-compatible with `_workflow_from_bind`: same id prefix, same
* port shape, so it merges cleanly with any auto-generated fn nodes
* already on the canvas.
*/
function addFnNode(tmpl: BoundFnTemplate): void {
if (readOnly) return;
const half = 110;
const { x, y } = findFreeSpot(
canvasCenter().x - half,
canvasCenter().y - 45
);
const height =
80 + Math.max(tmpl.inputs.length, tmpl.outputs.length, 1) * 36;
addNode(
"operator",
{
label: tmpl.label,
kind: "fn",
source: "fn",
fn: tmpl.fn,
inputs: tmpl.inputs,
outputs: tmpl.outputs,
width: 220,
height
},
x,
y
);
}
function setName(value: string): void {
if (!readOnly) workflow.update((wf) => ({ ...wf, name: value }));
editingName = false;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="workflow-root" bind:this={rootEl} onclick={handleGlobalClick}>
<div class="toolbar">
<div class="toolbar-left">
{#if readOnly}
<span class="name-text name-readonly">{$workflow.name}</span>
{:else if editingName}
<input
bind:this={nameInput}
class="name-input"
value={$workflow.name}
onblur={(e) => setName(e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === "Enter") setName(e.currentTarget.value);
if (e.key === "Escape") editingName = false;
}}
/>
{:else}
<button
class="name-btn"
onclick={() => {
editingName = true;
requestAnimationFrame(() => nameInput?.focus());
}}
>
<span class="name-text">{$workflow.name}</span>
<span class="name-edit-icon">&#x270E;</span>
</button>
{/if}
<span class="toolbar-stat"
>{subgraphCount}
{subgraphCount === 1 ? "subgraph" : "subgraphs"}</span
>
<button
class="tool-btn api-btn"
onclick={() => (showApiPanel = true)}
title="View the API for this workflow"
>
<CodeIcon />
View API
</button>
{#if saveIndicator}
<span
class="save-indicator"
in:fade={{ duration: 120 }}
out:fade={{ duration: 400 }}
>
<CheckIcon />
Saved
</span>
{/if}
</div>
<div class="toolbar-right">
{#if auth.status !== "checking"}
{#if auth.user}
<div class="toolbar-user-wrap">
<button
class="toolbar-user-chip"
class:open={showUserMenu}
onclick={(e) => {
e.stopPropagation();
showUserMenu = !showUserMenu;
}}
title="Account"
>
{#if auth.avatarUrl}
<img
class="toolbar-user-avatar"
src={auth.avatarUrl}
alt=""
referrerpolicy="no-referrer"
/>
{:else}
<span class="toolbar-user-avatar toolbar-user-avatar-fallback"
>{auth.user.charAt(0).toUpperCase()}</span
>
{/if}
<span class="toolbar-user-name">{auth.user}</span>
</button>
{#if showUserMenu}
<div class="toolbar-user-menu">
<div class="toolbar-user-menu-msg">{authExplanation}</div>
{#if auth.source !== "local"}
<button
class="toolbar-user-menu-btn"
onclick={() => {
showUserMenu = false;
auth.signOut();
}}>Log out</button
>
{/if}
</div>
{/if}
</div>
{:else if auth.isHFSpace && auth.oauthAvailable}
<button class="toolbar-login-btn" onclick={auth.signIn}
>Sign in with 🤗</button
>
{:else}
<form class="toolbar-token-form" onsubmit={(e) => e.preventDefault()}>
<input
class="toolbar-token-input"
class:invalid={auth.status === "invalid"}
type="password"
placeholder="Paste HF token (hf_...)"
value={auth.token}
onchange={(e) => auth.setPAT(e.currentTarget.value)}
title="HuggingFace token for GPU access"
/>
{#if auth.status === "validating"}
<span class="toolbar-token-status validating">checking…</span>
{:else if auth.status === "invalid"}
<span class="toolbar-token-status invalid">invalid</span>
{/if}
</form>
{/if}
{/if}
{#if !readOnly}
<button class="tool-btn" onclick={clearWorkflow}>Clear</button>
{#if auth.writeAccessKnown}
<span
class="access-badge access-write"
title="You have write access — changes you make are saved automatically."
>Write access</span
>
{/if}
{:else}
<div class="access-info-wrap">
<button
class="access-badge access-readonly"
class:open={showAccessInfo}
title={readOnlyReason}
aria-label="Why is this read-only?"
onclick={(e) => {
e.stopPropagation();
showAccessInfo = !showAccessInfo;
}}
>Run only<span class="access-info-icon"><InfoIcon /></span></button
>
{#if showAccessInfo}
<div class="access-info-popover">{readOnlyReason}</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="editor"
class:editor-panning={dragMode?.kind === "pan"}
class:editor-pan-ready={spaceHeld && !dragMode}
class:editor-marquee={dragMode?.kind === "marquee"}
bind:this={canvasEl}
ondragover={(e) => e.preventDefault()}
ondrop={handleDrop}
ondblclick={handleCanvasDoubleClick}
onpointerdown={onCanvasPointerDown}
onpointermove={onCanvasPointerMove}
onpointerup={onCanvasPointerUp}
onwheel={onWheel}
>
<!-- Dot grid background — fixed in screen space, parallax-free -->
<div
class="canvas-bg"
style="background-position: {viewport.x}px {viewport.y}px; background-size: {gridTile}px {gridTile}px;"
></div>
<!-- Viewport: all nodes + edges live in here, sharing one transform -->
<div
class="canvas-viewport"
style="transform: translate({viewport.x}px, {viewport.y}px) scale({viewport.zoom});"
>
<!-- Edges layer (SVG). overflow:visible so edges aren't clipped. -->
<svg class="edges-layer" width="1" height="1" style="overflow: visible;">
{#each legacyView.edges as edge (edge.id)}
{@const path = edgePath(edge)}
{#if path}
<path
class="edge-path"
class:edge-selected={selectedEdgeIds.has(edge.id)}
d={path}
stroke={PORT_COLOR[edge.type] ?? "#6b6e78"}
stroke-width={(selectedEdgeIds.has(edge.id) ? 3 : 2) /
viewport.zoom}
fill="none"
onclick={(e) => {
if (e.shiftKey)
selectedEdgeIds = toggle_set(selectedEdgeIds, edge.id);
else if (!readOnly) removeEdge(edge.id);
}}
/>
{/if}
{/each}
{#if dragMode?.kind === "connection"}
{@const preview = connectionPreviewPath(dragMode)}
{#if preview}
<path
class="edge-preview"
d={preview}
stroke={PORT_COLOR[dragMode.type] ?? "#6b6e78"}
stroke-width={2 / viewport.zoom}
stroke-dasharray="6 4"
fill="none"
/>
{/if}
{/if}
</svg>
<!-- Nodes layer -->
{#each legacyView.nodes as n (n.id)}
<div
class="node-pos-wrap"
class:node-pos-selected={selectedNodeIds.has(n.id)}
data-node-id={n.id}
style="left: {n.x}px; top: {n.y}px; width: {n.width}px;"
onpointerdown={(e) => startNodeDrag(e, n.id)}
>
<WorkflowNodeSF
id={n.id}
data={n}
selected={selectedNodeIds.has(n.id)}
/>
</div>
{/each}
{#if dragMode?.kind === "marquee" && dragMode.moved}
{@const mx = Math.min(dragMode.startCanvasX, dragMode.endCanvasX)}
{@const my = Math.min(dragMode.startCanvasY, dragMode.endCanvasY)}
{@const mw = Math.abs(dragMode.endCanvasX - dragMode.startCanvasX)}
{@const mh = Math.abs(dragMode.endCanvasY - dragMode.startCanvasY)}
<div
class="marquee-rect"
style="left: {mx}px; top: {my}px; width: {mw}px; height: {mh}px; border-width: {1 /
viewport.zoom}px;"
></div>
{/if}
</div>
{#if nodeCount === 0}
<WorkflowEmptyState />
{/if}
{#if running}
<div class="run-overlay"></div>
{/if}
{#if dropChoice}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Stop click + mousedown propagation: handleGlobalClick on
workflow-root closes activePicker when the click target is
outside .picker-panel. Without onclick stopPropagation, a
"Generate" click here would set activePicker and then have
the very same click bubble out and close it. -->
<div
class="drop-menu"
style="left: {dropChoice.clientX}px; top: {dropChoice.clientY}px;"
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
>
{#if dropChoice.modelOptions.length > 0}
<div class="drop-section-label">Models</div>
{#each dropChoice.modelOptions as opt}
<button
class="drop-opt"
onclick={() => handleDropChoiceModel(opt.subtab)}
>{opt.label}</button
>
{/each}
{/if}
{#if dropChoice.componentOptions.length > 0}
<div class="drop-section-label">
{dropChoice.reversed ? "Sources" : "Outputs"}
</div>
{#each dropChoice.componentOptions as opt}
<button class="drop-opt" onclick={handleDropChoiceUpload}
>{opt.label}</button
>
{/each}
{/if}
</div>
{/if}
{#if doubleClickMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Stop click+mousedown so handleGlobalClick (on .workflow-root)
doesn't close the menu before the inner button fires.
Same bug we fixed on .drop-menu earlier. -->
<div
class="add-node-menu"
style="left: {doubleClickMenu.clientX}px; top: {doubleClickMenu.clientY}px;"
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
>
<div class="add-node-section-label">Add</div>
<div class="add-node-grid">
<button class="add-node-type-btn" onclick={handleDoubleClickUpload}
>Upload Media</button
>
<button
class="add-node-type-btn"
onclick={() => handleDoubleClickInputNode("text" as PortType)}
>Text</button
>
</div>
<div class="add-node-divider"></div>
<div class="add-node-section-label">Space / Model</div>
<div class="add-node-grid">
{#each MODALITIES.filter( (m) => ["image", "audio", "video", "text", "3d"].includes(m.key) ) as m}
<button
class="add-node-type-btn"
onclick={() => handleDoubleClickSpaceModel(m)}>{m.label}</button
>
{/each}
</div>
</div>
{/if}
<div class="zoom-controls">
{#if !readOnly}
<button
class="zoom-ctrl-btn"
onclick={autoLayout}
title="Auto-arrange nodes"
aria-label="Auto-arrange nodes"
>
<LayoutIcon />
</button>
<div class="zoom-ctrl-divider"></div>
{/if}
<button
class="zoom-ctrl-btn"
onclick={() => (showShortcuts = !showShortcuts)}
title="Keyboard shortcuts">?</button
>
<button class="zoom-ctrl-btn" onclick={zoomToFit} title="Fit all (F)"
>&#x2922;</button
>
<button
class="zoom-ctrl-btn"
onclick={() => {
viewport = { ...viewport, zoom: Math.max(0.15, viewport.zoom / 1.2) };
}}></button
>
<button
class="zoom-btn"
onclick={() => {
viewport = { x: 0, y: 0, zoom: 1 };
}}>{Math.round(viewport.zoom * 100)}%</button
>
<button
class="zoom-ctrl-btn"
onclick={() => {
viewport = { ...viewport, zoom: Math.min(4, viewport.zoom * 1.2) };
}}>+</button
>
</div>
{#if showShortcuts}
<div class="shortcuts-panel">
<div class="shortcuts-title">Keyboard shortcuts</div>
<div class="shortcut-row">
<kbd>Cmd+Enter</kbd> <span>Run workflow</span>
</div>
<div class="shortcut-row">
<kbd>Cmd+D</kbd> <span>Duplicate node</span>
</div>
<div class="shortcut-row"><kbd>F</kbd> <span>Zoom to fit</span></div>
<div class="shortcut-row"><kbd>Cmd+0</kbd> <span>Reset zoom</span></div>
<div class="shortcut-row">
<kbd>Delete</kbd> <span>Remove selection</span>
</div>
<div class="shortcut-row">
<kbd>Cmd+A</kbd> <span>Select all</span>
</div>
<div class="shortcut-row"><kbd>Escape</kbd> <span>Deselect</span></div>
<div class="shortcut-row"><kbd>Scroll</kbd> <span>Pan</span></div>
<div class="shortcut-row">
<kbd>Cmd+Scroll</kbd> <span>Zoom</span>
</div>
<div class="shortcut-row">
<kbd>Drag canvas</kbd> <span>Marquee select</span>
</div>
<div class="shortcut-row">
<kbd>Shift+Drag</kbd> <span>Add to selection</span>
</div>
<div class="shortcut-row">
<kbd>Space+Drag</kbd> <span>Pan</span>
</div>
<div class="shortcut-row">
<kbd>Double-click</kbd> <span>Rename node</span>
</div>
</div>
{/if}
<WorkflowBottomBar
{running}
{hasTransforms}
{boundFns}
{server}
{readOnly}
activeModalityKey={activePicker?.modality.key ?? null}
onopenpicker={openPicker}
onaddinput={addInputNode}
onaddfn={addFnNode}
onrun={() => void runWorkflow()}
onstop={stopWorkflow}
/>
{#if activePicker}
<!-- Key on modality so swapping the bottom-bar modality button
while the picker is open re-mounts the component and
re-fetches Trending / New for the new modality. Without
this, NodeModelPicker just sees its `modality` prop change
and keeps stale activeSubtab + results. -->
{#key activePicker.modality.key}
<NodeModelPicker
mode={activePicker.mode}
modality={activePicker.modality}
nodeId={activePicker.nodeId}
initialSubtab={activePicker.initialSubtab}
{server}
anchorX={activePicker.anchorX}
anchorY={activePicker.anchorY}
oncreate={handlePickerCreate}
onupdate={handlePickerUpdate}
onclose={() => {
activePicker = null;
}}
oncleared={() => {
if (activePicker)
activePicker = { ...activePicker, modality: ALL_MODALITY };
}}
onerror={(msg) => showToast(msg, 5000, "error")}
/>
{/key}
{/if}
</div>
{#if toasts.length > 0}
<div class="wf-toast-stack">
{#each toasts as t (t.id)}
<div class="wf-toast wf-toast-{t.type}">
<span class="wf-toast-msg">{t.message}</span>
{#if t.action?.href}
<a
class="wf-toast-action"
href={t.action.href}
target="_blank"
rel="noopener noreferrer"
onclick={() => dismissToast(t.id)}
>
{t.action.label}
</a>
{:else if t.action?.onClick}
<button
class="wf-toast-action"
onclick={() => {
t.action?.onClick?.();
dismissToast(t.id);
}}
>
{t.action.label}
</button>
{/if}
<button
class="wf-toast-close"
aria-label="Dismiss"
title="Dismiss"
onclick={() => dismissToast(t.id)}
>
×
</button>
</div>
{/each}
</div>
{/if}
{#if clearConfirm}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wf-modal-backdrop" onclick={() => (clearConfirm = false)}>
<div
class="wf-modal"
role="dialog"
aria-modal="true"
aria-labelledby="wf-modal-title"
onclick={(e) => e.stopPropagation()}
>
<div class="wf-modal-title" id="wf-modal-title">Clear workflow?</div>
<div class="wf-modal-body">
This will remove <strong>{nodeCount}</strong>
{nodeCount === 1 ? "node" : "nodes"} and
<strong>{edgeCount}</strong>
{edgeCount === 1 ? "edge" : "edges"}. This can't be undone.
</div>
<div class="wf-modal-actions">
<button class="wf-modal-btn" onclick={() => (clearConfirm = false)}
>Cancel</button
>
<button
class="wf-modal-btn wf-modal-btn-danger"
onclick={confirmClearWorkflow}>Clear</button
>
</div>
</div>
</div>
{/if}
{#if showApiPanel}
<WorkflowApiPanel
{server}
workflowName={$workflow.name}
onClose={() => (showApiPanel = false)}
/>
{/if}
</div>
<style>
@import "./WorkflowCanvas.css";
/* ─── Custom canvas ─────────────────────────────────────────────────────── */
.editor {
cursor: default;
}
.editor.editor-pan-ready {
cursor: grab;
}
.editor.editor-panning {
cursor: grabbing;
}
.editor.editor-marquee {
cursor: crosshair;
}
.canvas-bg {
position: absolute;
inset: 0;
background-image: radial-gradient(#2a2b36 1px, transparent 1px);
background-repeat: repeat;
pointer-events: none;
}
.canvas-viewport {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
width: 0;
height: 0;
}
.edges-layer {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.edges-layer .edge-path {
cursor: pointer;
pointer-events: stroke;
transition: stroke-width 0.1s;
}
.edges-layer .edge-path:hover {
stroke-width: 3 !important;
}
.edges-layer .edge-preview {
pointer-events: none;
}
.node-pos-wrap {
position: absolute;
touch-action: none;
z-index: 1;
}
.node-pos-wrap.node-pos-selected {
z-index: 2;
}
.marquee-rect {
position: absolute;
border: 1px dashed #4f8cff;
background: rgba(79, 140, 255, 0.08);
pointer-events: none;
z-index: 3;
}
.edges-layer .edge-path.edge-selected {
filter: drop-shadow(0 0 4px #4f8cff);
}
:global(body:not(.dark)) .canvas-bg {
background-image: radial-gradient(#d0d2dc 1px, transparent 1px);
}
/* ─── Light mode ─── */
:global(body:not(.dark) .workflow-root) {
background: #f8f9fb;
border-color: #e2e4ea;
color-scheme: light;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 20px 60px rgba(0, 0, 0, 0.08);
}
:global(body:not(.dark) .toolbar) {
background: #ffffff;
border-bottom-color: #e2e4ea;
}
:global(body:not(.dark) .name-btn:hover) {
background: #f0f1f5;
}
:global(body:not(.dark) .name-text) {
color: #1a1b25;
}
:global(body:not(.dark) .name-edit-icon) {
color: #b0b2bc;
}
:global(body:not(.dark) .name-input) {
background: #ffffff;
color: #1a1b25;
}
:global(body:not(.dark) .toolbar-stat) {
color: #b0b2bc;
}
:global(body:not(.dark) .tool-btn) {
border-color: #e2e4ea;
color: #8b8d98;
}
:global(body:not(.dark) .tool-btn:hover) {
background: #f0f1f5;
color: #3e4050;
border-color: #d0d2dc;
}
:global(body:not(.dark) .toolbar-login-btn) {
border-color: #e2e4ea;
color: #6b6e78;
}
:global(body:not(.dark) .toolbar-login-btn:hover) {
background: #f0f1f5;
color: #1a1b25;
border-color: #d0d2dc;
}
:global(body:not(.dark) .toolbar-user-chip) {
border-color: #e2e4ea;
color: #3e4050;
}
:global(body:not(.dark) .toolbar-user-chip:hover),
:global(body:not(.dark) .toolbar-user-chip.open) {
background: #f0f1f5;
color: #1a1b25;
border-color: #d0d2dc;
}
:global(body:not(.dark) .toolbar-user-avatar) {
background: #e2e4ea;
}
:global(body:not(.dark) .toolbar-user-avatar-fallback) {
color: #6b6e78;
}
:global(body:not(.dark) .toolbar-user-menu) {
background: #ffffff;
border-color: #e2e4ea;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:global(body:not(.dark) .toolbar-user-menu-msg) {
color: #6b6e78;
}
:global(body:not(.dark) .toolbar-user-menu-btn) {
border-color: #e2e4ea;
color: #1a1b25;
}
:global(body:not(.dark) .toolbar-user-menu-btn:hover) {
background: #f0f1f5;
border-color: #d0d2dc;
}
:global(body:not(.dark) .toolbar-token-input) {
background: #ffffff;
color: #6b6e78;
border-color: #e2e4ea;
}
:global(body:not(.dark) .toolbar-token-input::placeholder) {
color: #c0c2cc;
}
:global(body:not(.dark) .toolbar-token-input:focus) {
background: #ffffff;
color: #3e4050;
}
:global(body:not(.dark) .toolbar-divider) {
background: #e2e4ea;
}
:global(body:not(.dark) .access-badge.access-readonly) {
color: #6b6e78;
background: #f0f1f5;
border-color: #e2e4ea;
}
:global(body:not(.dark) .access-badge.access-write) {
color: #0f9d76;
background: rgba(15, 157, 118, 0.08);
border-color: rgba(15, 157, 118, 0.25);
}
:global(body:not(.dark) button.access-badge.access-readonly:hover),
:global(body:not(.dark) button.access-badge.access-readonly.open) {
color: #3e4050;
border-color: #d0d2dc;
}
:global(body:not(.dark) .access-info-popover) {
background: #ffffff;
border-color: #e2e4ea;
color: #6b6e78;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:global(body:not(.dark) .save-indicator) {
color: #0f9d76;
}
:global(body:not(.dark) .zoom-ctrl-divider) {
background: #e2e4ea;
}
:global(body:not(.dark) .zoom-controls) {
background: #ffffff;
border-color: #e2e4ea;
}
:global(body:not(.dark) .zoom-ctrl-btn) {
color: #9a9caa;
}
:global(body:not(.dark) .zoom-ctrl-btn:hover) {
background: #f0f1f5;
color: #3e4050;
}
:global(body:not(.dark) .zoom-btn) {
background: #ffffff;
border-color: #e2e4ea;
color: #9a9caa;
}
:global(body:not(.dark) .zoom-btn:hover) {
color: #3e4050;
border-color: #d0d2dc;
}
:global(body:not(.dark) .shortcuts-panel) {
background: #ffffff;
border-color: #e2e4ea;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
:global(body:not(.dark) .shortcuts-title) {
color: #6b6e78;
}
:global(body:not(.dark) .shortcut-row) {
color: #9a9caa;
}
:global(body:not(.dark) .shortcut-row kbd) {
background: #f0f1f5;
color: #3e4050;
border-color: #d0d2dc;
}
:global(body:not(.dark) .wf-toast) {
background: #ffffff;
color: #2a2b36;
border-color: #e2e4ea;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
</style>