You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
298 lines
12 KiB
298 lines
12 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
|
import { BaseNode } from "./BaseNode";
|
|
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { ArrayNodeData } from "@/types";
|
|
import { parseTextToArray } from "@/utils/arrayParser";
|
|
|
|
type ArrayNodeType = Node<ArrayNodeData, "array">;
|
|
|
|
function arraysEqual(a: string[], b: string[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i++) {
|
|
if (a[i] !== b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
|
|
const nodeData = data;
|
|
const commentNavigation = useCommentNavigation(id);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const addNode = useWorkflowStore((state) => state.addNode);
|
|
const onConnect = useWorkflowStore((state) => state.onConnect);
|
|
const nodes = useWorkflowStore((state) => state.nodes);
|
|
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
|
|
const edges = useWorkflowStore((state) => state.edges);
|
|
const { setNodes } = useReactFlow();
|
|
const lastSyncedInputRef = useRef<string | null>(null);
|
|
const lastDerivedWriteRef = useRef<string | null>(null);
|
|
|
|
const hasIncomingTextConnection = useMemo(() => {
|
|
return edges.some((edge) => {
|
|
if (edge.target !== id) return false;
|
|
const handle = edge.targetHandle || "text";
|
|
return handle === "text" || handle.startsWith("text-") || handle.includes("prompt");
|
|
});
|
|
}, [edges, id]);
|
|
|
|
// Pull upstream text into this node whenever the connected input changes.
|
|
useEffect(() => {
|
|
if (!hasIncomingTextConnection) return;
|
|
const { text } = getConnectedInputs(id);
|
|
if (
|
|
text !== null &&
|
|
text !== nodeData.inputText &&
|
|
text !== lastSyncedInputRef.current
|
|
) {
|
|
lastSyncedInputRef.current = text;
|
|
updateNodeData(id, { inputText: text });
|
|
}
|
|
}, [hasIncomingTextConnection, getConnectedInputs, id, nodeData.inputText, updateNodeData]);
|
|
|
|
const parsed = useMemo(() => {
|
|
return parseTextToArray(nodeData.inputText, {
|
|
splitMode: nodeData.splitMode,
|
|
delimiter: nodeData.delimiter,
|
|
regexPattern: nodeData.regexPattern,
|
|
trimItems: nodeData.trimItems,
|
|
removeEmpty: nodeData.removeEmpty,
|
|
});
|
|
}, [
|
|
nodeData.inputText,
|
|
nodeData.splitMode,
|
|
nodeData.delimiter,
|
|
nodeData.regexPattern,
|
|
nodeData.trimItems,
|
|
nodeData.removeEmpty,
|
|
]);
|
|
|
|
// Keep derived outputs in node data so execution/edges always read the latest values.
|
|
useEffect(() => {
|
|
const nextOutputText = JSON.stringify(parsed.items);
|
|
const writeSignature = `${parsed.error ?? ""}::${nextOutputText}`;
|
|
const needsSync =
|
|
parsed.error !== nodeData.error ||
|
|
nextOutputText !== (nodeData.outputText ?? "[]") ||
|
|
!arraysEqual(parsed.items, nodeData.outputItems || []);
|
|
|
|
if (!needsSync) return;
|
|
if (lastDerivedWriteRef.current === writeSignature) return;
|
|
lastDerivedWriteRef.current = writeSignature;
|
|
|
|
if (
|
|
parsed.error !== nodeData.error ||
|
|
nextOutputText !== (nodeData.outputText ?? "[]") ||
|
|
!arraysEqual(parsed.items, nodeData.outputItems || [])
|
|
) {
|
|
updateNodeData(id, {
|
|
outputItems: parsed.items,
|
|
outputText: nextOutputText,
|
|
error: parsed.error,
|
|
});
|
|
}
|
|
}, [id, nodeData.error, nodeData.outputItems, nodeData.outputText, parsed.error, parsed.items, updateNodeData]);
|
|
|
|
const handleModeChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
updateNodeData(id, { splitMode: e.target.value as ArrayNodeData["splitMode"] });
|
|
},
|
|
[id, updateNodeData]
|
|
);
|
|
|
|
const previewItems = parsed.items;
|
|
|
|
const handleAutoRouteToPrompts = useCallback(() => {
|
|
const items = nodeData.outputItems || [];
|
|
if (items.length === 0) return;
|
|
|
|
const sourceNode = nodes.find((n) => n.id === id);
|
|
if (!sourceNode) return;
|
|
|
|
const sourceWidth = (sourceNode.style?.width as number) || 360;
|
|
const baseX = sourceNode.position.x + sourceWidth + 220;
|
|
const baseY = sourceNode.position.y;
|
|
const promptHeight = 220;
|
|
const verticalGap = 24;
|
|
const previousSelected = nodeData.selectedOutputIndex ?? null;
|
|
|
|
items.forEach((item, index) => {
|
|
const promptNodeId = addNode(
|
|
"prompt",
|
|
{ x: baseX, y: baseY + index * (promptHeight + verticalGap) },
|
|
{ prompt: item }
|
|
);
|
|
|
|
// Force each generated edge to bind to the matching array item index.
|
|
updateNodeData(id, { selectedOutputIndex: index });
|
|
onConnect({
|
|
source: id,
|
|
sourceHandle: "text",
|
|
target: promptNodeId,
|
|
targetHandle: "text",
|
|
});
|
|
});
|
|
|
|
updateNodeData(id, { selectedOutputIndex: previousSelected });
|
|
}, [addNode, id, nodeData.outputItems, nodeData.selectedOutputIndex, nodes, onConnect, updateNodeData]);
|
|
|
|
// Reset selection if it no longer points to a valid parsed item.
|
|
useEffect(() => {
|
|
const selected = nodeData.selectedOutputIndex;
|
|
if (selected !== null && (selected < 0 || selected >= previewItems.length)) {
|
|
updateNodeData(id, { selectedOutputIndex: null });
|
|
}
|
|
}, [id, nodeData.selectedOutputIndex, previewItems.length, updateNodeData]);
|
|
|
|
// Auto-resize node height to fit all parsed lines so users don't need to scroll.
|
|
useEffect(() => {
|
|
const baseHeight = 360;
|
|
const perItemHeight = 30;
|
|
const newHeight = Math.max(baseHeight, 270 + previewItems.length * perItemHeight);
|
|
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id ? { ...node, style: { ...node.style, height: newHeight } } : node
|
|
)
|
|
);
|
|
}, [id, previewItems.length, setNodes]);
|
|
|
|
return (
|
|
<BaseNode
|
|
id={id}
|
|
title="Array"
|
|
customTitle={nodeData.customTitle}
|
|
comment={nodeData.comment}
|
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
|
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
|
|
selected={selected}
|
|
commentNavigation={commentNavigation ?? undefined}
|
|
hasError={!!nodeData.error}
|
|
minWidth={320}
|
|
minHeight={300}
|
|
headerButtons={
|
|
<button
|
|
type="button"
|
|
onClick={handleAutoRouteToPrompts}
|
|
disabled={previewItems.length === 0}
|
|
className="nodrag nopan ml-2 shrink-0 p-1 rounded border border-neutral-600 text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Auto-route to Prompts"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v6m0 0a2 2 0 104 0 2 2 0 00-4 0zm0 0v7a3 3 0 003 3h6m0 0a2 2 0 100 4 2 2 0 000-4zm0 0v-6a3 3 0 013-3h0" />
|
|
</svg>
|
|
</button>
|
|
}
|
|
>
|
|
<Handle type="target" position={Position.Left} id="text" data-handletype="text" />
|
|
|
|
{/* Single text output point (each outgoing edge receives a separate item) */}
|
|
<Handle type="source" position={Position.Right} id="text" data-handletype="text" style={{ top: 48 }} />
|
|
|
|
<div className="flex flex-col gap-2 flex-1 min-h-0">
|
|
<div className="grid grid-cols-[auto_1fr] gap-2 items-center">
|
|
<label className="text-[11px] text-neutral-400">Split</label>
|
|
<select
|
|
value={nodeData.splitMode}
|
|
onChange={handleModeChange}
|
|
className="nodrag nopan bg-neutral-900 border border-neutral-700 rounded px-2 py-1 text-[11px] text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-600"
|
|
>
|
|
<option value="delimiter">Delimiter</option>
|
|
<option value="newline">Newline</option>
|
|
<option value="regex">Regex</option>
|
|
</select>
|
|
</div>
|
|
|
|
{nodeData.splitMode === "delimiter" && (
|
|
<div className="grid grid-cols-[auto_1fr] gap-2 items-center">
|
|
<label className="text-[11px] text-neutral-400">By</label>
|
|
<input
|
|
value={nodeData.delimiter}
|
|
onChange={(e) => updateNodeData(id, { delimiter: e.target.value })}
|
|
placeholder="*"
|
|
className="nodrag nopan bg-neutral-900 border border-neutral-700 rounded px-2 py-1 text-[11px] text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{nodeData.splitMode === "regex" && (
|
|
<div className="grid grid-cols-[auto_1fr] gap-2 items-center">
|
|
<label className="text-[11px] text-neutral-400">Pattern</label>
|
|
<input
|
|
value={nodeData.regexPattern}
|
|
onChange={(e) => updateNodeData(id, { regexPattern: e.target.value })}
|
|
placeholder="/\\n+/"
|
|
className="nodrag nopan bg-neutral-900 border border-neutral-700 rounded px-2 py-1 text-[11px] text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={nodeData.trimItems}
|
|
onChange={(e) => updateNodeData(id, { trimItems: e.target.checked })}
|
|
className="nodrag nopan w-3 h-3"
|
|
/>
|
|
Trim
|
|
</label>
|
|
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={nodeData.removeEmpty}
|
|
onChange={(e) => updateNodeData(id, { removeEmpty: e.target.checked })}
|
|
className="nodrag nopan w-3 h-3"
|
|
/>
|
|
Remove empty
|
|
</label>
|
|
</div>
|
|
|
|
<div className="text-[10px] uppercase tracking-wide text-neutral-500">
|
|
Parsed Items ({previewItems.length})
|
|
</div>
|
|
<div className="relative flex-1 min-h-[90px] border border-neutral-700 rounded bg-neutral-900/40">
|
|
{nodeData.error ? (
|
|
<div className="p-2 text-[11px] text-red-400">{nodeData.error}</div>
|
|
) : previewItems.length === 0 ? (
|
|
<div className="p-2 text-[11px] text-neutral-500">No items parsed</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{previewItems.map((item, index) => {
|
|
const isSelected = nodeData.selectedOutputIndex === index;
|
|
return (
|
|
<div key={`${index}-${item}`} className="relative pr-8">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateNodeData(id, {
|
|
selectedOutputIndex: isSelected ? null : index,
|
|
})
|
|
}
|
|
className={`nodrag nopan w-[calc(100%-1rem)] mx-2 my-1 rounded border px-2 py-1 text-[11px] text-left truncate transition-colors ${
|
|
isSelected
|
|
? "border-blue-500 bg-blue-900/40 text-blue-200"
|
|
: "border-neutral-700 bg-neutral-900/80 text-neutral-200 hover:bg-neutral-800"
|
|
}`}
|
|
title={isSelected ? "Selected for next connection (click to unselect)" : "Click to select for next connection"}
|
|
>
|
|
{index + 1}. {item}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-[10px] text-neutral-500">
|
|
{nodeData.selectedOutputIndex !== null
|
|
? `Next wire uses item ${nodeData.selectedOutputIndex + 1}`
|
|
: "No selection: wires advance in order from item 1"}
|
|
</div>
|
|
</div>
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|