Browse Source

feat(40-04): add variable system to PromptNode and define PromptConstructor types

- Add variableName field to PromptNodeData (optional, backward compatible)
- Add promptConstructor to NodeType union
- Add PromptConstructorNodeData interface (template, outputText, unresolvedVars)
- Add to WorkflowNodeData union type
- Add promptConstructor to defaultNodeDimensions (340x280)
- Add createDefaultNodeData case for promptConstructor
- Modified PromptNode with @ variable naming dialog using createPortal
- Variable name validation: alphanumeric + underscore, max 30 chars
- Blue @ icon when variableName set, dimmed when not set
- Dialog shows variable name input, preview, Save/Clear buttons
- Register PromptConstructorNode in WorkflowCanvas nodeTypes
- Add promptConstructor minimap color (#f472b6 pink)
- Add to TEXT_TARGET_OPTIONS and TEXT_SOURCE_OPTIONS in ConnectionDropMenu
- Add getConnectedInputs support for promptConstructor outputText
- Add executeWorkflow case for promptConstructor:
  - Finds connected prompt nodes via text edges
  - Builds variable map from variableName + prompt values
  - Resolves template by replacing @variableName with values
  - Tracks unresolved variables in unresolvedVars array
  - Stores outputText and unresolvedVars on node data
- Export PromptConstructorNode from components/nodes/index.ts
- Update duplicate defaultDimensions in WorkflowCanvas and quickstart validation
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
af9f88f122
  1. 26
      .planning/STATE.md
  2. 18
      src/components/ConnectionDropMenu.tsx
  3. 7
      src/components/WorkflowCanvas.tsx
  4. 116
      src/components/nodes/PromptNode.tsx
  5. 1
      src/components/nodes/index.ts
  6. 7
      src/lib/quickstart/validation.ts
  7. 8
      src/store/utils/nodeDefaults.ts
  8. 52
      src/store/workflowStore.ts
  9. 12
      src/types/nodes.ts

26
.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)

18
src/components/ConnectionDropMenu.tsx

@ -91,6 +91,15 @@ const TEXT_TARGET_OPTIONS: MenuOption[] = [
</svg>
),
},
{
type: "promptConstructor",
label: "Prompt Constructor",
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="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z" />
</svg>
),
},
{
type: "nanoBanana",
label: "Generate Image",
@ -161,6 +170,15 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [
</svg>
),
},
{
type: "promptConstructor",
label: "Prompt Constructor",
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="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z" />
</svg>
),
},
{
type: "llmGenerate",
label: "LLM Generate",

7
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":

116
src/components/nodes/PromptNode.tsx

@ -25,6 +25,10 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
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<PromptNodeType>) {
[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<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
@ -103,15 +124,38 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
data-handletype="text"
/>
<textarea
value={localPrompt}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
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}
/>
<div className="relative flex-1 flex flex-col">
{/* Variable name button - positioned at top-right of textarea area */}
<div className="flex items-start gap-2">
<textarea
value={localPrompt}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
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}
/>
<button
onClick={() => 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"}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 12h4m0 0l-4-4m4 4l-4 4m-8-4H4m0 0l4 4m-4-4l4-4" />
</svg>
</button>
</div>
{nodeData.variableName && (
<div className="mt-1 text-[10px] text-blue-400 px-2">
@{nodeData.variableName}
</div>
)}
</div>
{/* Text output handle */}
<Handle
@ -122,7 +166,7 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
/>
</BaseNode>
{/* Modal - rendered via portal to escape React Flow stacking context */}
{/* Prompt Editor Modal - rendered via portal to escape React Flow stacking context */}
{isModalOpenLocal && createPortal(
<PromptEditorModal
isOpen={isModalOpenLocal}
@ -132,6 +176,58 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
/>,
document.body
)}
{/* 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}
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
)}
</>
);
}

1
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";

7
src/lib/quickstart/validation.ts

@ -29,6 +29,7 @@ const DEFAULT_DIMENSIONS: Record<NodeType, { width: number; height: number }> =
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: [],

8
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<NodeType, { width: number; height: nu
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 },
@ -71,6 +73,12 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
return {
prompt: "",
} as PromptNodeData;
case "promptConstructor":
return {
template: "",
outputText: null,
unresolvedVars: [],
} as PromptConstructorNodeData;
case "nanoBanana": {
const nodeDefaults = loadNodeDefaults();
const legacyDefaults = loadGenerateImageDefaults();

52
src/store/workflowStore.ts

@ -15,6 +15,7 @@ import {
ImageInputNodeData,
AnnotationNodeData,
PromptNodeData,
PromptConstructorNodeData,
NanoBananaNodeData,
GenerateVideoNodeData,
LLMGenerateNodeData,
@ -809,6 +810,8 @@ export const useWorkflowStore = create<WorkflowStore>((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<WorkflowStore>((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<string, string> = {};
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);

12
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

Loading…
Cancel
Save