mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: add NodePalette and PipelineCanvas components for enhanced flow node management
- Introduced NodePalette component to display draggable nodes categorized by type. - Implemented PipelineCanvas component to manage the flow of nodes and connections. - Integrated drag-and-drop functionality for adding nodes to the canvas. - Created a catalog of node types with associated metadata for rendering in the palette. - Established conversion functions between pipeline definitions and React Flow state. - Added inspector for editing node properties and managing connections. - Updated Next.js configuration for production and development environments. - Removed obsolete embed.go file and added nginx configuration for serving the app. - Updated package dependencies including @xyflow/react for enhanced functionality. - Added TypeScript definitions for CSS imports to support styling in components.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { lookup } from "@/lib/node-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FlowNodeData } from "@/lib/pipeline-graph";
|
||||
|
||||
export function FlowNode({ data, selected }: NodeProps) {
|
||||
const pn = (data as FlowNodeData).pipelineNode;
|
||||
const entry = lookup(pn.type);
|
||||
const Icon = entry?.icon ?? AlertTriangle;
|
||||
const outputs = entry?.outputs ?? 1;
|
||||
const isTrigger = pn.type === "flow-nodes-base.trigger";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-[200px] rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow",
|
||||
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
|
||||
!entry && "border-destructive/60"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-t-lg px-3 py-2 text-xs font-medium text-white",
|
||||
entry?.color ?? "bg-destructive"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span className="truncate">{entry?.label ?? "Unsupported"}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="truncate text-sm font-medium">{pn.name}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{pn.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Input handle: triggers have no inputs */}
|
||||
{!isTrigger && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="i-0"
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-background !bg-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Output handle(s) */}
|
||||
{Array.from({ length: outputs }).map((_, i) => {
|
||||
const top = outputs === 1 ? "50%" : `${((i + 1) / (outputs + 1)) * 100}%`;
|
||||
return (
|
||||
<Handle
|
||||
key={i}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={`o-${i}`}
|
||||
style={{ top }}
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-background !bg-primary"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { lookup } from "@/lib/node-catalog";
|
||||
import type { PipelineNode } from "@/lib/pipeline-graph";
|
||||
|
||||
interface InspectorProps {
|
||||
node: PipelineNode | null;
|
||||
onChange: (next: PipelineNode) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [paramsText, setParamsText] = useState("{}");
|
||||
const [paramsErr, setParamsErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setName(node.name);
|
||||
setParamsText(JSON.stringify(node.parameters ?? {}, null, 2));
|
||||
setParamsErr(null);
|
||||
}, [node?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<aside className="w-80 shrink-0 border-l bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
Select a node to edit its parameters.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const entry = lookup(node.type);
|
||||
|
||||
function commitName(next: string) {
|
||||
if (!node) return;
|
||||
onChange({ ...node, name: next });
|
||||
}
|
||||
function commitParams(text: string) {
|
||||
if (!node) return;
|
||||
try {
|
||||
const parsed = JSON.parse(text || "{}");
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("parameters must be a JSON object");
|
||||
}
|
||||
setParamsErr(null);
|
||||
onChange({ ...node, parameters: parsed as Record<string, unknown> });
|
||||
} catch (e) {
|
||||
setParamsErr(e instanceof Error ? e.message : "invalid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex w-80 shrink-0 flex-col border-l bg-muted/20">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{node.name}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{node.type}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={entry ? "secondary" : "destructive"}>
|
||||
{entry ? "supported" : "unsupported"}
|
||||
</Badge>
|
||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="node-name">Name</Label>
|
||||
<Input
|
||||
id="node-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => name !== node.name && commitName(name)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Names are used as references in <code>{`{{ $('Name').json.x }}`}</code>{" "}
|
||||
expressions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="node-params">Parameters (JSON)</Label>
|
||||
<Textarea
|
||||
id="node-params"
|
||||
value={paramsText}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
onBlur={() => commitParams(paramsText)}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
{paramsErr && (
|
||||
<p className="text-[11px] text-destructive">{paramsErr}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry && (
|
||||
<div className="rounded-md border bg-background p-2 text-[11px] text-muted-foreground">
|
||||
{entry.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDelete(node.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete node
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { CATALOG, GROUP_LABELS, type CatalogEntry } from "@/lib/node-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
const DRAG_TYPE = "application/x-flow-node";
|
||||
|
||||
export function NodePalette() {
|
||||
const groups = CATALOG.reduce<Record<string, CatalogEntry[]>>((acc, c) => {
|
||||
(acc[c.group] ??= []).push(c);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 overflow-y-auto border-r bg-muted/20 p-3">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-semibold">Nodes</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Drag onto the canvas to add.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groups).map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{GROUP_LABELS[group as CatalogEntry["group"]] ?? group}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<PaletteItem key={item.type + item.label} entry={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteItem({ entry }: { entry: CatalogEntry }) {
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(DRAG_TYPE, entry.type);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
title={entry.description}
|
||||
className="group flex cursor-grab items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm shadow-sm transition-colors hover:bg-accent active:cursor-grabbing"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded text-white",
|
||||
entry.color
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<span className="truncate">{entry.label}</span>
|
||||
<GripVertical className="ml-auto size-3.5 opacity-30 group-hover:opacity-60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PALETTE_DRAG_TYPE = DRAG_TYPE;
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
type Connection,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { lookup, uniqueName } from "@/lib/node-catalog";
|
||||
import {
|
||||
fromReactFlow,
|
||||
toReactFlow,
|
||||
type FlowNodeData,
|
||||
type PipelineDefinition,
|
||||
type PipelineNode,
|
||||
} from "@/lib/pipeline-graph";
|
||||
|
||||
import { FlowNode } from "./flow-node";
|
||||
import { Inspector } from "./inspector";
|
||||
import { NodePalette, PALETTE_DRAG_TYPE } from "./node-palette";
|
||||
|
||||
const NODE_TYPES = { flowNode: FlowNode };
|
||||
|
||||
interface PipelineCanvasProps {
|
||||
/** Initial pipeline definition. Read once per `pipelineId` — the canvas owns
|
||||
* graph state after that. Parent reads back via `onChange`. */
|
||||
initialValue: PipelineDefinition | null;
|
||||
/** Stable identity for the loaded pipeline (URL id, "new"). When this
|
||||
* changes the canvas remounts via React's `key` to reload cleanly. */
|
||||
pipelineId: string;
|
||||
/** Called when the user makes an edit. Debounced + ref-stable internally. */
|
||||
onChange?: (def: PipelineDefinition) => void;
|
||||
/** Read-only mode disables all editing affordances. */
|
||||
readOnly?: boolean;
|
||||
/** Full-bleed: no border / rounded corners; fills parent instead of using
|
||||
* the legacy fixed height. Use when the page wraps the canvas in its own
|
||||
* layout (e.g. flow editor pages). */
|
||||
fullBleed?: boolean;
|
||||
}
|
||||
|
||||
// Public component: keys on pipelineId so a fresh inner instance mounts when
|
||||
// the user navigates between pipelines. This is the simplest, bulletproof way
|
||||
// to handle "load a different pipeline" without a controlled-prop reload loop.
|
||||
export function PipelineCanvas(props: PipelineCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider key={props.pipelineId}>
|
||||
<CanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasInner({
|
||||
initialValue,
|
||||
onChange,
|
||||
readOnly,
|
||||
fullBleed,
|
||||
}: PipelineCanvasProps) {
|
||||
// Compute initial RF state once. The canvas owns it from here on.
|
||||
const initial = useMemo(() => toReactFlow(initialValue ?? null), []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: load-once
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
// Stable refs so emit doesn't churn.
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const baseRef = useRef({ name: initialValue?.name, settings: initialValue?.settings });
|
||||
|
||||
// Emit changes upward, but **outside** the render cycle and debounced so a
|
||||
// burst of internal RF state updates collapses into one notification.
|
||||
const emitTimer = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (emitTimer.current !== null) {
|
||||
window.clearTimeout(emitTimer.current);
|
||||
}
|
||||
emitTimer.current = window.setTimeout(() => {
|
||||
const fn = onChangeRef.current;
|
||||
if (!fn) return;
|
||||
fn(fromReactFlow(nodes, edges, baseRef.current));
|
||||
emitTimer.current = null;
|
||||
}, 100);
|
||||
return () => {
|
||||
if (emitTimer.current !== null) {
|
||||
window.clearTimeout(emitTimer.current);
|
||||
emitTimer.current = null;
|
||||
}
|
||||
};
|
||||
}, [nodes, edges]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: Connection) => {
|
||||
if (readOnly) return;
|
||||
if (!conn.source || !conn.target || conn.source === conn.target) return;
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{
|
||||
...conn,
|
||||
id: `e:${conn.source}:${conn.sourceHandle ?? "o-0"}->${conn.target}:${conn.targetHandle ?? "i-0"}`,
|
||||
},
|
||||
eds
|
||||
)
|
||||
);
|
||||
},
|
||||
[readOnly, setEdges]
|
||||
);
|
||||
|
||||
// --- drop from palette ---------------------------------------------------
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes(PALETTE_DRAG_TYPE)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (readOnly) return;
|
||||
const type = e.dataTransfer.getData(PALETTE_DRAG_TYPE);
|
||||
if (!type) return;
|
||||
e.preventDefault();
|
||||
const entry = lookup(type);
|
||||
if (!entry) return;
|
||||
|
||||
const position = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
setNodes((ns) => {
|
||||
const existing = new Set<string>();
|
||||
ns.forEach((n) => existing.add(n.id));
|
||||
const name = uniqueName(entry.label, existing);
|
||||
const pn: PipelineNode = {
|
||||
id: cryptoId(),
|
||||
name,
|
||||
type: entry.type,
|
||||
typeVersion: 1,
|
||||
parameters: structuredClone(entry.defaults),
|
||||
...(entry.settings ? { settings: structuredClone(entry.settings) } : {}),
|
||||
position: [Math.round(position.x), Math.round(position.y)],
|
||||
};
|
||||
const rfNode: Node<FlowNodeData> = {
|
||||
id: name,
|
||||
type: "flowNode",
|
||||
position,
|
||||
data: { pipelineNode: pn },
|
||||
};
|
||||
// Defer the selection state update so we don't setState during another
|
||||
// setState (React 18+ batches but we want to be explicit).
|
||||
queueMicrotask(() => setSelectedId(name));
|
||||
return [...ns, rfNode];
|
||||
});
|
||||
},
|
||||
[readOnly, screenToFlowPosition, setNodes]
|
||||
);
|
||||
|
||||
// --- selection / inspector ----------------------------------------------
|
||||
|
||||
const selectedPipelineNode: PipelineNode | null = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
const n = nodes.find((nn) => nn.id === selectedId);
|
||||
return (n?.data as FlowNodeData | undefined)?.pipelineNode ?? null;
|
||||
}, [nodes, selectedId]);
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedId(node.id);
|
||||
}, []);
|
||||
|
||||
const onPaneClick = useCallback(() => setSelectedId(null), []);
|
||||
|
||||
const onInspectorChange = useCallback(
|
||||
(next: PipelineNode) => {
|
||||
const oldName = selectedPipelineNode?.name;
|
||||
setNodes((ns) =>
|
||||
ns.map((rn) => {
|
||||
if (rn.id !== selectedId) return rn;
|
||||
return { ...rn, id: next.name, data: { pipelineNode: next } };
|
||||
})
|
||||
);
|
||||
if (oldName && next.name !== oldName) {
|
||||
setEdges((es) =>
|
||||
es.map((e) => ({
|
||||
...e,
|
||||
source: e.source === oldName ? next.name : e.source,
|
||||
target: e.target === oldName ? next.name : e.target,
|
||||
}))
|
||||
);
|
||||
setSelectedId(next.name);
|
||||
}
|
||||
},
|
||||
[selectedId, selectedPipelineNode, setEdges, setNodes]
|
||||
);
|
||||
|
||||
const onInspectorDelete = useCallback(
|
||||
(id: string) => {
|
||||
setNodes((ns) => ns.filter((n) => n.id !== id));
|
||||
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
|
||||
setSelectedId(null);
|
||||
},
|
||||
[setEdges, setNodes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
fullBleed
|
||||
? "absolute inset-0 flex overflow-hidden bg-background"
|
||||
: "flex h-[calc(100vh-260px)] min-h-[480px] overflow-hidden rounded-lg border bg-background"
|
||||
}
|
||||
>
|
||||
{!readOnly && <NodePalette />}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex-1"
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
|
||||
nodesDraggable={!readOnly}
|
||||
nodesConnectable={!readOnly}
|
||||
elementsSelectable
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={16} />
|
||||
<Controls position="bottom-left" />
|
||||
<MiniMap pannable zoomable position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<Inspector
|
||||
node={selectedPipelineNode}
|
||||
onChange={onInspectorChange}
|
||||
onDelete={onInspectorDelete}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cryptoId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
Reference in New Issue
Block a user