Browse Source

fix(14-01): fix drag-connect node creation and handle persistence

- Always render image/text handles with placeholders for unused inputs
- Placeholder handles (dimmed 30%) preserve connections for models that
  don't use that input type (e.g., text-to-image models)
- Normalize dynamic handle IDs to standard values (image, text, image-0)
- Update findCompatibleHandle to return normalized IDs
- Map normalized handles to schema names in getConnectedInputs
- Exclude image_size from image input detection
- Switch save-generation to hash suffix format for better readability

Co-Authored-By: Claude <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
f50c984180
  1. 9
      src/app/api/models/[modelId]/route.ts
  2. 13
      src/app/api/save-generation/route.ts
  3. 9
      src/components/WorkflowCanvas.tsx
  4. 104
      src/components/nodes/GenerateImageNode.tsx
  5. 104
      src/components/nodes/GenerateVideoNode.tsx
  6. 36
      src/store/workflowStore.ts

9
src/app/api/models/[modelId]/route.ts

@ -45,6 +45,9 @@ const IMAGE_INPUT_PATTERNS = [
// Text input properties
const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"];
// Properties that start with "image_" but are NOT image inputs
const IMAGE_PREFIX_EXCLUSIONS = ["image_size"];
// Parameters to filter out (internal/system params)
const EXCLUDED_PARAMS = new Set([
"webhook",
@ -69,6 +72,7 @@ const PRIORITY_PARAMS = new Set([
"negative_prompt",
"width",
"height",
"image_size",
"num_outputs",
"num_images",
"scheduler",
@ -110,6 +114,11 @@ function isImageInput(name: string): boolean {
return true;
}
// Check exclusions (e.g., image_size is a parameter, not an image input)
if (IMAGE_PREFIX_EXCLUSIONS.includes(name)) {
return false;
}
// Check for "image" as a word boundary:
// - ends with _image (e.g., start_image, control_image)
// - starts with image_ (e.g., image_url, image_urls)

13
src/app/api/save-generation/route.ts

@ -28,7 +28,7 @@ function computeContentHash(buffer: Buffer): string {
return crypto.createHash("md5").update(buffer).digest("hex");
}
// Helper to find existing file by hash prefix
// Helper to find existing file by hash suffix
async function findExistingFileByHash(
directoryPath: string,
hash: string,
@ -36,10 +36,9 @@ async function findExistingFileByHash(
): Promise<string | null> {
try {
const files = await fs.readdir(directoryPath);
// Look for files starting with this hash
const matching = files.find(
(f) => f.startsWith(hash) && f.endsWith(`.${extension}`)
);
// Look for files ending with this hash before extension
const hashSuffix = `_${hash}.${extension}`;
const matching = files.find((f) => f.endsWith(hashSuffix));
return matching || null;
} catch {
return null;
@ -154,7 +153,7 @@ export async function POST(request: NextRequest) {
});
}
// Generate filename with hash prefix for fast future lookups
// Generate filename with hash suffix for deduplication
const promptSnippet = prompt
? prompt
.slice(0, 30)
@ -163,7 +162,7 @@ export async function POST(request: NextRequest) {
.replace(/^_|_$/g, "")
.toLowerCase()
: "generation";
const filename = `${contentHash}_${promptSnippet}.${extension}`;
const filename = `${promptSnippet}_${contentHash}.${extension}`;
const filePath = path.join(directoryPath, filename);
// Write the file

9
src/components/WorkflowCanvas.tsx

@ -334,8 +334,13 @@ export function WorkflowCanvas() {
if (nodeData.inputSchema && nodeData.inputSchema.length > 0) {
if (needInput) {
// Find first input handle matching the type
const match = nodeData.inputSchema.find(i => i.type === handleType);
if (match) return match.name;
// Count how many of this type exist to determine if we need indexed handle ID
const matchingInputs = nodeData.inputSchema.filter(i => i.type === handleType);
if (matchingInputs.length > 0) {
// Return normalized handle ID, not schema name
// Nodes use "image"/"text" for single inputs, "image-0"/"text-0" for multiple
return matchingInputs.length > 1 ? `${handleType}-0` : handleType;
}
}
// Output handle - check for video or image type
if (handleType === "video") return "video";

104
src/components/nodes/GenerateImageNode.tsx

@ -464,32 +464,103 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
{/* Dynamic input handles based on model schema (external providers only) */}
{!isGeminiProvider && nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (
// Render handles from schema, sorted by type (images first, text second)
// IMPORTANT: Always render "image" and "text" handles to maintain connection
// compatibility. Schema may only have text inputs (text-to-image models) but
// we still need the image handle to preserve connections made before model selection.
(() => {
const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image");
const textInputs = nodeData.inputSchema!.filter(i => i.type === "text");
const sortedInputs = [...imageInputs, ...textInputs];
// Calculate positions with gap between image and text groups
const imageCount = imageInputs.length;
const textCount = textInputs.length;
const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap
return sortedInputs.map((input, index) => {
// Add 1 to index for text inputs if there are image inputs (to account for gap)
const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index;
// Always include at least one image and one text handle for connection stability
const hasImageInput = imageInputs.length > 0;
const hasTextInput = textInputs.length > 0;
// Build the handles array: schema inputs + fallback defaults if missing
const handles: Array<{
id: string;
type: "image" | "text";
label: string;
schemaName: string | null;
description: string | null;
isPlaceholder: boolean;
}> = [];
// Add image handles from schema, or a placeholder if none exist
if (hasImageInput) {
imageInputs.forEach((input, index) => {
handles.push({
id: imageInputs.length > 1 ? `image-${index}` : "image",
type: "image",
label: input.label,
schemaName: input.name,
description: input.description || null,
isPlaceholder: false,
});
});
} else {
// No image inputs in schema - add placeholder to preserve connections
handles.push({
id: "image",
type: "image",
label: "Image",
schemaName: null,
description: "Not used by this model",
isPlaceholder: true,
});
}
// Add text handles from schema, or a placeholder if none exist
if (hasTextInput) {
textInputs.forEach((input, index) => {
handles.push({
id: textInputs.length > 1 ? `text-${index}` : "text",
type: "text",
label: input.label,
schemaName: input.name,
description: input.description || null,
isPlaceholder: false,
});
});
} else {
// No text inputs in schema - add placeholder to preserve connections
handles.push({
id: "text",
type: "text",
label: "Prompt",
schemaName: null,
description: "Not used by this model",
isPlaceholder: true,
});
}
// Calculate positions
const imageHandles = handles.filter(h => h.type === "image");
const textHandles = handles.filter(h => h.type === "text");
const totalSlots = imageHandles.length + textHandles.length + 1; // +1 for gap
return handles.map((handle, index) => {
// Position: images first, then gap, then text
const isImage = handle.type === "image";
const typeIndex = isImage
? imageHandles.findIndex(h => h.id === handle.id)
: textHandles.findIndex(h => h.id === handle.id);
const adjustedIndex = isImage ? typeIndex : imageHandles.length + 1 + typeIndex;
const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100;
const isImage = input.type === "image";
return (
<React.Fragment key={input.name}>
<React.Fragment key={handle.id}>
<Handle
type="target"
position={Position.Left}
id={input.name}
style={{ top: `${topPercent}%` }}
data-handletype={input.type}
id={handle.id}
style={{
top: `${topPercent}%`,
opacity: handle.isPlaceholder ? 0.3 : 1,
}}
data-handletype={handle.type}
data-schema-name={handle.schemaName || undefined}
isConnectable={true}
title={input.description || input.label}
title={handle.description || handle.label}
/>
{/* Handle label - positioned outside node, above the connector */}
<div
@ -498,9 +569,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
right: `calc(100% + 8px)`,
top: `calc(${topPercent}% - 18px)`,
color: isImage ? "var(--handle-color-image)" : "var(--handle-color-text)",
opacity: handle.isPlaceholder ? 0.3 : 1,
}}
>
{input.label}
{handle.label}
</div>
</React.Fragment>
);

104
src/components/nodes/GenerateVideoNode.tsx

@ -354,32 +354,103 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
{/* Dynamic input handles based on model schema */}
{nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (
// Render handles from schema, sorted by type (images first, text second)
// IMPORTANT: Always render "image" and "text" handles to maintain connection
// compatibility. Schema may only have text inputs (text-to-video models) but
// we still need the image handle to preserve connections made before model selection.
(() => {
const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image");
const textInputs = nodeData.inputSchema!.filter(i => i.type === "text");
const sortedInputs = [...imageInputs, ...textInputs];
// Calculate positions with gap between image and text groups
const imageCount = imageInputs.length;
const textCount = textInputs.length;
const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap
return sortedInputs.map((input, index) => {
// Add 1 to index for text inputs if there are image inputs (to account for gap)
const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index;
// Always include at least one image and one text handle for connection stability
const hasImageInput = imageInputs.length > 0;
const hasTextInput = textInputs.length > 0;
// Build the handles array: schema inputs + fallback defaults if missing
const handles: Array<{
id: string;
type: "image" | "text";
label: string;
schemaName: string | null;
description: string | null;
isPlaceholder: boolean;
}> = [];
// Add image handles from schema, or a placeholder if none exist
if (hasImageInput) {
imageInputs.forEach((input, index) => {
handles.push({
id: imageInputs.length > 1 ? `image-${index}` : "image",
type: "image",
label: input.label,
schemaName: input.name,
description: input.description || null,
isPlaceholder: false,
});
});
} else {
// No image inputs in schema - add placeholder to preserve connections
handles.push({
id: "image",
type: "image",
label: "Image",
schemaName: null,
description: "Not used by this model",
isPlaceholder: true,
});
}
// Add text handles from schema, or a placeholder if none exist
if (hasTextInput) {
textInputs.forEach((input, index) => {
handles.push({
id: textInputs.length > 1 ? `text-${index}` : "text",
type: "text",
label: input.label,
schemaName: input.name,
description: input.description || null,
isPlaceholder: false,
});
});
} else {
// No text inputs in schema - add placeholder to preserve connections
handles.push({
id: "text",
type: "text",
label: "Prompt",
schemaName: null,
description: "Not used by this model",
isPlaceholder: true,
});
}
// Calculate positions
const imageHandles = handles.filter(h => h.type === "image");
const textHandles = handles.filter(h => h.type === "text");
const totalSlots = imageHandles.length + textHandles.length + 1; // +1 for gap
return handles.map((handle, index) => {
// Position: images first, then gap, then text
const isImage = handle.type === "image";
const typeIndex = isImage
? imageHandles.findIndex(h => h.id === handle.id)
: textHandles.findIndex(h => h.id === handle.id);
const adjustedIndex = isImage ? typeIndex : imageHandles.length + 1 + typeIndex;
const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100;
const isImage = input.type === "image";
return (
<React.Fragment key={input.name}>
<React.Fragment key={handle.id}>
<Handle
type="target"
position={Position.Left}
id={input.name}
style={{ top: `${topPercent}%` }}
data-handletype={input.type}
id={handle.id}
style={{
top: `${topPercent}%`,
opacity: handle.isPlaceholder ? 0.3 : 1,
}}
data-handletype={handle.type}
data-schema-name={handle.schemaName || undefined}
isConnectable={true}
title={input.description || input.label}
title={handle.description || handle.label}
/>
{/* Handle label - positioned outside node, above the connector */}
<div
@ -388,9 +459,10 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
right: `calc(100% + 8px)`,
top: `calc(${topPercent}% - 18px)`,
color: isImage ? "var(--handle-color-image)" : "var(--handle-color-text)",
opacity: handle.isPlaceholder ? 0.3 : 1,
}}
>
{input.label}
{handle.label}
</div>
</React.Fragment>
);

36
src/store/workflowStore.ts

@ -828,16 +828,41 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
let text: string | null = null;
const dynamicInputs: Record<string, string> = {};
// Get the target node to check for inputSchema
const targetNode = nodes.find((n) => n.id === nodeId);
const inputSchema = (targetNode?.data as { inputSchema?: Array<{ name: string; type: string }> })?.inputSchema;
// Build mapping from normalized handle IDs to schema names if schema exists
// Handles use normalized IDs ("image", "image-0", "text", "text-0")
// but API needs schema names ("image_url", "first_frame", "prompt", etc.)
const handleToSchemaName: Record<string, string> = {};
if (inputSchema && inputSchema.length > 0) {
const imageInputs = inputSchema.filter(i => i.type === "image");
const textInputs = inputSchema.filter(i => i.type === "text");
// Map image handles to schema names
imageInputs.forEach((input, index) => {
const handleId = imageInputs.length > 1 ? `image-${index}` : "image";
handleToSchemaName[handleId] = input.name;
});
// Map text handles to schema names
textInputs.forEach((input, index) => {
const handleId = textInputs.length > 1 ? `text-${index}` : "text";
handleToSchemaName[handleId] = input.name;
});
}
// Helper to determine if a handle ID is an image type
const isImageHandle = (handleId: string | null | undefined): boolean => {
if (!handleId) return false;
return handleId === "image" || handleId.includes("image") || handleId.includes("frame");
return handleId === "image" || handleId.startsWith("image-") || handleId.includes("frame");
};
// Helper to determine if a handle ID is a text type
const isTextHandle = (handleId: string | null | undefined): boolean => {
if (!handleId) return false;
return handleId === "text" || handleId.includes("prompt");
return handleId === "text" || handleId.startsWith("text-") || handleId.includes("prompt");
};
// Helper to extract output from source node
@ -870,9 +895,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
if (!value) return;
// Store in dynamic inputs if handle ID is specific (not just "image" or "text")
if (handleId && handleId !== "image" && handleId !== "text") {
dynamicInputs[handleId] = value;
// Map normalized handle ID to schema name for dynamicInputs
// This allows API to receive schema-specific parameter names
if (handleId && handleToSchemaName[handleId]) {
dynamicInputs[handleToSchemaName[handleId]] = value;
}
// Also populate legacy arrays for backward compatibility

Loading…
Cancel
Save