Browse Source

Merge pull request #38 from shrimbly/feat/llm-node-improvements

feat: LLM node improvements and selection bug fix
handoff-20260429-1057
Willie 6 months ago
committed by GitHub
parent
commit
94ef5afc3b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      src/components/ConnectionDropMenu.tsx
  2. 21
      src/components/ProjectSetupModal.tsx
  3. 53
      src/components/WorkflowCanvas.tsx
  4. 9
      src/components/__tests__/ConnectionDropMenu.test.tsx
  5. 18
      src/components/__tests__/LLMGenerateNode.test.tsx
  6. 2
      src/components/__tests__/PromptNode.test.tsx
  7. 81
      src/components/nodes/LLMGenerateNode.tsx
  8. 31
      src/components/nodes/PromptNode.tsx
  9. 23
      src/store/workflowStore.ts

9
src/components/ConnectionDropMenu.tsx

@ -73,6 +73,15 @@ const IMAGE_TARGET_OPTIONS: MenuOption[] = [
];
const TEXT_TARGET_OPTIONS: MenuOption[] = [
{
type: "prompt",
label: "Prompt",
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
),
},
{
type: "nanoBanana",
label: "Generate Image",

21
src/components/ProjectSetupModal.tsx

@ -778,6 +778,27 @@ export function ProjectSetupModal({
className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
/>
</div>
{/* Max Tokens slider */}
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400 w-20">
Tokens: {(localNodeDefaults.llm?.maxTokens ?? 8192).toLocaleString()}
</label>
<input
type="range"
min="256"
max="16384"
step="256"
value={localNodeDefaults.llm?.maxTokens ?? 8192}
onChange={(e) => {
setLocalNodeDefaults(prev => ({
...prev,
llm: { ...prev.llm, maxTokens: parseInt(e.target.value, 10) }
}));
}}
className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
/>
</div>
</div>
</div>

53
src/components/WorkflowCanvas.tsx

@ -13,6 +13,7 @@ import {
useReactFlow,
OnConnectEnd,
Node,
OnSelectionChangeParams,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
@ -80,7 +81,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[]
case "annotation":
return { inputs: ["image"], outputs: ["image"] };
case "prompt":
return { inputs: [], outputs: ["text"] };
return { inputs: ["text"], outputs: ["text"] };
case "nanoBanana":
return { inputs: ["image", "text"], outputs: ["image"] };
case "generateVideo":
@ -613,6 +614,8 @@ export function WorkflowCanvas() {
sourceHandleIdForNewNode = "text";
}
} else if (nodeType === "prompt") {
// prompt can receive and output text
targetHandleId = "text";
sourceHandleIdForNewNode = "text";
}
}
@ -993,6 +996,53 @@ export function WorkflowCanvas() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
// Fix for React Flow selection bug where nodes with undefined bounds get incorrectly selected.
// Uses statistical outlier detection to identify and deselect nodes that are clearly
// outside the actual selection area.
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: OnSelectionChangeParams) => {
if (selectedNodes.length <= 1) return;
// Get positions of all selected nodes
const positions = selectedNodes.map(n => ({
id: n.id,
x: n.position.x,
y: n.position.y,
}));
// Calculate IQR-based bounds for outlier detection
const sortedX = [...positions].sort((a, b) => a.x - b.x);
const sortedY = [...positions].sort((a, b) => a.y - b.y);
const q1X = sortedX[Math.floor(sortedX.length * 0.25)].x;
const q3X = sortedX[Math.floor(sortedX.length * 0.75)].x;
const q1Y = sortedY[Math.floor(sortedY.length * 0.25)].y;
const q3Y = sortedY[Math.floor(sortedY.length * 0.75)].y;
const iqrX = q3X - q1X;
const iqrY = q3Y - q1Y;
// Outlier threshold: 3x IQR from quartiles
const minX = q1X - iqrX * 3;
const maxX = q3X + iqrX * 3;
const minY = q1Y - iqrY * 3;
const maxY = q3Y + iqrY * 3;
// Find and deselect outliers
const outliers = positions.filter(p =>
p.x < minX || p.x > maxX || p.y < minY || p.y > maxY
);
if (outliers.length > 0) {
onNodesChange(
outliers.map(o => ({
type: 'select' as const,
id: o.id,
selected: false,
}))
);
}
}, [onNodesChange]);
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
@ -1198,6 +1248,7 @@ export function WorkflowCanvas() {
onConnect={handleConnect}
onConnectEnd={handleConnectEnd}
onNodeDragStop={handleNodeDragStop}
onSelectionChange={handleSelectionChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
isValidConnection={isValidConnection}

9
src/components/__tests__/ConnectionDropMenu.test.tsx

@ -226,14 +226,15 @@ describe("ConnectionDropMenu", () => {
it("should wrap around when navigating past last item", () => {
render(<ConnectionDropMenu {...defaultProps} handleType="text" connectionType="source" />);
// Text options: nanoBanana, generateVideo, llmGenerate (3 items)
// Navigate down 3 times to wrap to first
// Text target options: Prompt, nanoBanana, generateVideo, llmGenerate (4 items)
// Navigate down 4 times to wrap to first
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });
// Should be back on first item
const firstButton = screen.getByText("Generate Image").closest("button");
// Should be back on first item (Prompt)
const firstButton = screen.getByText("Prompt").closest("button");
expect(firstButton).toHaveClass("bg-neutral-700");
});
});

18
src/components/__tests__/LLMGenerateNode.test.tsx

@ -194,14 +194,18 @@ describe("LLMGenerateNode", () => {
});
describe("Temperature Slider", () => {
it("should render temperature slider with current value", () => {
it("should render temperature slider with current value after expanding parameters", () => {
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps({ temperature: 0.7 })} />
</TestWrapper>
);
expect(screen.getByText("Temp: 0.7")).toBeInTheDocument();
// Expand the Parameters section
const parametersButton = screen.getByText("Parameters");
fireEvent.click(parametersButton);
expect(screen.getByText("Temperature: 0.7")).toBeInTheDocument();
});
it("should call updateNodeData when temperature is changed", () => {
@ -211,8 +215,14 @@ describe("LLMGenerateNode", () => {
</TestWrapper>
);
const slider = screen.getByRole("slider");
fireEvent.change(slider, { target: { value: "1.5" } });
// Expand the Parameters section
const parametersButton = screen.getByText("Parameters");
fireEvent.click(parametersButton);
// Get the first slider (temperature) - there are two sliders: temperature and maxTokens
const sliders = screen.getAllByRole("slider");
const temperatureSlider = sliders[0];
fireEvent.change(temperatureSlider, { target: { value: "1.5" } });
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", {
temperature: 1.5,

2
src/components/__tests__/PromptNode.test.tsx

@ -17,6 +17,8 @@ vi.mock("@/store/workflowStore", () => ({
currentNodeId: null,
groups: {},
nodes: [],
edges: [],
getConnectedInputs: vi.fn(() => ({ images: [], videos: [], text: null, dynamicInputs: {} })),
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
setNavigationTarget: vi.fn(),

81
src/components/nodes/LLMGenerateNode.tsx

@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
@ -57,6 +57,15 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
[id, updateNodeData]
);
const handleMaxTokensChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeData(id, { maxTokens: parseInt(e.target.value, 10) });
},
[id, updateNodeData]
);
const [showParams, setShowParams] = useState(false);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
@ -68,6 +77,20 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
updateNodeData(id, { outputText: null, status: "idle", error: null });
}, [id, updateNodeData]);
const [copied, setCopied] = useState(false);
const handleCopyOutput = useCallback(async () => {
if (nodeData.outputText) {
try {
await navigator.clipboard.writeText(nodeData.outputText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error("Failed to copy text:", err);
}
}
}, [nodeData.outputText]);
const provider = nodeData.provider || "google";
const availableModels = MODELS[provider] || MODELS.google;
const model = availableModels.some(m => m.value === nodeData.model)
@ -145,6 +168,21 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
{nodeData.outputText}
</p>
<div className="absolute top-1 right-1 flex gap-1">
<button
onClick={handleCopyOutput}
className={`w-5 h-5 ${copied ? "bg-green-600/80" : "bg-neutral-900/80 hover:bg-neutral-700/80"} rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors`}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<button
onClick={handleRegenerate}
disabled={isRunning}
@ -201,9 +239,28 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
))}
</select>
{/* Collapsible parameters section */}
<div className="shrink-0">
<button
onClick={() => setShowParams(!showParams)}
className="w-full flex items-center justify-between text-[9px] text-neutral-400 hover:text-neutral-300 py-1"
>
<span>Parameters</span>
<svg
className={`w-3 h-3 transition-transform ${showParams ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showParams && (
<div className="flex flex-col gap-2 pt-1 border-t border-neutral-700/50">
{/* Temperature slider */}
<div className="flex flex-col gap-0.5 shrink-0">
<label className="text-[9px] text-neutral-500">Temp: {nodeData.temperature.toFixed(1)}</label>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-neutral-500">Temperature: {nodeData.temperature.toFixed(1)}</label>
<input
type="range"
min="0"
@ -211,10 +268,26 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
step="0.1"
value={nodeData.temperature}
onChange={handleTemperatureChange}
className="w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
className="nodrag w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
/>
</div>
{/* Max tokens slider */}
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-neutral-500">Max Tokens: {nodeData.maxTokens.toLocaleString()}</label>
<input
type="range"
min="256"
max="16384"
step="256"
value={nodeData.maxTokens}
onChange={handleMaxTokensChange}
className="nodrag w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
/>
</div>
</div>
)}
</div>
</div>
</BaseNode>
);
}

31
src/components/nodes/PromptNode.tsx

@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, useEffect } from "react";
import { useCallback, useState, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
@ -17,12 +17,29 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const incrementModalCount = useWorkflowStore((state) => state.incrementModalCount);
const decrementModalCount = useWorkflowStore((state) => state.decrementModalCount);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const edges = useWorkflowStore((state) => state.edges);
const [isModalOpenLocal, setIsModalOpenLocal] = useState(false);
// Local state for prompt to prevent cursor jumping during typing
const [localPrompt, setLocalPrompt] = useState(nodeData.prompt);
const [isEditing, setIsEditing] = useState(false);
// Check if this node has any incoming text connections
const hasIncomingTextConnection = useMemo(() => {
return edges.some((edge) => edge.target === id && edge.targetHandle === "text");
}, [edges, id]);
// Get connected text input and update prompt when connection provides text
useEffect(() => {
if (hasIncomingTextConnection) {
const { text } = getConnectedInputs(id);
if (text !== null && text !== nodeData.prompt) {
updateNodeData(id, { prompt: text });
}
}
}, [hasIncomingTextConnection, id, getConnectedInputs, updateNodeData, nodeData.prompt]);
// Sync from props when not actively editing
useEffect(() => {
if (!isEditing) {
@ -78,15 +95,25 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
selected={selected}
commentNavigation={commentNavigation ?? undefined}
>
{/* Text input handle - for receiving text from LLM nodes */}
<Handle
type="target"
position={Position.Left}
id="text"
data-handletype="text"
/>
<textarea
value={localPrompt}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Describe what to generate..."
placeholder={hasIncomingTextConnection ? "Receiving text from connected node..." : "Describe what to generate..."}
className="nodrag nopan nowheel w-full flex-1 min-h-[70px] p-2 text-xs leading-relaxed text-neutral-100 border border-neutral-700 rounded bg-neutral-900/50 resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600 focus:border-neutral-600 placeholder:text-neutral-500"
readOnly={hasIncomingTextConnection}
/>
{/* Text output handle */}
<Handle
type="source"
position={Position.Right}

23
src/store/workflowStore.ts

@ -1000,9 +1000,14 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
break;
}
case "prompt":
// Nothing to execute, data is already set
case "prompt": {
// Check for connected text input and update prompt if connected
const { text: connectedText } = getConnectedInputs(node.id);
if (connectedText !== null) {
updateNodeData(node.id, { prompt: connectedText });
}
break;
}
case "nanoBanana": {
const { images, text, dynamicInputs } = getConnectedInputs(node.id);
@ -2368,7 +2373,8 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const workflow: WorkflowFile = {
version: 1,
name: name || `workflow-${new Date().toISOString().slice(0, 10)}`,
nodes,
// Strip selected property - selection is transient UI state and should not be persisted
nodes: nodes.map(({ selected, ...rest }) => rest),
edges,
edgeStyle,
groups: Object.keys(groups).length > 0 ? groups : undefined,
@ -2452,7 +2458,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null;
set({
nodes: hydratedWorkflow.nodes,
// Clear selected state - selection should not be persisted across sessions
// Also validate position to ensure coordinates are finite numbers
nodes: hydratedWorkflow.nodes.map(node => ({
...node,
selected: false,
position: {
x: isFinite(node.position?.x) ? node.position.x : 0,
y: isFinite(node.position?.y) ? node.position.y : 0,
},
})),
edges: hydratedWorkflow.edges,
edgeStyle: hydratedWorkflow.edgeStyle || "angular",
groups: hydratedWorkflow.groups || {},

Loading…
Cancel
Save