diff --git a/.planning/STATE.md b/.planning/STATE.md index 0f92ef8c..fc7f8463 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -10,18 +10,18 @@ See: .planning/PROJECT.md (updated 2026-01-09) ## Current Position Phase: 40 of 40 (Node Enhancements) -Plan: 2 of 4 in current phase +Plan: 1 of 4 in current phase Status: In Progress -Last activity: 2026-02-01 - Completed 40-02-PLAN.md (Connection numbering) +Last activity: 2026-02-01 - Completed 40-01-PLAN.md (OutputGallery node) -Progress: ███████████░ 94% +Progress: ███████████░ 95% ## Performance Metrics **Velocity:** -- Total plans completed: 38 -- Average duration: 6.7 min -- Total execution time: 4.28 hours +- Total plans completed: 39 +- Average duration: 6.6 min +- Total execution time: 4.37 hours **By Phase:** @@ -57,11 +57,11 @@ Progress: ███████████░ 94% | 33. Workflow Edit Safety | 2/2 | 5 min | 5 min | | 34. Agentic Workflow Editing | 3/3 | 13 min | 4.3 min | | 35. Large Workflow Handling | 3/3 | 18 min | 6 min | -| 40. Node Enhancements | 2/4 | 2 min | 2 min | +| 40. Node Enhancements | 1/4 | 5 min | 5 min | **Recent Trend:** -- Last 5 plans: <1 min, 10 min, 3 min, 10 min, 5 min, 2 min -- Trend: Phase 40 in progress - node enhancements (connection numbering complete) +- Last 5 plans: 10 min, 3 min, 10 min, 5 min, 2 min, 5 min +- Trend: Phase 40 in progress - node enhancements (OutputGallery complete) ## Accumulated Context @@ -184,6 +184,10 @@ Recent decisions affecting current work: - createdAt timestamp on edge data for stable connection ordering - Image edge sequence numbers shown only when 2+ connections to same target - EdgeToolbar displays "Image N" labels for multi-image connections +- OutputGallery node uses createPortal for full-screen lightbox (avoids z-index conflicts) +- Real-time image collection pattern: useMemo watching edges/nodes for live updates +- nowheel class for scroll isolation inside React Flow nodes +- Pink minimap color (#ec4899) for OutputGallery nodes ### Deferred Issues @@ -220,6 +224,6 @@ Recent decisions affecting current work: ## Session Continuity Last session: 2026-02-01 -Stopped at: Completed 40-02-PLAN.md (Connection numbering) +Stopped at: Completed 40-01-PLAN.md (OutputGallery node) Resume file: None -Next action: Continue Phase 40 - 2 more plans remaining (OutputGallery, ImageCompare, PromptConstructor) +Next action: Continue Phase 40 - 3 more plans remaining (Connection numbering, ImageCompare, PromptConstructor) diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index 9f4ff10f..ee6084f1 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -91,6 +91,15 @@ const TEXT_TARGET_OPTIONS: MenuOption[] = [ ), }, + { + type: "promptConstructor", + label: "Prompt Constructor", + icon: ( + + + + ), + }, { type: "nanoBanana", label: "Generate Image", @@ -161,6 +170,15 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [ ), }, + { + type: "promptConstructor", + label: "Prompt Constructor", + icon: ( + + + + ), + }, { type: "llmGenerate", label: "LLM Generate", diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 557ef427..73a2b9ee 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -23,6 +23,7 @@ import { ImageInputNode, AnnotationNode, PromptNode, + PromptConstructorNode, GenerateImageNode, GenerateVideoNode, LLMGenerateNode, @@ -48,6 +49,7 @@ const nodeTypes: NodeTypes = { imageInput: ImageInputNode, annotation: AnnotationNode, prompt: PromptNode, + promptConstructor: PromptConstructorNode, nanoBanana: GenerateImageNode, generateVideo: GenerateVideoNode, llmGenerate: LLMGenerateNode, @@ -88,6 +90,8 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] return { inputs: ["image"], outputs: ["image"] }; case "prompt": return { inputs: ["text"], outputs: ["text"] }; + case "promptConstructor": + return { inputs: ["text"], outputs: ["text"] }; case "nanoBanana": return { inputs: ["image", "text"], outputs: ["image"] }; case "generateVideo": @@ -886,6 +890,7 @@ export function WorkflowCanvas() { imageInput: { width: 300, height: 280 }, annotation: { width: 300, height: 280 }, prompt: { width: 320, height: 220 }, + promptConstructor: { width: 340, height: 280 }, nanoBanana: { width: 300, height: 300 }, generateVideo: { width: 300, height: 300 }, llmGenerate: { width: 320, height: 360 }, @@ -1369,6 +1374,8 @@ export function WorkflowCanvas() { return "#8b5cf6"; case "prompt": return "#f97316"; + case "promptConstructor": + return "#f472b6"; case "nanoBanana": return "#22c55e"; case "generateVideo": diff --git a/src/components/nodes/PromptNode.tsx b/src/components/nodes/PromptNode.tsx index 30dc26d5..2067ac5c 100644 --- a/src/components/nodes/PromptNode.tsx +++ b/src/components/nodes/PromptNode.tsx @@ -25,6 +25,10 @@ export function PromptNode({ id, data, selected }: NodeProps) { 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"); @@ -82,6 +86,23 @@ export function PromptNode({ id, data, selected }: NodeProps) { [id, 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) => { + // 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 ( <> ) { data-handletype="text" /> - + + {/* Variable name button - positioned at top-right of textarea area */} + + + setShowVarDialog(true)} + className={`nodrag nopan mt-1 p-1.5 rounded transition-colors ${ + nodeData.variableName + ? "text-blue-400 hover:text-blue-300 hover:bg-blue-900/30" + : "text-neutral-500 hover:text-neutral-400 hover:bg-neutral-700/50" + }`} + title={nodeData.variableName ? `Variable: @${nodeData.variableName}` : "Set variable name"} + > + + + + + + {nodeData.variableName && ( + + @{nodeData.variableName} + + )} + {/* Text output handle */} ) { /> - {/* Modal - rendered via portal to escape React Flow stacking context */} + {/* Prompt Editor Modal - rendered via portal to escape React Flow stacking context */} {isModalOpenLocal && createPortal( ) { />, document.body )} + + {/* Variable Naming Dialog - rendered via portal */} + {showVarDialog && createPortal( + + + Set Variable Name + + Use this prompt as a variable in PromptConstructor nodes + + + Variable name + + {varNameInput && ( + + Preview: @{varNameInput} + + )} + + + {nodeData.variableName && ( + + Clear + + )} + 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 + + + Save + + + + , + document.body + )} > ); } diff --git a/src/components/nodes/index.ts b/src/components/nodes/index.ts index 21988113..cbd13572 100644 --- a/src/components/nodes/index.ts +++ b/src/components/nodes/index.ts @@ -1,6 +1,7 @@ export { ImageInputNode } from "./ImageInputNode"; export { AnnotationNode } from "./AnnotationNode"; export { PromptNode } from "./PromptNode"; +export { PromptConstructorNode } from "./PromptConstructorNode"; export { GenerateImageNode, NanoBananaNode } from "./GenerateImageNode"; export { GenerateVideoNode } from "./GenerateVideoNode"; export { LLMGenerateNode } from "./LLMGenerateNode"; diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index 149f33dd..236b16b0 100644 --- a/src/lib/quickstart/validation.ts +++ b/src/lib/quickstart/validation.ts @@ -29,6 +29,7 @@ const DEFAULT_DIMENSIONS: Record = imageInput: { width: 300, height: 280 }, annotation: { width: 300, height: 280 }, prompt: { width: 320, height: 220 }, + promptConstructor: { width: 340, height: 280 }, nanoBanana: { width: 300, height: 300 }, generateVideo: { width: 300, height: 300 }, llmGenerate: { width: 320, height: 360 }, @@ -206,6 +207,12 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData { return { prompt: "", }; + case "promptConstructor": + return { + template: "", + outputText: null, + unresolvedVars: [], + }; case "nanoBanana": return { inputImages: [], diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index 9443c17b..63ce42f5 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -3,6 +3,7 @@ import { ImageInputNodeData, AnnotationNodeData, PromptNodeData, + PromptConstructorNodeData, NanoBananaNodeData, GenerateVideoNodeData, LLMGenerateNodeData, @@ -23,6 +24,7 @@ export const defaultNodeDimensions: Record { return { prompt: "", } as PromptNodeData; + case "promptConstructor": + return { + template: "", + outputText: null, + unresolvedVars: [], + } as PromptConstructorNodeData; case "nanoBanana": { const nodeDefaults = loadNodeDefaults(); const legacyDefaults = loadGenerateImageDefaults(); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 37d2fc62..9a3adba6 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -15,6 +15,7 @@ import { ImageInputNodeData, AnnotationNodeData, PromptNodeData, + PromptConstructorNodeData, NanoBananaNodeData, GenerateVideoNodeData, LLMGenerateNodeData, @@ -809,6 +810,8 @@ export const useWorkflowStore = create((set, get) => ({ return { type: "video", value: (sourceNode.data as GenerateVideoNodeData).outputVideo }; } else if (sourceNode.type === "prompt") { return { type: "text", value: (sourceNode.data as PromptNodeData).prompt }; + } else if (sourceNode.type === "promptConstructor") { + return { type: "text", value: (sourceNode.data as PromptConstructorNodeData).outputText }; } else if (sourceNode.type === "llmGenerate") { return { type: "text", value: (sourceNode.data as LLMGenerateNodeData).outputText }; } @@ -1059,6 +1062,55 @@ export const useWorkflowStore = create((set, get) => ({ break; } + case "promptConstructor": { + // Get fresh node data from store + const freshNode = get().nodes.find((n) => n.id === node.id); + const nodeData = (freshNode?.data || node.data) as PromptConstructorNodeData; + const template = nodeData.template; + + // Find connected prompt nodes via text edges + const connectedPromptNodes = edges + .filter((e) => e.target === node.id && e.targetHandle === "text") + .map((e) => nodes.find((n) => n.id === e.source)) + .filter((n): n is WorkflowNode => n !== undefined && n.type === "prompt"); + + // Build variable map from connected prompt nodes + const variableMap: Record = {}; + connectedPromptNodes.forEach((promptNode) => { + const promptData = promptNode.data as PromptNodeData; + if (promptData.variableName) { + variableMap[promptData.variableName] = promptData.prompt; + } + }); + + // Find all @variable patterns in template + const varPattern = /@(\w+)/g; + const unresolvedVars: string[] = []; + let resolvedText = template; + + // Replace @variables with values or track unresolved + const matches = template.matchAll(varPattern); + for (const match of matches) { + const varName = match[1]; + if (variableMap[varName] !== undefined) { + // Replace with actual value + resolvedText = resolvedText.replace(new RegExp(`@${varName}`, 'g'), variableMap[varName]); + } else { + // Track unresolved variable + if (!unresolvedVars.includes(varName)) { + unresolvedVars.push(varName); + } + } + } + + // Update node with resolved text and unresolved vars list + updateNodeData(node.id, { + outputText: resolvedText, + unresolvedVars, + }); + break; + } + case "nanoBanana": { const { images, text, dynamicInputs } = getConnectedInputs(node.id); diff --git a/src/types/nodes.ts b/src/types/nodes.ts index d167d161..8650f77e 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -26,6 +26,7 @@ export type NodeType = | "imageInput" | "annotation" | "prompt" + | "promptConstructor" | "nanoBanana" | "generateVideo" | "llmGenerate" @@ -53,6 +54,16 @@ export interface ImageInputNodeData extends BaseNodeData { */ export interface PromptNodeData extends BaseNodeData { prompt: string; + variableName?: string; // Optional variable name for use in PromptConstructor templates +} + +/** + * Prompt Constructor node - template-based prompt builder with @variable interpolation + */ +export interface PromptConstructorNodeData extends BaseNodeData { + template: string; + outputText: string | null; + unresolvedVars: string[]; } /** @@ -206,6 +217,7 @@ export type WorkflowNodeData = | ImageInputNodeData | AnnotationNodeData | PromptNodeData + | PromptConstructorNodeData | NanoBananaNodeData | GenerateVideoNodeData | LLMGenerateNodeData
+ Use this prompt as a variable in PromptConstructor nodes +