From 7e8c92501889b73d861f59ac1605e894569ffbd2 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 17 Jan 2026 21:42:08 +1300 Subject: [PATCH] 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 --- .../utils/__tests__/localStorage.test.ts | 114 ++++++++++++++++++ .../utils/__tests__/nodeDefaults.test.ts | 102 ++++++++++++++++ src/store/utils/localStorage.ts | 46 ++++++- src/store/utils/nodeDefaults.ts | 59 ++++++--- src/types/nodes.ts | 37 +++++- 5 files changed, 336 insertions(+), 22 deletions(-) diff --git a/src/store/utils/__tests__/localStorage.test.ts b/src/store/utils/__tests__/localStorage.test.ts index d3434ec8..b61e3752 100644 --- a/src/store/utils/__tests__/localStorage.test.ts +++ b/src/store/utils/__tests__/localStorage.test.ts @@ -4,6 +4,7 @@ import { COST_DATA_STORAGE_KEY, GENERATE_IMAGE_DEFAULTS_KEY, PROVIDER_SETTINGS_KEY, + NODE_DEFAULTS_KEY, loadSaveConfigs, saveSaveConfig, loadWorkflowCostData, @@ -14,6 +15,11 @@ import { saveProviderSettings, defaultProviderSettings, generateWorkflowId, + loadNodeDefaults, + saveNodeDefaults, + getGenerateImageDefaults, + getGenerateVideoDefaults, + getLLMDefaults, } from "../localStorage"; // Mock localStorage @@ -279,4 +285,112 @@ describe("localStorage utilities", () => { 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); + }); + }); }); diff --git a/src/store/utils/__tests__/nodeDefaults.test.ts b/src/store/utils/__tests__/nodeDefaults.test.ts index e4f2960c..ef1934f6 100644 --- a/src/store/utils/__tests__/nodeDefaults.test.ts +++ b/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).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); + }); }); }); diff --git a/src/store/utils/localStorage.ts b/src/store/utils/localStorage.ts index ec7b1c40..a8c9d9d9 100644 --- a/src/store/utils/localStorage.ts +++ b/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 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 PROVIDER_SETTINGS_KEY = "node-banana-provider-settings"; 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) export const MAX_RECENT_MODELS = 8; @@ -145,6 +155,40 @@ export const saveRecentModels = (models: RecentModel[]): void => { 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 export const generateWorkflowId = (): string => `wf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index ee3bb0f0..525b3427 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -12,7 +12,7 @@ import { GroupColor, SelectedModel, } from "@/types"; -import { loadGenerateImageDefaults } from "./localStorage"; +import { loadGenerateImageDefaults, loadNodeDefaults } from "./localStorage"; /** * Default dimensions for each node type. @@ -70,51 +70,70 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { prompt: "", } as PromptNodeData; case "nanoBanana": { - const defaults = loadGenerateImageDefaults(); - const modelDisplayName = defaults.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro"; - const defaultSelectedModel: SelectedModel = { - provider: "gemini", - modelId: defaults.model, - displayName: modelDisplayName, - }; + const nodeDefaults = loadNodeDefaults(); + const legacyDefaults = loadGenerateImageDefaults(); + + // Determine selectedModel: prefer new nodeDefaults, fallback to legacy + let selectedModel: SelectedModel; + 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 { inputImages: [], inputPrompt: null, outputImage: null, - aspectRatio: defaults.aspectRatio, - resolution: defaults.resolution, - model: defaults.model, - selectedModel: defaultSelectedModel, - useGoogleSearch: defaults.useGoogleSearch, + aspectRatio, + resolution, + model: legacyDefaults.model, // Keep legacy model field for backward compat + selectedModel, + useGoogleSearch, status: "idle", error: null, imageHistory: [], selectedHistoryIndex: 0, } as NanoBananaNodeData; } - case "generateVideo": + case "generateVideo": { + const nodeDefaults = loadNodeDefaults(); return { inputImages: [], inputPrompt: null, outputVideo: null, - selectedModel: undefined, + selectedModel: nodeDefaults.generateVideo?.selectedModel, status: "idle", error: null, videoHistory: [], selectedVideoHistoryIndex: 0, } as GenerateVideoNodeData; - case "llmGenerate": + } + case "llmGenerate": { + const nodeDefaults = loadNodeDefaults(); + const llmDefaults = nodeDefaults.llm; return { inputPrompt: null, inputImages: [], outputText: null, - provider: "google", - model: "gemini-3-flash-preview", - temperature: 0.7, - maxTokens: 8192, + provider: llmDefaults?.provider ?? "google", + model: llmDefaults?.model ?? "gemini-3-flash-preview", + temperature: llmDefaults?.temperature ?? 0.7, + maxTokens: llmDefaults?.maxTokens ?? 8192, status: "idle", error: null, } as LLMGenerateNodeData; + } case "splitGrid": return { sourceImage: null, diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 10bc6770..abbc02e7 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -17,7 +17,7 @@ export type { AnnotationNodeData, BaseNodeData }; // Import from domain files to avoid circular dependencies 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 @@ -214,3 +214,38 @@ export type WorkflowNode = Node & { * Handle types for node connections */ 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; +}