Browse Source

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.
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
9cce95fa22
  1. 10
      .env.example
  2. 20
      src/app/api/env-status/route.ts
  3. 5
      src/app/api/generate/route.ts
  4. 24
      src/app/api/llm/route.ts
  5. 82
      src/app/api/models/[modelId]/route.ts
  6. 77
      src/app/api/save-generation/route.ts
  7. 308
      src/components/ProjectSetupModal.tsx
  8. 169
      src/store/workflowStore.ts
  9. 2
      src/types/index.ts

10
.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

20
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);
}

5
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<GenerateResponse>(
{
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 }
);

24
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<string> {
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<string> {
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<LLMGenerateResponse>(

82
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<string, unknown>
): Record<string, unknown> | 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<string, unknown> | undefined;
return resolved || null;
}
/**
* Convert OpenAPI schema property to ModelParameter
*/
function convertSchemaProperty(
name: string,
prop: Record<string, unknown>,
required: string[]
required: string[],
schemaComponents?: Record<string, unknown>
): 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<Record<string, unknown>> | 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<string, unknown>);
// Pass components.schemas for $ref resolution
const schemaComponents = openApiSchema.components?.schemas as Record<string, unknown> | undefined;
return extractParametersFromSchema(inputSchema as Record<string, unknown>, 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<string, unknown> | undefined;
return extractParametersFromSchema(inputSchema, schemaComponents);
}
/**
* Extract ModelParameters and ModelInputs from an OpenAPI schema object
*/
function extractParametersFromSchema(schema: Record<string, unknown>): ExtractedSchema {
function extractParametersFromSchema(
schema: Record<string, unknown>,
schemaComponents?: Record<string, unknown>
): ExtractedSchema {
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined;
const required = (schema.required as string[]) || [];
@ -323,7 +383,7 @@ function extractParametersFromSchema(schema: Record<string, unknown>): Extracted
}
// Otherwise it's a parameter
const param = convertSchemaProperty(name, prop, required);
const param = convertSchemaProperty(name, prop, required, schemaComponents);
if (param) {
parameters.push(param);
}

77
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<string, string> = {
"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', {

308
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>(providerSettings);
const [showApiKey, setShowApiKey] = useState<Record<ProviderType, boolean>>({
gemini: false,
openai: false,
replicate: false,
fal: false,
});
const [overrideActive, setOverrideActive] = useState<Record<ProviderType, boolean>>({
gemini: false,
openai: false,
replicate: false,
fal: false,
});
const [envStatus, setEnvStatus] = useState<EnvStatusResponse | null>(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" && (
<div className="space-y-4">
<div className="space-y-3">
{/* Gemini Provider */}
<div className="p-4 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-100">
{localProviders.providers.gemini.name}
</span>
<span className="px-2 py-0.5 text-xs bg-green-900/50 text-green-400 rounded">
Default
</span>
</div>
<span className="text-xs text-green-400">Enabled</span>
</div>
<p className="text-xs text-neutral-500">
Configured via GEMINI_API_KEY environment variable
</p>
</div>
{/* Replicate Provider */}
<div className="p-4 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-neutral-100">
{localProviders.providers.replicate.name}
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localProviders.providers.replicate.enabled}
onChange={(e) => updateLocalProvider("replicate", { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.replicate.enabled && (
<div className="space-y-2">
<label className="block text-xs text-neutral-400">API Key</label>
<div className="flex gap-2">
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">Google Gemini</span>
{envStatus?.gemini && !overrideActive.gemini ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, gemini: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type={showApiKey.replicate ? "text" : "password"}
value={localProviders.providers.replicate.apiKey || ""}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, replicate: !prev.replicate }))}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 text-sm rounded transition-colors"
onClick={() => setShowApiKey((prev) => ({ ...prev, gemini: !prev.gemini }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{showApiKey.replicate ? "Hide" : "Show"}
{showApiKey.gemini ? "Hide" : "Show"}
</button>
{envStatus?.gemini && (
<button
type="button"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, gemini: false }));
updateLocalProvider("gemini", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
)}
</div>
</div>
)}
)}
</div>
</div>
{/* fal.ai Provider */}
<div className="p-4 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-neutral-100">
{localProviders.providers.fal.name}
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localProviders.providers.fal.enabled}
onChange={(e) => updateLocalProvider("fal", { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.fal.enabled && (
<div className="space-y-2">
<label className="block text-xs text-neutral-400">API Key</label>
<div className="flex gap-2">
{/* OpenAI Provider */}
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">OpenAI</span>
{envStatus?.openai && !overrideActive.openai ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, openai: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type={showApiKey.fal ? "text" : "password"}
value={localProviders.providers.fal.apiKey || ""}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, fal: !prev.fal }))}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 text-sm rounded transition-colors"
onClick={() => setShowApiKey((prev) => ({ ...prev, openai: !prev.openai }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{showApiKey.fal ? "Hide" : "Show"}
{showApiKey.openai ? "Hide" : "Show"}
</button>
{envStatus?.openai && (
<button
type="button"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, openai: false }));
updateLocalProvider("openai", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
)}
</div>
)}
</div>
</div>
{/* Replicate Provider */}
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-100">Replicate</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localProviders.providers.replicate?.enabled || false}
onChange={(e) => updateLocalProvider("replicate", { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-8 h-4 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.replicate?.enabled && (
envStatus?.replicate && !overrideActive.replicate ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, replicate: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type={showApiKey.replicate ? "text" : "password"}
value={localProviders.providers.replicate?.apiKey || ""}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, replicate: !prev.replicate }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{showApiKey.replicate ? "Hide" : "Show"}
</button>
{envStatus?.replicate && (
<button
type="button"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, replicate: false }));
updateLocalProvider("replicate", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
)}
</div>
)
)}
</div>
</div>
{/* fal.ai Provider */}
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-100">fal.ai</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localProviders.providers.fal?.enabled || false}
onChange={(e) => updateLocalProvider("fal", { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-8 h-4 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
)}
{localProviders.providers.fal?.enabled && (
envStatus?.fal && !overrideActive.fal ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, fal: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type={showApiKey.fal ? "text" : "password"}
value={localProviders.providers.fal?.apiKey || ""}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, fal: !prev.fal }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{showApiKey.fal ? "Hide" : "Show"}
</button>
{envStatus?.fal && (
<button
type="button"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, fal: false }));
updateLocalProvider("fal", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
)}
</div>
)
)}
</div>
</div>
<p className="text-xs text-neutral-500">
API keys are stored locally in your browser and never sent to our servers.
<p className="text-xs text-neutral-500 mt-2">
Add API keys via <code className="px-1 py-0.5 bg-neutral-800 rounded">.env.local</code> for better security. Keys added here override .env and are stored in your browser.
</p>
</div>
)}

169
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<WorkflowStore>((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<string, string> = {
"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<WorkflowStore>((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<string, string> = {
"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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((set, get) => ({
try {
const nodeData = node.data as LLMGenerateNodeData;
const providerSettingsState = get().providerSettings;
// Build headers with API keys for LLM providers
const headers: Record<string, string> = {
"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<WorkflowStore>((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<WorkflowStore>((set, get) => ({
error: null,
});
// Build headers with API keys for external providers
// Build headers with API keys for providers
const headers: Record<string, string> = {
"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<WorkflowStore>((set, get) => ({
error: null,
});
const providerSettingsState = get().providerSettings;
// Build headers with API keys for LLM providers
const headers: Record<string, string> = {
"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<WorkflowStore>((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<WorkflowStore>((set, get) => ({
error: null,
});
// Build headers with API keys for external providers
// Build headers with API keys for providers
const headers: Record<string, string> = {
"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<WorkflowStore>((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,

2
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 {

Loading…
Cancel
Save