From 9cce95fa225a8b3128f595a8a82d6df9d7ee820c Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 12 Jan 2026 00:11:26 +1300 Subject: [PATCH] feat: enhance API key management and provider integration - Updated .env.example to include new API keys for OpenAI, Replicate, and fal.ai. - Modified API routes to prioritize user-provided API keys over environment variables for Gemini and OpenAI. - Improved error messages for missing API keys to guide users on configuration. - Enhanced ProjectSetupModal to support OpenAI provider and manage API key overrides. - Updated workflow store to handle new provider settings and API key integration for OpenAI. - Added functionality to auto-save generated images and videos to the specified directory. --- .env.example | 10 +- src/app/api/env-status/route.ts | 20 ++ src/app/api/generate/route.ts | 5 +- src/app/api/llm/route.ts | 24 +- src/app/api/models/[modelId]/route.ts | 82 ++++++- src/app/api/save-generation/route.ts | 77 ++++++- src/components/ProjectSetupModal.tsx | 308 +++++++++++++++++++------- src/store/workflowStore.ts | 169 ++++++++++++-- src/types/index.ts | 2 +- 9 files changed, 559 insertions(+), 138 deletions(-) create mode 100644 src/app/api/env-status/route.ts diff --git a/.env.example b/.env.example index f2737f8a..0a82c19f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,15 @@ -# Google Gemini API Key (Required) +# Google Gemini API Key (Required for image generation and Google LLM) # Get your API key from: https://aistudio.google.com/ GEMINI_API_KEY=your_gemini_api_key_here # OpenAI API Key (Optional - only needed if using OpenAI LLM provider) # Get your API key from: https://platform.openai.com/api-keys OPENAI_API_KEY=your_openai_api_key_here + +# Replicate API Key (Optional - only needed if using Replicate models) +# Get your API key from: https://replicate.com/account/api-tokens +REPLICATE_API_KEY=your_replicate_api_key_here + +# fal.ai API Key (Optional - only needed if using fal.ai models) +# Get your API key from: https://fal.ai/dashboard/keys +FAL_API_KEY=your_fal_api_key_here diff --git a/src/app/api/env-status/route.ts b/src/app/api/env-status/route.ts new file mode 100644 index 00000000..621fbddb --- /dev/null +++ b/src/app/api/env-status/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +export interface EnvStatusResponse { + gemini: boolean; + openai: boolean; + replicate: boolean; + fal: boolean; +} + +export async function GET() { + // Check which API keys are configured via environment variables + const status: EnvStatusResponse = { + gemini: !!process.env.GEMINI_API_KEY, + openai: !!process.env.OPENAI_API_KEY, + replicate: !!process.env.REPLICATE_API_KEY, + fal: !!process.env.FAL_API_KEY, + }; + + return NextResponse.json(status); +} diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 101e03d7..78657e0d 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1097,14 +1097,15 @@ export async function POST(request: NextRequest) { } // Default: Use Gemini - const geminiApiKey = process.env.GEMINI_API_KEY; + // User-provided key (from settings) takes precedence over env variable + const geminiApiKey = request.headers.get("X-Gemini-API-Key") || process.env.GEMINI_API_KEY; if (!geminiApiKey) { console.error(`[API:${requestId}] No Gemini API key configured`); return NextResponse.json( { success: false, - error: "API key not configured. Add GEMINI_API_KEY to .env.local", + error: "API key not configured. Add GEMINI_API_KEY to .env.local or configure in Settings.", }, { status: 500 } ); diff --git a/src/app/api/llm/route.ts b/src/app/api/llm/route.ts index 7ca085fe..411495a8 100644 --- a/src/app/api/llm/route.ts +++ b/src/app/api/llm/route.ts @@ -28,12 +28,14 @@ async function generateWithGoogle( temperature: number, maxTokens: number, images?: string[], - requestId?: string + requestId?: string, + userApiKey?: string | null ): Promise { - const apiKey = process.env.GEMINI_API_KEY; + // User-provided key takes precedence over env variable + const apiKey = userApiKey || process.env.GEMINI_API_KEY; if (!apiKey) { logger.error('api.error', 'GEMINI_API_KEY not configured', { requestId }); - throw new Error("GEMINI_API_KEY not configured"); + throw new Error("GEMINI_API_KEY not configured. Add it to .env.local or configure in Settings."); } const ai = new GoogleGenAI({ apiKey }); @@ -110,12 +112,14 @@ async function generateWithOpenAI( temperature: number, maxTokens: number, images?: string[], - requestId?: string + requestId?: string, + userApiKey?: string | null ): Promise { - const apiKey = process.env.OPENAI_API_KEY; + // User-provided key takes precedence over env variable + const apiKey = userApiKey || process.env.OPENAI_API_KEY; if (!apiKey) { logger.error('api.error', 'OPENAI_API_KEY not configured', { requestId }); - throw new Error("OPENAI_API_KEY not configured"); + throw new Error("OPENAI_API_KEY not configured. Add it to .env.local or configure in Settings."); } const modelId = OPENAI_MODEL_MAP[model]; @@ -190,6 +194,10 @@ export async function POST(request: NextRequest) { const requestId = generateRequestId(); try { + // Get user-provided API keys from headers (override env variables) + const geminiApiKey = request.headers.get("X-Gemini-API-Key"); + const openaiApiKey = request.headers.get("X-OpenAI-API-Key"); + const body: LLMGenerateRequest = await request.json(); const { prompt, @@ -222,9 +230,9 @@ export async function POST(request: NextRequest) { let text: string; if (provider === "google") { - text = await generateWithGoogle(prompt, model, temperature, maxTokens, images, requestId); + text = await generateWithGoogle(prompt, model, temperature, maxTokens, images, requestId, geminiApiKey); } else if (provider === "openai") { - text = await generateWithOpenAI(prompt, model, temperature, maxTokens, images, requestId); + text = await generateWithOpenAI(prompt, model, temperature, maxTokens, images, requestId, openaiApiKey); } else { logger.warn('api.llm', 'Unknown provider requested', { requestId, provider }); return NextResponse.json( diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index bf3fc912..1d96c21d 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -116,21 +116,43 @@ function isTextInput(name: string): boolean { return TEXT_INPUT_NAMES.includes(name); } +/** + * Resolve a $ref reference in OpenAPI schema + * E.g., "#/components/schemas/AspectRatio" -> schema object + */ +function resolveRef( + ref: string, + schemaComponents: Record +): Record | null { + // Parse reference path like "#/components/schemas/AspectRatio" + const match = ref.match(/^#\/components\/schemas\/(.+)$/); + if (!match) return null; + + const schemaName = match[1]; + const resolved = schemaComponents[schemaName] as Record | undefined; + return resolved || null; +} + /** * Convert OpenAPI schema property to ModelParameter */ function convertSchemaProperty( name: string, prop: Record, - required: string[] + required: string[], + schemaComponents?: Record ): ModelParameter | null { // Skip excluded parameters if (EXCLUDED_PARAMS.has(name)) { return null; } - // Determine type + // Determine type and extract enum from allOf/$ref if present let type: ModelParameter["type"] = "string"; + let enumValues: unknown[] | undefined; + let resolvedDefault: unknown; + let resolvedDescription: string | undefined; + const schemaType = prop.type as string | undefined; const allOf = prop.allOf as Array> | undefined; @@ -142,16 +164,43 @@ function convertSchemaProperty( type = "boolean"; } else if (schemaType === "array") { type = "array"; - } else if (allOf && allOf.length > 0) { - // Handle allOf with enum reference - type = "string"; + } else if (allOf && allOf.length > 0 && schemaComponents) { + // Handle allOf with $ref - resolve references and extract enum/type + for (const item of allOf) { + const itemRef = item.$ref as string | undefined; + if (itemRef) { + const resolved = resolveRef(itemRef, schemaComponents); + if (resolved) { + // Extract type from resolved schema + if (resolved.type === "integer") type = "integer"; + else if (resolved.type === "number") type = "number"; + else if (resolved.type === "boolean") type = "boolean"; + + // Extract enum from resolved schema + if (Array.isArray(resolved.enum)) { + enumValues = resolved.enum; + } + // Extract default from resolved schema + if (resolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = resolved.default; + } + // Extract description from resolved schema + if (resolved.description && !resolvedDescription) { + resolvedDescription = resolved.description as string; + } + } + } else if (Array.isArray(item.enum)) { + // Direct enum in allOf item + enumValues = item.enum; + } + } } const parameter: ModelParameter = { name, type, - description: prop.description as string | undefined, - default: prop.default, + description: (prop.description as string | undefined) || resolvedDescription, + default: prop.default !== undefined ? prop.default : resolvedDefault, required: required.includes(name), }; @@ -162,8 +211,12 @@ function convertSchemaProperty( if (typeof prop.maximum === "number") { parameter.maximum = prop.maximum; } + + // Use enum from property directly, or from resolved $ref if (Array.isArray(prop.enum)) { parameter.enum = prop.enum; + } else if (enumValues) { + parameter.enum = enumValues; } return parameter; @@ -210,7 +263,9 @@ async function fetchReplicateSchema( return { parameters: [], inputs: [] }; } - return extractParametersFromSchema(inputSchema as Record); + // Pass components.schemas for $ref resolution + const schemaComponents = openApiSchema.components?.schemas as Record | undefined; + return extractParametersFromSchema(inputSchema as Record, schemaComponents); } /** @@ -281,13 +336,18 @@ async function fetchFalSchema( return { parameters: [], inputs: [] }; } - return extractParametersFromSchema(inputSchema); + // Pass components.schemas for $ref resolution + const schemaComponents = spec.components?.schemas as Record | undefined; + return extractParametersFromSchema(inputSchema, schemaComponents); } /** * Extract ModelParameters and ModelInputs from an OpenAPI schema object */ -function extractParametersFromSchema(schema: Record): ExtractedSchema { +function extractParametersFromSchema( + schema: Record, + schemaComponents?: Record +): ExtractedSchema { const properties = schema.properties as Record> | undefined; const required = (schema.required as string[]) || []; @@ -323,7 +383,7 @@ function extractParametersFromSchema(schema: Record): Extracted } // Otherwise it's a parameter - const param = convertSchemaProperty(name, prop, required); + const param = convertSchemaProperty(name, prop, required, schemaComponents); if (param) { parameters.push(param); } diff --git a/src/app/api/save-generation/route.ts b/src/app/api/save-generation/route.ts index 1d072135..7234dc21 100644 --- a/src/app/api/save-generation/route.ts +++ b/src/app/api/save-generation/route.ts @@ -3,26 +3,50 @@ import * as fs from "fs/promises"; import * as path from "path"; import { logger } from "@/utils/logger"; -// POST: Save a generated image to the generations folder +// Helper to get file extension from MIME type +function getExtensionFromMime(mimeType: string): string { + const mimeToExt: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", + }; + return mimeToExt[mimeType] || "mp4"; +} + +// Helper to detect if a string is an HTTP URL +function isHttpUrl(str: string): boolean { + return str.startsWith("http://") || str.startsWith("https://"); +} + +// POST: Save a generated image or video to the generations folder export async function POST(request: NextRequest) { let directoryPath: string | undefined; try { const body = await request.json(); directoryPath = body.directoryPath; const image = body.image; + const video = body.video; const prompt = body.prompt; const imageId = body.imageId; // Optional ID for carousel support + const isVideo = !!video; + const content = video || image; + logger.info('file.save', 'Generation auto-save request received', { directoryPath, hasImage: !!image, + hasVideo: !!video, prompt, }); - if (!directoryPath || !image) { + if (!directoryPath || !content) { logger.warn('file.save', 'Generation save validation failed: missing fields', { hasDirectoryPath: !!directoryPath, - hasImage: !!image, + hasContent: !!content, }); return NextResponse.json( { success: false, error: "Missing required fields" }, @@ -52,10 +76,42 @@ export async function POST(request: NextRequest) { ); } - // Generate filename: use imageId if provided, otherwise timestamp + sanitized prompt snippet + let buffer: Buffer; + let extension: string; + + if (isHttpUrl(content)) { + // Handle HTTP URL (common for large video files from providers) + logger.info('file.save', 'Fetching content from URL', { url: content.substring(0, 100) }); + + const response = await fetch(content); + if (!response.ok) { + throw new Error(`Failed to fetch content: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get("content-type") || (isVideo ? "video/mp4" : "image/png"); + extension = getExtensionFromMime(contentType); + + const arrayBuffer = await response.arrayBuffer(); + buffer = Buffer.from(arrayBuffer); + } else { + // Handle base64 data URL + const dataUrlMatch = content.match(/^data:([\w/+-]+);base64,/); + if (dataUrlMatch) { + const mimeType = dataUrlMatch[1]; + extension = getExtensionFromMime(mimeType); + const base64Data = content.replace(/^data:[\w/+-]+;base64,/, ""); + buffer = Buffer.from(base64Data, "base64"); + } else { + // Fallback: assume it's raw base64 without data URL prefix + extension = isVideo ? "mp4" : "png"; + buffer = Buffer.from(content, "base64"); + } + } + + // Generate filename let filename: string; if (imageId) { - filename = `${imageId}.png`; + filename = `${imageId}.${extension}`; } else { const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const promptSnippet = prompt @@ -66,28 +122,25 @@ export async function POST(request: NextRequest) { .replace(/^_|_$/g, "") .toLowerCase() : "generation"; - filename = `${timestamp}_${promptSnippet}.png`; + filename = `${timestamp}_${promptSnippet}.${extension}`; } const filePath = path.join(directoryPath, filename); - // Extract base64 data and convert to buffer - const base64Data = image.replace(/^data:image\/\w+;base64,/, ""); - const buffer = Buffer.from(base64Data, "base64"); - - // Write the image file + // Write the file await fs.writeFile(filePath, buffer); logger.info('file.save', 'Generation auto-saved successfully', { filePath, filename, fileSize: buffer.length, + isVideo, }); return NextResponse.json({ success: true, filePath, filename, - imageId: imageId || filename.replace('.png', ''), // Return ID for carousel tracking + imageId: imageId || filename.replace(`.${extension}`, ''), }); } catch (error) { logger.error('file.error', 'Failed to save generation', { diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 375e0cc1..c698b0ea 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { generateWorkflowId, useWorkflowStore } from "@/store/workflowStore"; import { ProviderType, ProviderSettings } from "@/types"; +import { EnvStatusResponse } from "@/app/api/env-status/route"; interface ProjectSetupModalProps { isOpen: boolean; @@ -42,9 +43,17 @@ export function ProjectSetupModal({ const [localProviders, setLocalProviders] = useState(providerSettings); const [showApiKey, setShowApiKey] = useState>({ gemini: false, + openai: false, replicate: false, fal: false, }); + const [overrideActive, setOverrideActive] = useState>({ + gemini: false, + openai: false, + replicate: false, + fal: false, + }); + const [envStatus, setEnvStatus] = useState(null); // Pre-fill when opening in settings mode useEffect(() => { @@ -66,8 +75,21 @@ export function ProjectSetupModal({ // Sync local providers state setLocalProviders(providerSettings); - setShowApiKey({ gemini: false, replicate: false, fal: false }); + setShowApiKey({ gemini: false, openai: false, replicate: false, fal: false }); + // Initialize override as active if user already has a key set + setOverrideActive({ + gemini: !!providerSettings.providers.gemini?.apiKey, + openai: !!providerSettings.providers.openai?.apiKey, + replicate: !!providerSettings.providers.replicate?.apiKey, + fal: !!providerSettings.providers.fal?.apiKey, + }); setError(null); + + // Fetch env status + fetch("/api/env-status") + .then((res) => res.json()) + .then((data: EnvStatusResponse) => setEnvStatus(data)) + .catch(() => setEnvStatus(null)); } }, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage, providerSettings]); @@ -148,11 +170,13 @@ export function ProjectSetupModal({ const handleSaveProviders = () => { // Save each provider's settings - const providerIds: ProviderType[] = ["gemini", "replicate", "fal"]; + const providerIds: ProviderType[] = ["gemini", "openai", "replicate", "fal"]; for (const providerId of providerIds) { const local = localProviders.providers[providerId]; const current = providerSettings.providers[providerId]; + if (!local || !current) continue; + // Update enabled state if changed if (local.enabled !== current.enabled) { toggleProvider(providerId, local.enabled); @@ -292,105 +316,227 @@ export function ProjectSetupModal({ {/* Providers Tab Content */} {activeTab === "providers" && ( -
+
{/* Gemini Provider */} -
-
-
- - {localProviders.providers.gemini.name} - - - Default - -
- Enabled -
-

- Configured via GEMINI_API_KEY environment variable -

-
- - {/* Replicate Provider */} -
-
- - {localProviders.providers.replicate.name} - - -
- {localProviders.providers.replicate.enabled && ( -
- -
+
+
+ Google Gemini + {envStatus?.gemini && !overrideActive.gemini ? ( +
+ Configured via .env + +
+ ) : ( +
updateLocalProvider("replicate", { apiKey: e.target.value || null })} - placeholder="r8_..." - className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500" + type={showApiKey.gemini ? "text" : "password"} + value={localProviders.providers.gemini?.apiKey || ""} + onChange={(e) => updateLocalProvider("gemini", { apiKey: e.target.value || null })} + placeholder="AIza..." + className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> + {envStatus?.gemini && ( + + )}
-
- )} + )} +
- {/* fal.ai Provider */} -
-
- - {localProviders.providers.fal.name} - - -
- {localProviders.providers.fal.enabled && ( -
- -
+ {/* OpenAI Provider */} +
+
+ OpenAI + {envStatus?.openai && !overrideActive.openai ? ( +
+ Configured via .env + +
+ ) : ( +
updateLocalProvider("fal", { apiKey: e.target.value || null })} - placeholder="fal_..." - className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500" + type={showApiKey.openai ? "text" : "password"} + value={localProviders.providers.openai?.apiKey || ""} + onChange={(e) => updateLocalProvider("openai", { apiKey: e.target.value || null })} + placeholder="sk-..." + className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> + {envStatus?.openai && ( + + )}
+ )} +
+
+ + {/* Replicate Provider */} +
+
+
+ Replicate + +
+ {localProviders.providers.replicate?.enabled && ( + envStatus?.replicate && !overrideActive.replicate ? ( +
+ Configured via .env + +
+ ) : ( +
+ updateLocalProvider("replicate", { apiKey: e.target.value || null })} + placeholder="r8_..." + className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" + /> + + {envStatus?.replicate && ( + + )} +
+ ) + )} +
+
+ + {/* fal.ai Provider */} +
+
+
+ fal.ai +
- )} + {localProviders.providers.fal?.enabled && ( + envStatus?.fal && !overrideActive.fal ? ( +
+ Configured via .env + +
+ ) : ( +
+ updateLocalProvider("fal", { apiKey: e.target.value || null })} + placeholder="..." + className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" + /> + + {envStatus?.fal && ( + + )} +
+ ) + )} +
-

- API keys are stored locally in your browser and never sent to our servers. +

+ Add API keys via .env.local for better security. Keys added here override .env and are stored in your browser.

)} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 2d54309f..ea8253fb 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -354,8 +354,9 @@ const PROVIDER_SETTINGS_KEY = "node-banana-provider-settings"; const defaultProviderSettings: ProviderSettings = { providers: { gemini: { id: "gemini", name: "Google Gemini", enabled: true, apiKey: null, apiKeyEnvVar: "GEMINI_API_KEY" }, - replicate: { id: "replicate", name: "Replicate", enabled: false, apiKey: null }, - fal: { id: "fal", name: "fal.ai", enabled: false, apiKey: null }, + openai: { id: "openai", name: "OpenAI", enabled: true, apiKey: null, apiKeyEnvVar: "OPENAI_API_KEY" }, + replicate: { id: "replicate", name: "Replicate", enabled: false, apiKey: null, apiKeyEnvVar: "REPLICATE_API_KEY" }, + fal: { id: "fal", name: "fal.ai", enabled: false, apiKey: null, apiKeyEnvVar: "FAL_API_KEY" }, } }; @@ -364,7 +365,14 @@ const getProviderSettings = (): ProviderSettings => { const stored = localStorage.getItem(PROVIDER_SETTINGS_KEY); if (stored) { try { - return JSON.parse(stored); + const parsed = JSON.parse(stored) as ProviderSettings; + // Merge with defaults to handle new providers added after user saved settings + return { + providers: { + ...defaultProviderSettings.providers, + ...parsed.providers, + } + }; } catch { return defaultProviderSettings; } @@ -1101,23 +1109,28 @@ export const useWorkflowStore = create((set, get) => ({ dynamicInputs, // Pass dynamic inputs for schema-mapped connections }; - // Build headers with API keys for external providers + // Build headers with API keys for providers const headers: Record = { "Content-Type": "application/json", }; - if (nodeData.selectedModel?.provider === "replicate") { + const provider = nodeData.selectedModel?.provider || "gemini"; + if (provider === "gemini") { + const geminiConfig = providerSettingsState.providers.gemini; + if (geminiConfig?.apiKey) { + headers["X-Gemini-API-Key"] = geminiConfig.apiKey; + } + } else if (provider === "replicate") { const replicateConfig = providerSettingsState.providers.replicate; if (replicateConfig?.apiKey) { headers["X-Replicate-API-Key"] = replicateConfig.apiKey; } - } else if (nodeData.selectedModel?.provider === "fal") { + } else if (provider === "fal") { const falConfig = providerSettingsState.providers.fal; if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } } - const provider = nodeData.selectedModel?.provider || "gemini"; logger.info('node.execution', `Calling ${provider} API for image generation`, { nodeId: node.id, provider, @@ -1310,23 +1323,27 @@ export const useWorkflowStore = create((set, get) => ({ dynamicInputs, // Pass dynamic inputs for schema-mapped connections }; - // Build headers with API keys for external providers + // Build headers with API keys for providers const headers: Record = { "Content-Type": "application/json", }; - if (nodeData.selectedModel.provider === "replicate") { + const provider = nodeData.selectedModel.provider; + if (provider === "gemini") { + const geminiConfig = providerSettingsState.providers.gemini; + if (geminiConfig?.apiKey) { + headers["X-Gemini-API-Key"] = geminiConfig.apiKey; + } + } else if (provider === "replicate") { const replicateConfig = providerSettingsState.providers.replicate; if (replicateConfig?.apiKey) { headers["X-Replicate-API-Key"] = replicateConfig.apiKey; } - } else if (nodeData.selectedModel.provider === "fal") { + } else if (provider === "fal") { const falConfig = providerSettingsState.providers.fal; if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } } - - const provider = nodeData.selectedModel.provider; logger.info('node.execution', `Calling ${provider} API for video generation`, { nodeId: node.id, provider, @@ -1378,6 +1395,22 @@ export const useWorkflowStore = create((set, get) => ({ status: "complete", error: null, }); + + // Auto-save video to generations folder if configured + const genPath = get().generationsPath; + if (genPath) { + fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: genPath, + video: videoData, + prompt: text, + }), + }).catch((err) => { + console.error("Failed to save video generation:", err); + }); + } } else if (result.success && result.image) { // Some models might return an image preview; treat as video for now updateNodeData(node.id, { @@ -1385,6 +1418,22 @@ export const useWorkflowStore = create((set, get) => ({ status: "complete", error: null, }); + + // Auto-save image preview to generations folder if configured + const genPath = get().generationsPath; + if (genPath) { + fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: genPath, + image: result.image, + prompt: text, + }), + }).catch((err) => { + console.error("Failed to save video generation:", err); + }); + } } else { logger.error('api.error', `${provider} API video generation failed`, { nodeId: node.id, @@ -1453,6 +1502,23 @@ export const useWorkflowStore = create((set, get) => ({ try { const nodeData = node.data as LLMGenerateNodeData; + const providerSettingsState = get().providerSettings; + + // Build headers with API keys for LLM providers + const headers: Record = { + "Content-Type": "application/json", + }; + if (nodeData.provider === "google") { + const geminiConfig = providerSettingsState.providers.gemini; + if (geminiConfig?.apiKey) { + headers["X-Gemini-API-Key"] = geminiConfig.apiKey; + } + } else if (nodeData.provider === "openai") { + const openaiConfig = providerSettingsState.providers.openai; + if (openaiConfig?.apiKey) { + headers["X-OpenAI-API-Key"] = openaiConfig.apiKey; + } + } logger.info('api.llm', 'Calling LLM API', { nodeId: node.id, @@ -1466,7 +1532,7 @@ export const useWorkflowStore = create((set, get) => ({ const response = await fetch("/api/llm", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ prompt: text, ...(images.length > 0 && { images }), @@ -1735,16 +1801,21 @@ export const useWorkflowStore = create((set, get) => ({ error: null, }); - // Build headers with API keys for external providers + // Build headers with API keys for providers const headers: Record = { "Content-Type": "application/json", }; - if (nodeData.selectedModel?.provider === "replicate") { + if (provider === "gemini") { + const geminiConfig = providerSettingsState.providers.gemini; + if (geminiConfig?.apiKey) { + headers["X-Gemini-API-Key"] = geminiConfig.apiKey; + } + } else if (provider === "replicate") { const replicateConfig = providerSettingsState.providers.replicate; if (replicateConfig?.apiKey) { headers["X-Replicate-API-Key"] = replicateConfig.apiKey; } - } else if (nodeData.selectedModel?.provider === "fal") { + } else if (provider === "fal") { const falConfig = providerSettingsState.providers.fal; if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; @@ -1881,6 +1952,24 @@ export const useWorkflowStore = create((set, get) => ({ error: null, }); + const providerSettingsState = get().providerSettings; + + // Build headers with API keys for LLM providers + const headers: Record = { + "Content-Type": "application/json", + }; + if (nodeData.provider === "google") { + const geminiConfig = providerSettingsState.providers.gemini; + if (geminiConfig?.apiKey) { + headers["X-Gemini-API-Key"] = geminiConfig.apiKey; + } + } else if (nodeData.provider === "openai") { + const openaiConfig = providerSettingsState.providers.openai; + if (openaiConfig?.apiKey) { + headers["X-OpenAI-API-Key"] = openaiConfig.apiKey; + } + } + logger.info('api.llm', 'Calling LLM API for node regeneration', { nodeId, provider: nodeData.provider, @@ -1893,7 +1982,7 @@ export const useWorkflowStore = create((set, get) => ({ const response = await fetch("/api/llm", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ prompt: text, ...(images.length > 0 && { images }), @@ -1982,23 +2071,27 @@ export const useWorkflowStore = create((set, get) => ({ error: null, }); - // Build headers with API keys for external providers + // Build headers with API keys for providers const headers: Record = { "Content-Type": "application/json", }; - if (nodeData.selectedModel.provider === "replicate") { + const provider = nodeData.selectedModel.provider; + if (provider === "gemini") { + const geminiConfig = providerSettingsState.providers.gemini; + if (geminiConfig?.apiKey) { + headers["X-Gemini-API-Key"] = geminiConfig.apiKey; + } + } else if (provider === "replicate") { const replicateConfig = providerSettingsState.providers.replicate; if (replicateConfig?.apiKey) { headers["X-Replicate-API-Key"] = replicateConfig.apiKey; } - } else if (nodeData.selectedModel.provider === "fal") { + } else if (provider === "fal") { const falConfig = providerSettingsState.providers.fal; if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } } - - const provider = nodeData.selectedModel.provider; logger.info('node.execution', `Calling ${provider} API for video regeneration`, { nodeId, provider, @@ -2046,12 +2139,44 @@ export const useWorkflowStore = create((set, get) => ({ status: "complete", error: null, }); + + // Auto-save video to generations folder if configured + const genPath = get().generationsPath; + if (genPath) { + fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: genPath, + video: videoData, + prompt: text, + }), + }).catch((err) => { + console.error("Failed to save video generation:", err); + }); + } } else if (result.success && result.image) { updateNodeData(nodeId, { outputVideo: result.image, status: "complete", error: null, }); + + // Auto-save image preview to generations folder if configured + const genPath = get().generationsPath; + if (genPath) { + fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: genPath, + image: result.image, + prompt: text, + }), + }).catch((err) => { + console.error("Failed to save video generation:", err); + }); + } } else { logger.error('api.error', `${provider} API video regeneration failed`, { nodeId, diff --git a/src/types/index.ts b/src/types/index.ts index 37e93424..a3b8d54b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -332,7 +332,7 @@ export interface NodeGroup { } // Provider Types for multi-provider support -export type ProviderType = "gemini" | "replicate" | "fal"; +export type ProviderType = "gemini" | "openai" | "replicate" | "fal"; // Selected model for image/video generation nodes export interface SelectedModel {