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.
 
 

193 lines
7.3 KiB

"use client";
import { useCallback, useState, useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptNodeData } from "@/types";
type PromptNodeType = Node<PromptNodeData, "prompt">;
export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const edges = useWorkflowStore((state) => state.edges);
// Local state for prompt to prevent cursor jumping during typing
const [localPrompt, setLocalPrompt] = useState(nodeData.prompt);
const [isEditing, setIsEditing] = useState(false);
// Variable naming dialog state
const [showVarDialog, setShowVarDialog] = useState(false);
const [varNameInput, setVarNameInput] = useState(nodeData.variableName || "");
// Check if this node has any incoming text connections
const hasIncomingTextConnection = useMemo(() => {
return edges.some((edge) => edge.target === id && edge.targetHandle === "text");
}, [edges, id]);
// Track the last received text from connected LLM node to detect when it changes
const lastReceivedTextRef = useRef<string | null>(null);
// Get connected text input and update prompt when LLM output changes
useEffect(() => {
if (hasIncomingTextConnection) {
const { text } = getConnectedInputs(id);
// Only update if the incoming text changed (LLM node ran again)
if (text !== null && text !== lastReceivedTextRef.current) {
lastReceivedTextRef.current = text;
updateNodeData(id, { prompt: text });
}
} else {
// Clear tracking when connection is removed
lastReceivedTextRef.current = null;
}
}, [hasIncomingTextConnection, id, getConnectedInputs, updateNodeData]);
// Sync from props when not actively editing
useEffect(() => {
if (!isEditing) {
setLocalPrompt(nodeData.prompt);
}
}, [nodeData.prompt, isEditing]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setLocalPrompt(e.target.value);
},
[]
);
const handleFocus = useCallback(() => {
setIsEditing(true);
}, []);
const handleBlur = useCallback(() => {
setIsEditing(false);
if (localPrompt !== nodeData.prompt) {
updateNodeData(id, { prompt: localPrompt });
}
}, [id, localPrompt, nodeData.prompt, updateNodeData]);
const handleSaveVariableName = useCallback(() => {
updateNodeData(id, { variableName: varNameInput || undefined });
setShowVarDialog(false);
}, [id, varNameInput, updateNodeData]);
const handleClearVariableName = useCallback(() => {
setVarNameInput("");
updateNodeData(id, { variableName: undefined });
setShowVarDialog(false);
}, [id, updateNodeData]);
const handleVariableNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
// Allow only alphanumeric and underscore, max 30 chars
const sanitized = e.target.value.replace(/[^a-zA-Z0-9_]/g, "").slice(0, 30);
setVarNameInput(sanitized);
}, []);
return (
<>
<BaseNode
id={id}
selected={selected}
fullBleed
>
{/* Text input handle - for receiving text from LLM nodes */}
<Handle
type="target"
position={Position.Left}
id="text"
data-handletype="text"
style={{ zIndex: 10 }}
/>
<textarea
value={localPrompt}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={hasIncomingTextConnection ? "Text from connected node (editable)..." : nodeData.isOptional ? "Optional prompt (leave empty to skip)..." : "Describe what to generate..."}
className="nodrag nopan nowheel w-full h-full p-3 pb-7 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-t-lg resize-none focus:outline-none placeholder:text-neutral-500"
/>
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 bg-neutral-900/90 rounded-b-lg">
<button
onClick={() => setShowVarDialog(true)}
className="nodrag nopan text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
title="Set variable name"
>
{nodeData.variableName ? `@${nodeData.variableName}` : "Add variable"}
</button>
</div>
{/* Text output handle */}
<Handle
type="source"
position={Position.Right}
id="text"
data-handletype="text"
style={{ zIndex: 10 }}
/>
</BaseNode>
{/* Variable Naming Dialog - rendered via portal */}
{showVarDialog && createPortal(
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999]">
<div className="bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-4 w-96">
<h3 className="text-sm font-semibold text-neutral-100 mb-3">Set Variable Name</h3>
<p className="text-xs text-neutral-400 mb-3">
Use this prompt as a variable in PromptConstructor nodes
</p>
<div className="mb-4">
<label className="block text-xs text-neutral-300 mb-1">Variable name</label>
<input
type="text"
value={varNameInput}
onChange={handleVariableNameChange}
onKeyDown={(e) => {
if (e.key === "Enter" && varNameInput) {
handleSaveVariableName();
}
}}
placeholder="e.g. color, style, subject"
className="w-full px-3 py-2 text-sm text-neutral-100 bg-neutral-900 border border-neutral-700 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
{varNameInput && (
<div className="mt-2 text-xs text-blue-400">
Preview: <span className="font-mono">@{varNameInput}</span>
</div>
)}
</div>
<div className="flex gap-2 justify-end">
{nodeData.variableName && (
<button
onClick={handleClearVariableName}
className="px-3 py-1.5 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded transition-colors"
>
Clear
</button>
)}
<button
onClick={() => setShowVarDialog(false)}
className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-300 hover:bg-neutral-700 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveVariableName}
disabled={!varNameInput}
className="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
</div>
</div>
</div>,
document.body
)}
</>
);
}