Browse Source

feat(27-01): add node defaults infrastructure for user preferences

Enable storing default model and settings per node type in localStorage.
New nodes created via shortcuts will use these stored preferences.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
7e8c925018
  1. 114
      src/store/utils/__tests__/localStorage.test.ts
  2. 102
      src/store/utils/__tests__/nodeDefaults.test.ts
  3. 46
      src/store/utils/localStorage.ts
  4. 59
      src/store/utils/nodeDefaults.ts
  5. 37
      src/types/nodes.ts

114
src/store/utils/__tests__/localStorage.test.ts

@ -4,6 +4,7 @@ import {
COST_DATA_STORAGE_KEY, COST_DATA_STORAGE_KEY,
GENERATE_IMAGE_DEFAULTS_KEY, GENERATE_IMAGE_DEFAULTS_KEY,
PROVIDER_SETTINGS_KEY, PROVIDER_SETTINGS_KEY,
NODE_DEFAULTS_KEY,
loadSaveConfigs, loadSaveConfigs,
saveSaveConfig, saveSaveConfig,
loadWorkflowCostData, loadWorkflowCostData,
@ -14,6 +15,11 @@ import {
saveProviderSettings, saveProviderSettings,
defaultProviderSettings, defaultProviderSettings,
generateWorkflowId, generateWorkflowId,
loadNodeDefaults,
saveNodeDefaults,
getGenerateImageDefaults,
getGenerateVideoDefaults,
getLLMDefaults,
} from "../localStorage"; } from "../localStorage";
// Mock localStorage // Mock localStorage
@ -279,4 +285,112 @@ describe("localStorage utilities", () => {
expect(id).toMatch(/^wf_\d+_[a-z0-9]+$/); expect(id).toMatch(/^wf_\d+_[a-z0-9]+$/);
}); });
}); });
describe("loadNodeDefaults", () => {
it("returns empty object when localStorage is empty", () => {
const result = loadNodeDefaults();
expect(result).toEqual({});
});
it("returns stored config when data exists", () => {
const config = {
generateImage: {
selectedModel: { provider: "fal", modelId: "test-model", displayName: "Test Model" },
aspectRatio: "16:9",
},
llm: {
provider: "openai",
model: "gpt-4.1-mini",
},
};
localStorageMock.setItem(NODE_DEFAULTS_KEY, JSON.stringify(config));
const result = loadNodeDefaults();
expect(result).toEqual(config);
});
it("returns empty object on invalid JSON", () => {
localStorageMock.setItem(NODE_DEFAULTS_KEY, "not valid json");
const result = loadNodeDefaults();
expect(result).toEqual({});
});
});
describe("saveNodeDefaults", () => {
it("stores config to localStorage", () => {
const config = {
generateVideo: {
selectedModel: { provider: "replicate", modelId: "video-model", displayName: "Video Model" },
},
};
saveNodeDefaults(config);
const stored = JSON.parse(localStorageMock.getItem(NODE_DEFAULTS_KEY)!);
expect(stored).toEqual(config);
});
});
describe("getGenerateImageDefaults", () => {
it("returns undefined when not configured", () => {
const result = getGenerateImageDefaults();
expect(result).toBeUndefined();
});
it("returns stored generateImage defaults when configured", () => {
const config = {
generateImage: {
selectedModel: { provider: "gemini", modelId: "nano-banana-pro", displayName: "Nano Banana Pro" },
aspectRatio: "4:3",
useGoogleSearch: true,
},
};
localStorageMock.setItem(NODE_DEFAULTS_KEY, JSON.stringify(config));
const result = getGenerateImageDefaults();
expect(result).toEqual(config.generateImage);
});
});
describe("getGenerateVideoDefaults", () => {
it("returns undefined when not configured", () => {
const result = getGenerateVideoDefaults();
expect(result).toBeUndefined();
});
it("returns stored generateVideo defaults when configured", () => {
const config = {
generateVideo: {
selectedModel: { provider: "fal", modelId: "kling-video", displayName: "Kling Video" },
},
};
localStorageMock.setItem(NODE_DEFAULTS_KEY, JSON.stringify(config));
const result = getGenerateVideoDefaults();
expect(result).toEqual(config.generateVideo);
});
});
describe("getLLMDefaults", () => {
it("returns undefined when not configured", () => {
const result = getLLMDefaults();
expect(result).toBeUndefined();
});
it("returns stored llm defaults when configured", () => {
const config = {
llm: {
provider: "openai",
model: "gpt-4.1-nano",
temperature: 0.5,
maxTokens: 4096,
},
};
localStorageMock.setItem(NODE_DEFAULTS_KEY, JSON.stringify(config));
const result = getLLMDefaults();
expect(result).toEqual(config.llm);
});
});
}); });

102
src/store/utils/__tests__/nodeDefaults.test.ts

@ -201,5 +201,107 @@ describe("nodeDefaults utilities", () => {
expect((data as any).model).toBe("nano-banana"); expect((data as any).model).toBe("nano-banana");
expect((data as any).useGoogleSearch).toBe(true); expect((data as any).useGoogleSearch).toBe(true);
}); });
it("uses node defaults selectedModel for nanoBanana when set", () => {
const nodeDefaultsConfig = {
generateImage: {
selectedModel: { provider: "fal", modelId: "flux-pro", displayName: "Flux Pro" },
aspectRatio: "4:3",
useGoogleSearch: true,
},
};
localStorageMock.setItem(
"node-banana-node-defaults",
JSON.stringify(nodeDefaultsConfig)
);
const data = createDefaultNodeData("nanoBanana");
expect((data as any).selectedModel).toEqual({
provider: "fal",
modelId: "flux-pro",
displayName: "Flux Pro",
});
expect((data as any).aspectRatio).toBe("4:3");
expect((data as any).useGoogleSearch).toBe(true);
});
it("uses node defaults selectedModel for generateVideo when set", () => {
const nodeDefaultsConfig = {
generateVideo: {
selectedModel: { provider: "replicate", modelId: "kling-video", displayName: "Kling Video" },
},
};
localStorageMock.setItem(
"node-banana-node-defaults",
JSON.stringify(nodeDefaultsConfig)
);
const data = createDefaultNodeData("generateVideo");
expect((data as any).selectedModel).toEqual({
provider: "replicate",
modelId: "kling-video",
displayName: "Kling Video",
});
});
it("returns undefined selectedModel for generateVideo when not set", () => {
const data = createDefaultNodeData("generateVideo");
expect((data as any).selectedModel).toBeUndefined();
});
it("uses node defaults for llmGenerate when set", () => {
const nodeDefaultsConfig = {
llm: {
provider: "openai",
model: "gpt-4.1-mini",
temperature: 0.3,
maxTokens: 4096,
},
};
localStorageMock.setItem(
"node-banana-node-defaults",
JSON.stringify(nodeDefaultsConfig)
);
const data = createDefaultNodeData("llmGenerate");
expect((data as any).provider).toBe("openai");
expect((data as any).model).toBe("gpt-4.1-mini");
expect((data as any).temperature).toBe(0.3);
expect((data as any).maxTokens).toBe(4096);
});
it("falls back to hardcoded llmGenerate defaults when not set", () => {
const data = createDefaultNodeData("llmGenerate");
expect((data as any).provider).toBe("google");
expect((data as any).model).toBe("gemini-3-flash-preview");
expect((data as any).temperature).toBe(0.7);
expect((data as any).maxTokens).toBe(8192);
});
it("partially overrides llmGenerate defaults with node defaults", () => {
const nodeDefaultsConfig = {
llm: {
temperature: 0.9,
},
};
localStorageMock.setItem(
"node-banana-node-defaults",
JSON.stringify(nodeDefaultsConfig)
);
const data = createDefaultNodeData("llmGenerate");
// Should use node default for temperature
expect((data as any).temperature).toBe(0.9);
// Should fall back to hardcoded for others
expect((data as any).provider).toBe("google");
expect((data as any).model).toBe("gemini-3-flash-preview");
expect((data as any).maxTokens).toBe(8192);
});
}); });
}); });

46
src/store/utils/localStorage.ts

@ -1,4 +1,13 @@
import { WorkflowSaveConfig, WorkflowCostData, ProviderSettings, RecentModel } from "@/types"; import {
WorkflowSaveConfig,
WorkflowCostData,
ProviderSettings,
RecentModel,
NodeDefaultsConfig,
GenerateImageNodeDefaults,
GenerateVideoNodeDefaults,
LLMNodeDefaults,
} from "@/types";
// Storage keys // Storage keys
export const STORAGE_KEY = "node-banana-workflow-configs"; export const STORAGE_KEY = "node-banana-workflow-configs";
@ -6,6 +15,7 @@ export const COST_DATA_STORAGE_KEY = "node-banana-workflow-costs";
export const GENERATE_IMAGE_DEFAULTS_KEY = "node-banana-nanoBanana-defaults"; export const GENERATE_IMAGE_DEFAULTS_KEY = "node-banana-nanoBanana-defaults";
export const PROVIDER_SETTINGS_KEY = "node-banana-provider-settings"; export const PROVIDER_SETTINGS_KEY = "node-banana-provider-settings";
export const RECENT_MODELS_KEY = "node-banana-recent-models"; export const RECENT_MODELS_KEY = "node-banana-recent-models";
export const NODE_DEFAULTS_KEY = "node-banana-node-defaults";
// Maximum recent models to store (show 4 in UI, keep 8 for persistence) // Maximum recent models to store (show 4 in UI, keep 8 for persistence)
export const MAX_RECENT_MODELS = 8; export const MAX_RECENT_MODELS = 8;
@ -145,6 +155,40 @@ export const saveRecentModels = (models: RecentModel[]): void => {
localStorage.setItem(RECENT_MODELS_KEY, JSON.stringify(models)); localStorage.setItem(RECENT_MODELS_KEY, JSON.stringify(models));
}; };
// Node defaults helpers
export const loadNodeDefaults = (): NodeDefaultsConfig => {
if (typeof window === "undefined") return {};
const stored = localStorage.getItem(NODE_DEFAULTS_KEY);
if (stored) {
try {
return JSON.parse(stored) as NodeDefaultsConfig;
} catch {
return {};
}
}
return {};
};
export const saveNodeDefaults = (config: NodeDefaultsConfig): void => {
if (typeof window === "undefined") return;
localStorage.setItem(NODE_DEFAULTS_KEY, JSON.stringify(config));
};
export const getGenerateImageDefaults = (): GenerateImageNodeDefaults | undefined => {
const config = loadNodeDefaults();
return config.generateImage;
};
export const getGenerateVideoDefaults = (): GenerateVideoNodeDefaults | undefined => {
const config = loadNodeDefaults();
return config.generateVideo;
};
export const getLLMDefaults = (): LLMNodeDefaults | undefined => {
const config = loadNodeDefaults();
return config.llm;
};
// Workflow ID generator // Workflow ID generator
export const generateWorkflowId = (): string => export const generateWorkflowId = (): string =>
`wf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; `wf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

59
src/store/utils/nodeDefaults.ts

@ -12,7 +12,7 @@ import {
GroupColor, GroupColor,
SelectedModel, SelectedModel,
} from "@/types"; } from "@/types";
import { loadGenerateImageDefaults } from "./localStorage"; import { loadGenerateImageDefaults, loadNodeDefaults } from "./localStorage";
/** /**
* Default dimensions for each node type. * Default dimensions for each node type.
@ -70,51 +70,70 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
prompt: "", prompt: "",
} as PromptNodeData; } as PromptNodeData;
case "nanoBanana": { case "nanoBanana": {
const defaults = loadGenerateImageDefaults(); const nodeDefaults = loadNodeDefaults();
const modelDisplayName = defaults.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro"; const legacyDefaults = loadGenerateImageDefaults();
const defaultSelectedModel: SelectedModel = {
provider: "gemini", // Determine selectedModel: prefer new nodeDefaults, fallback to legacy
modelId: defaults.model, let selectedModel: SelectedModel;
displayName: modelDisplayName, if (nodeDefaults.generateImage?.selectedModel) {
}; selectedModel = nodeDefaults.generateImage.selectedModel;
} else {
const modelDisplayName = legacyDefaults.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro";
selectedModel = {
provider: "gemini",
modelId: legacyDefaults.model,
displayName: modelDisplayName,
};
}
// Merge settings: new nodeDefaults override legacy defaults
const aspectRatio = nodeDefaults.generateImage?.aspectRatio ?? legacyDefaults.aspectRatio;
const resolution = nodeDefaults.generateImage?.resolution ?? legacyDefaults.resolution;
const useGoogleSearch = nodeDefaults.generateImage?.useGoogleSearch ?? legacyDefaults.useGoogleSearch;
return { return {
inputImages: [], inputImages: [],
inputPrompt: null, inputPrompt: null,
outputImage: null, outputImage: null,
aspectRatio: defaults.aspectRatio, aspectRatio,
resolution: defaults.resolution, resolution,
model: defaults.model, model: legacyDefaults.model, // Keep legacy model field for backward compat
selectedModel: defaultSelectedModel, selectedModel,
useGoogleSearch: defaults.useGoogleSearch, useGoogleSearch,
status: "idle", status: "idle",
error: null, error: null,
imageHistory: [], imageHistory: [],
selectedHistoryIndex: 0, selectedHistoryIndex: 0,
} as NanoBananaNodeData; } as NanoBananaNodeData;
} }
case "generateVideo": case "generateVideo": {
const nodeDefaults = loadNodeDefaults();
return { return {
inputImages: [], inputImages: [],
inputPrompt: null, inputPrompt: null,
outputVideo: null, outputVideo: null,
selectedModel: undefined, selectedModel: nodeDefaults.generateVideo?.selectedModel,
status: "idle", status: "idle",
error: null, error: null,
videoHistory: [], videoHistory: [],
selectedVideoHistoryIndex: 0, selectedVideoHistoryIndex: 0,
} as GenerateVideoNodeData; } as GenerateVideoNodeData;
case "llmGenerate": }
case "llmGenerate": {
const nodeDefaults = loadNodeDefaults();
const llmDefaults = nodeDefaults.llm;
return { return {
inputPrompt: null, inputPrompt: null,
inputImages: [], inputImages: [],
outputText: null, outputText: null,
provider: "google", provider: llmDefaults?.provider ?? "google",
model: "gemini-3-flash-preview", model: llmDefaults?.model ?? "gemini-3-flash-preview",
temperature: 0.7, temperature: llmDefaults?.temperature ?? 0.7,
maxTokens: 8192, maxTokens: llmDefaults?.maxTokens ?? 8192,
status: "idle", status: "idle",
error: null, error: null,
} as LLMGenerateNodeData; } as LLMGenerateNodeData;
}
case "splitGrid": case "splitGrid":
return { return {
sourceImage: null, sourceImage: null,

37
src/types/nodes.ts

@ -17,7 +17,7 @@ export type { AnnotationNodeData, BaseNodeData };
// Import from domain files to avoid circular dependencies // Import from domain files to avoid circular dependencies
import type { AspectRatio, Resolution, ModelType } from "./models"; import type { AspectRatio, Resolution, ModelType } from "./models";
import type { LLMProvider, LLMModelType, SelectedModel } from "./providers"; import type { LLMProvider, LLMModelType, SelectedModel, ProviderType } from "./providers";
/** /**
* All available node types in the workflow editor * All available node types in the workflow editor
@ -214,3 +214,38 @@ export type WorkflowNode = Node<WorkflowNodeData, NodeType> & {
* Handle types for node connections * Handle types for node connections
*/ */
export type HandleType = "image" | "text"; export type HandleType = "image" | "text";
/**
* Default settings for node types - stored in localStorage
*/
export interface GenerateImageNodeDefaults {
selectedModel?: {
provider: ProviderType;
modelId: string;
displayName: string;
};
aspectRatio?: string;
resolution?: string;
useGoogleSearch?: boolean;
}
export interface GenerateVideoNodeDefaults {
selectedModel?: {
provider: ProviderType;
modelId: string;
displayName: string;
};
}
export interface LLMNodeDefaults {
provider?: LLMProvider;
model?: LLMModelType;
temperature?: number;
maxTokens?: number;
}
export interface NodeDefaultsConfig {
generateImage?: GenerateImageNodeDefaults;
generateVideo?: GenerateVideoNodeDefaults;
llm?: LLMNodeDefaults;
}

Loading…
Cancel
Save