diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index 00ad2c8e..7f460ef5 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -73,6 +73,15 @@ const IMAGE_TARGET_OPTIONS: MenuOption[] = [ ]; const TEXT_TARGET_OPTIONS: MenuOption[] = [ + { + type: "prompt", + label: "Prompt", + icon: ( + + + + ), + }, { type: "nanoBanana", label: "Generate Image", diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 5cf6eaed..0812ccfa 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/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" /> + + {/* Max Tokens slider */} +
+ + { + 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" + /> +
diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx index c5851137..a2d2c556 100644 --- a/src/components/__tests__/ConnectionDropMenu.test.tsx +++ b/src/components/__tests__/ConnectionDropMenu.test.tsx @@ -226,14 +226,15 @@ describe("ConnectionDropMenu", () => { it("should wrap around when navigating past last item", () => { render(); - // 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"); }); }); diff --git a/src/components/__tests__/LLMGenerateNode.test.tsx b/src/components/__tests__/LLMGenerateNode.test.tsx index 2f3f43bf..d32d4d09 100644 --- a/src/components/__tests__/LLMGenerateNode.test.tsx +++ b/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( ); - 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", () => { ); - 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, diff --git a/src/components/__tests__/PromptNode.test.tsx b/src/components/__tests__/PromptNode.test.tsx index a3b3702d..eb402d3a 100644 --- a/src/components/__tests__/PromptNode.test.tsx +++ b/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(), diff --git a/src/components/nodes/LLMGenerateNode.tsx b/src/components/nodes/LLMGenerateNode.tsx index 3eb34267..c4e05614 100644 --- a/src/components/nodes/LLMGenerateNode.tsx +++ b/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) => { + 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 { + 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
+ + {showParams && ( +
+ {/* Temperature slider */} +
+ + +
+ {/* Max tokens slider */} +
+ + +
+
+ )}
diff --git a/src/components/nodes/PromptNode.tsx b/src/components/nodes/PromptNode.tsx index 18dd25e8..30dc26d5 100644 --- a/src/components/nodes/PromptNode.tsx +++ b/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) { 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) { selected={selected} commentNavigation={commentNavigation ?? undefined} > + {/* Text input handle - for receiving text from LLM nodes */} + +