diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..cc3914c474ac1de4dc7bedde7a971b125c7cda4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Production build outputs +.next/ +out/ +dist/ +build/ + +# Dependencies +node_modules/ + +# Environment variables (IMPORTANT: Never commit API keys!) +.env +.env.local +.env.development +.env.production +.env.test +.env*.local + +# Next.js +next-env.d.ts + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-debug.log* + +# OS / Editor junk +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# TypeScript build info +tsconfig.tsbuildinfo +*.tsbuildinfo + +# Temporary files +*.temp +*.tmp + +# IDE / Editor settings +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Local caches +.cache/ +.turbo/ + +# Debug +.npm/ + +# Vercel +.vercel + +# Package managers +package-lock.json +*.lock +!pnpm-lock.yaml + diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..720ed412915ee1c357c4b3c886cba5ca6e4a6b16 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "nextn", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack -p 9002", + "genkit:dev": "genkit start -- tsx src/ai/dev.ts", + "genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts", + "build": "NODE_ENV=production next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@genkit-ai/google-genai": "^1.20.0", + "@genkit-ai/next": "^1.20.0", + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.6", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.1.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "dotenv": "^16.5.0", + "embla-carousel-react": "^8.6.0", + "firebase": "^11.9.1", + "genkit": "^1.20.0", + "lucide-react": "^0.475.0", + "next": "^15.3.6", + "next-themes": "0.4.0", + "patch-package": "^8.0.0", + "react": "^19.2.1", + "react-day-picker": "^9.11.3", + "react-dom": "^19.2.1", + "react-hook-form": "^7.54.2", + "recharts": "^2.15.1", + "tailwind-merge": "^3.0.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19.2.1", + "@types/react-dom": "^19.2.1", + "autoprefixer": "^10.4.22", + "genkit-cli": "^1.20.0", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..67cdf1a55fc3e666199b19f69367b5ff0b05f1fc --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/ai/dev.ts b/src/ai/dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..b710e3cd419e1c6a9c586f53c26207c95ba5aa8f --- /dev/null +++ b/src/ai/dev.ts @@ -0,0 +1,5 @@ +import { config } from 'dotenv'; +config(); + +import '@/ai/flows/enhance-user-prompt.ts'; +import '@/ai/flows/generate-image.ts'; diff --git a/src/ai/flows/auto-select-image-model.ts b/src/ai/flows/auto-select-image-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..74d9855d648e3a1525a1c63e71fe162b3082bb57 --- /dev/null +++ b/src/ai/flows/auto-select-image-model.ts @@ -0,0 +1,209 @@ +'use server'; + +/** + * @fileOverview Smart routing flow for automatically selecting the best image generation provider. + * + * This flow: + * 1. Checks which API keys are valid/present + * 2. If smart routing is enabled and multiple providers are available, uses AI to select the best one + * 3. Falls back to available providers when some are unavailable + * 4. Returns the selected provider along with available options + */ + +import { ai } from '@/ai/genkit'; +import { z } from 'genkit'; +import { + type ImageProvider, + PROVIDER_CONFIGS +} from '@/lib/api-config'; +import { getAvailableProviders } from '@/lib/api-validation'; + +// Shared provider enum for consistent typing +const ProviderEnum = z.enum(['openai', 'google', 'qwen']); + +// Client API keys schema +const ClientApiKeysSchema = z.object({ + openai: z.string().optional(), + google: z.string().optional(), + qwen: z.string().optional(), +}).optional(); + +// Input schema for the smart router +const SmartRouterInputSchema = z.object({ + prompt: z.string().describe('The user prompt for image generation.'), + smartRoutingEnabled: z.boolean().describe('Whether AI-based smart routing is enabled.'), + preferredProvider: ProviderEnum.optional().describe('User-preferred provider when smart routing is disabled.'), + clientApiKeys: ClientApiKeysSchema.describe('Optional API keys provided by user from client-side.'), +}); +export type SmartRouterInput = z.infer; + +// Output schema for the smart router +const SmartRouterOutputSchema = z.object({ + selectedProvider: ProviderEnum.describe('The selected image generation provider.'), + availableProviders: z.array(ProviderEnum).describe('List of available providers.'), + reason: z.string().describe('Reason for the selection.'), +}); +export type SmartRouterOutput = z.infer; + +// AI prompt for selecting the best provider based on the user's prompt +const selectProviderPrompt = ai.definePrompt({ + name: 'selectProviderPrompt', + input: { + schema: z.object({ + prompt: z.string(), + availableProviders: z.array(z.string()), + }), + }, + output: { + schema: z.object({ + selectedProvider: ProviderEnum, + reason: z.string(), + }), + }, + prompt: `You are an AI assistant that selects the best image generation provider based on a user's prompt. + +Available providers: {{{availableProviders}}} + +Provider strengths: +- openai (DALL-E 3): Best for photorealistic images, product photography, professional headshots, realistic scenes, and detailed realistic artwork. +- google (Gemini): Best for general purpose, quick generation, simple images, icons, and versatile everyday needs. +- qwen (Qwen Image Plus): Best for artistic, creative, stylized, anime, illustration, digital art, fantasy, and visually striking artistic images. + +Analyze the prompt and select the BEST provider from the AVAILABLE providers list. +If a provider is not in the available list, DO NOT select it. + +User's prompt: {{{prompt}}} + +Respond with: +1. selectedProvider: The name of the best provider (must be one from the available list) +2. reason: A brief explanation of why this provider is best for this prompt`, +}); + +/** + * Check if a provider has a valid key either from client or env + */ +function hasValidKey(provider: ImageProvider, clientKeys?: { openai?: string; google?: string; qwen?: string }): boolean { + const clientKey = clientKeys?.[provider]; + if (clientKey && clientKey.trim().length > 0) { + return true; + } + return false; // Server-side env check is done separately +} + +/** + * Smart router for selecting the best image generation provider. + */ +export async function smartSelectProvider(input: SmartRouterInput): Promise { + // 1. Get available providers from environment + const envAvailableProviders = await getAvailableProviders(); + + // 2. Also check client-provided keys + const clientKeys = input.clientApiKeys; + const allProviders: ImageProvider[] = ['openai', 'google', 'qwen']; + + // Combine: provider is available if it has env key OR valid client key + const availableProviders = allProviders.filter(provider => + envAvailableProviders.includes(provider) || hasValidKey(provider, clientKeys) + ); + + // 2. Handle case where no providers are available + if (availableProviders.length === 0) { + return { + selectedProvider: 'google', // Default, but won't work + availableProviders: [], + reason: 'No API keys are configured. Please add at least one API key to your environment.', + }; + } + + // Cast to the enum type for Zod compatibility + const typedAvailableProviders = availableProviders as ('openai' | 'google' | 'qwen')[]; + + // 3. If only one provider is available, use it + if (typedAvailableProviders.length === 1) { + const provider = typedAvailableProviders[0]; + return { + selectedProvider: provider, + availableProviders: typedAvailableProviders, + reason: `Only ${PROVIDER_CONFIGS[provider].displayName} is available.`, + }; + } + + // 4. If smart routing is disabled and user has a preference + if (!input.smartRoutingEnabled && input.preferredProvider) { + // Check if preferred provider is available + if (typedAvailableProviders.includes(input.preferredProvider)) { + return { + selectedProvider: input.preferredProvider, + availableProviders: typedAvailableProviders, + reason: `User selected ${PROVIDER_CONFIGS[input.preferredProvider].displayName}.`, + }; + } else { + // Preferred provider not available, fall back to first available + const fallback = typedAvailableProviders[0]; + return { + selectedProvider: fallback, + availableProviders: typedAvailableProviders, + reason: `${PROVIDER_CONFIGS[input.preferredProvider].displayName} is not available. Falling back to ${PROVIDER_CONFIGS[fallback].displayName}.`, + }; + } + } + + // 5. Smart routing enabled - use AI to select the best provider + try { + const { output } = await selectProviderPrompt({ + prompt: input.prompt, + availableProviders: typedAvailableProviders, + }); + + if (output && typedAvailableProviders.includes(output.selectedProvider)) { + return { + selectedProvider: output.selectedProvider, + availableProviders: typedAvailableProviders, + reason: output.reason, + }; + } + } catch (error) { + console.error('[SmartRouter] AI selection failed, using fallback:', error); + } + + // 6. Fallback: Use keyword matching if AI fails + const prompt = input.prompt.toLowerCase(); + + // Check for photorealistic keywords -> OpenAI + const photorealisticKeywords = ['photo', 'realistic', 'professional', 'headshot', 'product', 'portrait']; + if (typedAvailableProviders.includes('openai') && + photorealisticKeywords.some(kw => prompt.includes(kw))) { + return { + selectedProvider: 'openai', + availableProviders: typedAvailableProviders, + reason: 'Detected photorealistic content in prompt, using OpenAI DALL-E.', + }; + } + + // Check for artistic keywords -> Qwen + const artisticKeywords = ['artistic', 'fantasy', 'abstract', 'stylized', 'watercolor', 'painting', 'illustration', 'anime', 'digital art']; + if (typedAvailableProviders.includes('qwen') && + artisticKeywords.some(kw => prompt.includes(kw))) { + return { + selectedProvider: 'qwen', + availableProviders: typedAvailableProviders, + reason: 'Detected artistic content in prompt, using Qwen Image Plus.', + }; + } + + // Default to Google for general purpose + if (typedAvailableProviders.includes('google')) { + return { + selectedProvider: 'google', + availableProviders: typedAvailableProviders, + reason: 'Using Google Gemini for general purpose image generation.', + }; + } + + // Final fallback - use first available + return { + selectedProvider: typedAvailableProviders[0], + availableProviders: typedAvailableProviders, + reason: `Using ${PROVIDER_CONFIGS[typedAvailableProviders[0]].displayName} as default.`, + }; +} diff --git a/src/ai/flows/enhance-user-prompt.ts b/src/ai/flows/enhance-user-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b3087452a7a38ea9d7756e087402d54dde30cd3 --- /dev/null +++ b/src/ai/flows/enhance-user-prompt.ts @@ -0,0 +1,50 @@ +'use server'; + +/** + * @fileOverview This file defines a Genkit flow for enhancing user-provided prompts using the Gemini AI model. + * + * The flow takes a user prompt as input and returns an enhanced version of the prompt. + * It exports: + * - enhanceUserPrompt: The main function to call to enhance a prompt. + * - EnhanceUserPromptInput: The input type for the enhanceUserPrompt function. + * - EnhanceUserPromptOutput: The output type for the enhanceUserPrompt function. + */ + +import {ai} from '@/ai/genkit'; +import {z} from 'genkit'; + +const EnhanceUserPromptInputSchema = z.object({ + prompt: z.string().describe('The user-provided prompt to enhance.'), +}); +export type EnhanceUserPromptInput = z.infer; + +const EnhanceUserPromptOutputSchema = z.object({ + enhancedPrompt: z.string().describe('The AI-enhanced version of the prompt.'), +}); +export type EnhanceUserPromptOutput = z.infer; + +export async function enhanceUserPrompt(input: EnhanceUserPromptInput): Promise { + return enhanceUserPromptFlow(input); +} + +const enhanceUserPromptPrompt = ai.definePrompt({ + name: 'enhanceUserPromptPrompt', + input: {schema: EnhanceUserPromptInputSchema}, + output: {schema: EnhanceUserPromptOutputSchema}, + prompt: `You are an AI prompt enhancer. You will take the user's prompt and enhance it to be more descriptive and specific, in order to get better results from image generation models. + +User Prompt: {{{prompt}}}`, + model: 'googleai/gemini-2.5-pro', +}); + +const enhanceUserPromptFlow = ai.defineFlow( + { + name: 'enhanceUserPromptFlow', + inputSchema: EnhanceUserPromptInputSchema, + outputSchema: EnhanceUserPromptOutputSchema, + }, + async input => { + const {output} = await enhanceUserPromptPrompt(input); + return output!; + } +); diff --git a/src/ai/flows/generate-image-google.ts b/src/ai/flows/generate-image-google.ts new file mode 100644 index 0000000000000000000000000000000000000000..e969ad027dd06f799e496b73e6631e2bed5d7db2 --- /dev/null +++ b/src/ai/flows/generate-image-google.ts @@ -0,0 +1,57 @@ +'use server'; + +/** + * @fileOverview Google Gemini image generation flow. + * Uses the Genkit AI framework with Google's Gemini model for image generation. + */ + +import { ai } from '@/ai/genkit'; +import { z } from 'genkit'; + +const GenerateImageGoogleInputSchema = z.object({ + prompt: z.string().describe('The text prompt to generate an image from.'), +}); +export type GenerateImageGoogleInput = z.infer; + +const GenerateImageGoogleOutputSchema = z.object({ + url: z.string().describe('The data URI of the generated image.'), + prompt: z.string().describe('The prompt used to generate the image.'), + provider: z.literal('google').describe('The provider used for generation.'), +}); +export type GenerateImageGoogleOutput = z.infer; + +export async function generateImageGoogle(input: GenerateImageGoogleInput): Promise { + return generateImageGoogleFlow(input); +} + +const generateImageGoogleFlow = ai.defineFlow( + { + name: 'generateImageGoogleFlow', + inputSchema: GenerateImageGoogleInputSchema, + outputSchema: GenerateImageGoogleOutputSchema, + }, + async (input) => { + const startTime = Date.now(); + + const { media } = await ai.generate({ + model: 'googleai/gemini-2.0-flash-exp', + prompt: input.prompt, + config: { + responseModalities: ['IMAGE', 'TEXT'], + }, + }); + + const endTime = Date.now(); + console.log(`[Google] Image generation took ${endTime - startTime}ms`); + + if (!media?.url) { + throw new Error('Google image generation failed - no image returned'); + } + + return { + url: media.url, + prompt: input.prompt, + provider: 'google' as const, + }; + } +); diff --git a/src/ai/flows/generate-image-openai.ts b/src/ai/flows/generate-image-openai.ts new file mode 100644 index 0000000000000000000000000000000000000000..270f9315627bc42a9da3ead6605e0486a8cb8b59 --- /dev/null +++ b/src/ai/flows/generate-image-openai.ts @@ -0,0 +1,75 @@ +'use server'; + +/** + * @fileOverview OpenAI DALL-E image generation flow. + * Uses OpenAI's API for photorealistic image generation. + */ + +import { z } from 'zod'; + +const GenerateImageOpenAIInputSchema = z.object({ + prompt: z.string().describe('The text prompt to generate an image from.'), + apiKey: z.string().optional().describe('Optional API key provided by user.'), +}); +export type GenerateImageOpenAIInput = z.infer; + +const GenerateImageOpenAIOutputSchema = z.object({ + url: z.string().describe('The URL or data URI of the generated image.'), + prompt: z.string().describe('The prompt used to generate the image.'), + provider: z.literal('openai').describe('The provider used for generation.'), +}); +export type GenerateImageOpenAIOutput = z.infer; + +export async function generateImageOpenAI(input: GenerateImageOpenAIInput): Promise { + // Use provided API key or fall back to environment variable + const apiKey = input.apiKey || process.env.OPENAI_API_KEY; + + if (!apiKey) { + throw new Error('OpenAI API key is not configured. Please add your API key in Settings.'); + } + + const startTime = Date.now(); + + try { + const response = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'dall-e-3', + prompt: input.prompt, + n: 1, + size: '1024x1024', + response_format: 'b64_json', + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(`OpenAI API error: ${error.error?.message || response.statusText}`); + } + + const data = await response.json(); + const endTime = Date.now(); + console.log(`[OpenAI] Image generation took ${endTime - startTime}ms`); + + if (!data.data?.[0]?.b64_json) { + throw new Error('OpenAI image generation failed - no image returned'); + } + + // Convert base64 to data URI + const base64Image = data.data[0].b64_json; + const dataUri = `data:image/png;base64,${base64Image}`; + + return { + url: dataUri, + prompt: input.prompt, + provider: 'openai' as const, + }; + } catch (error) { + console.error('[OpenAI] Generation error:', error); + throw error; + } +} diff --git a/src/ai/flows/generate-image-qwen.ts b/src/ai/flows/generate-image-qwen.ts new file mode 100644 index 0000000000000000000000000000000000000000..d176cd6d5646cdfd7009bae687755a478a08330f --- /dev/null +++ b/src/ai/flows/generate-image-qwen.ts @@ -0,0 +1,104 @@ +'use server'; + +/** + * @fileOverview Qwen Image Plus image generation flow. + * Uses Alibaba DashScope's OpenAI-compatible API for artistic image generation. + */ + +import { z } from 'zod'; + +const GenerateImageQwenInputSchema = z.object({ + prompt: z.string().describe('The text prompt to generate an image from.'), + apiKey: z.string().optional().describe('Optional API key provided by user.'), +}); +export type GenerateImageQwenInput = z.infer; + +const GenerateImageQwenOutputSchema = z.object({ + url: z.string().describe('The data URI of the generated image.'), + prompt: z.string().describe('The prompt used to generate the image.'), + provider: z.literal('qwen').describe('The provider used for generation.'), +}); +export type GenerateImageQwenOutput = z.infer; + +/** + * Qwen Image Plus image generation using DashScope's OpenAI-compatible API. + */ +export async function generateImageQwen(input: GenerateImageQwenInput): Promise { + // Use provided API key or fall back to environment variable + const apiKey = input.apiKey || process.env.DASHSCOPE_API_KEY; + + if (!apiKey) { + throw new Error('DashScope API key is not configured. Please add your API key in Settings.'); + } + + const startTime = Date.now(); + + try { + const response = await fetch('https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'qwen-image-plus', + messages: [ + { + role: 'user', + content: `Generate an image: ${input.prompt}`, + }, + ], + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(`Qwen API error: ${error.error?.message || response.statusText}`); + } + + const data = await response.json(); + const endTime = Date.now(); + console.log(`[Qwen] Image generation took ${endTime - startTime}ms`); + + // Extract image from the response + // Qwen returns the image in the message content + const content = data.choices?.[0]?.message?.content; + + if (!content) { + throw new Error('Qwen image generation failed - no content returned'); + } + + // Check if response contains an image URL or base64 data + let imageUrl: string; + + // Try to extract image URL from markdown format ![](url) or direct URL + const urlMatch = content.match(/!\[.*?\]\((.*?)\)|https?:\/\/[^\s\)]+\.(png|jpg|jpeg|gif|webp)/i); + if (urlMatch) { + imageUrl = urlMatch[1] || urlMatch[0]; + } else if (content.startsWith('data:image')) { + // Already a data URI + imageUrl = content; + } else if (content.match(/^[A-Za-z0-9+/=]+$/)) { + // Looks like base64 + imageUrl = `data:image/png;base64,${content}`; + } else { + // Try to find any base64 image data in the content + const base64Match = content.match(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/); + if (base64Match) { + imageUrl = base64Match[0]; + } else { + console.error('[Qwen] Unexpected response format:', content.substring(0, 200)); + throw new Error('Qwen image generation failed - could not extract image from response'); + } + } + + return { + url: imageUrl, + prompt: input.prompt, + provider: 'qwen' as const, + }; + } catch (error) { + console.error('[Qwen] Generation error:', error); + throw error; + } +} diff --git a/src/ai/flows/generate-image.ts b/src/ai/flows/generate-image.ts new file mode 100644 index 0000000000000000000000000000000000000000..c207834535c2d88a2b2caf46ce5096153722a4b2 --- /dev/null +++ b/src/ai/flows/generate-image.ts @@ -0,0 +1,61 @@ +'use server'; + +/** + * @fileOverview Unified image generation flow that routes to the appropriate provider. + * + * This is the main entry point for image generation. It accepts a provider parameter + * and routes the request to the appropriate provider-specific flow. + */ + +import { type ImageProvider } from '@/lib/api-config'; +import { generateImageGoogle } from './generate-image-google'; +import { generateImageOpenAI } from './generate-image-openai'; +import { generateImageQwen } from './generate-image-qwen'; + +export interface ApiKeysInput { + openai?: string; + google?: string; + qwen?: string; +} + +export interface GenerateImageInput { + prompt: string; + provider?: ImageProvider; + apiKeys?: ApiKeysInput; +} + +export interface GenerateImageOutput { + url: string; + prompt: string; + provider: ImageProvider; +} + +/** + * Generate an image using the specified provider. + * Defaults to 'google' if no provider is specified. + */ +export async function generateImage(input: GenerateImageInput): Promise { + const provider = input.provider || 'google'; + + console.log(`[GenerateImage] Using provider: ${provider}`); + + switch (provider) { + case 'openai': + return generateImageOpenAI({ + prompt: input.prompt, + apiKey: input.apiKeys?.openai, + }); + + case 'qwen': + return generateImageQwen({ + prompt: input.prompt, + apiKey: input.apiKeys?.qwen, + }); + + case 'google': + default: + // Google uses Genkit which reads from process.env directly + // Client-provided keys for Google would require a different approach + return generateImageGoogle({ prompt: input.prompt }); + } +} diff --git a/src/ai/genkit.ts b/src/ai/genkit.ts new file mode 100644 index 0000000000000000000000000000000000000000..8811f2c5635fb76162328f50791a4b8f08c664c8 --- /dev/null +++ b/src/ai/genkit.ts @@ -0,0 +1,7 @@ +import {genkit} from 'genkit'; +import {googleAI} from '@genkit-ai/google-genai'; + +export const ai = genkit({ + plugins: [googleAI()], + model: 'googleai/gemini-2.5-flash', +}); diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9621e46d3413d55917e344b844a6605e8d9359ac Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..c943a508fbc9bbeceec3cb090fda24c69f5c1024 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 0 0% 94.1%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 275 100% 25.5%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 240 10% 3.9%; + --muted: 0 0% 96.1%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 274 100% 50%; + --accent-foreground: 0 0% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 275 100% 25.5%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 240 6% 10%; + --foreground: 0 0% 98%; + --card: 240 6% 12%; + --card-foreground: 0 0% 98%; + --popover: 240 6% 10%; + --popover-foreground: 0 0% 98%; + --primary: 275 100% 70%; + --primary-foreground: 275 100% 15%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 274 100% 65%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 274 100% 65%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2faa93b0f0283d0dae4be9ba4467f94c83d1c6a1 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { ThemeProvider } from "@/providers/theme-provider"; +import { ApiKeysProvider } from "@/contexts/api-keys-context"; +import { Toaster } from "@/components/ui/toaster"; + +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); + +export const metadata: Metadata = { + title: "ImageForge AI", + description: "Generate amazing images with the power of multiple AI models.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + + + + {children} + + + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0d65c23fe8312c285668a06fc67db97004f85eb --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,167 @@ +'use client'; + +import * as React from 'react'; +import { AppHeader } from '@/components/header'; +import { HistorySidebar } from '@/components/history-sidebar'; +import { ImageGallery, type GeneratedImage } from '@/components/image-gallery'; +import { PromptForm } from '@/components/prompt-form'; +import { + SidebarInset, + SidebarProvider, +} from '@/components/ui/sidebar'; +import { useToast } from '@/hooks/use-toast'; +import { generateImage } from '@/ai/flows/generate-image'; +import { enhanceUserPrompt } from '@/ai/flows/enhance-user-prompt'; +import { smartSelectProvider } from '@/ai/flows/auto-select-image-model'; +import { PROVIDER_CONFIGS, type ImageProvider } from '@/lib/api-config'; +import { useApiKeys } from '@/contexts/api-keys-context'; + +export default function DashboardPage() { + const { toast } = useToast(); + const { apiKeys, setIsOpen: setSettingsOpen } = useApiKeys(); + const [isGenerating, setIsGenerating] = React.useState(false); + const [numImagesToGenerate, setNumImagesToGenerate] = React.useState(1); + const [generatedImages, setGeneratedImages] = React.useState< + GeneratedImage[] + >([]); + const [history, setHistory] = React.useState([]); + + const handleGenerate = async (values: any) => { + setIsGenerating(true); + setGeneratedImages([]); + setNumImagesToGenerate(values.numImages); + const startTime = Date.now(); + + try { + // 1. Smart route to select the best provider + const routerResult = await smartSelectProvider({ + prompt: values.prompt, + smartRoutingEnabled: values.smartRouting, + preferredProvider: values.smartRouting ? undefined : values.model, + clientApiKeys: { + openai: apiKeys.openai || undefined, + google: apiKeys.google || undefined, + qwen: apiKeys.qwen || undefined, + }, + }); + + console.log('[Page] Router result:', routerResult); + + // 2. Check if any providers are available + if (routerResult.availableProviders.length === 0) { + toast({ + variant: 'destructive', + title: 'No API Keys Available', + description: 'Please configure at least one API key in Settings to generate images.', + action: ( + + ), + }); + setIsGenerating(false); + return; + } + + // 3. Show which provider is being used + const providerName = PROVIDER_CONFIGS[routerResult.selectedProvider].displayName; + toast({ + title: `Using ${providerName}`, + description: routerResult.reason, + }); + + // 4. Enhance prompt + let generationPrompt = values.prompt; + try { + const enhanced = await enhanceUserPrompt({ prompt: values.prompt }); + generationPrompt = enhanced.enhancedPrompt; + } catch (enhanceError) { + console.warn('[Page] Prompt enhancement failed, using original prompt:', enhanceError); + // Continue with original prompt if enhancement fails + } + + // 5. Generate images with selected provider, passing client API keys + const selectedProvider = routerResult.selectedProvider; + + const generationPromises = Array.from( + { length: values.numImages }, + () => generateImage({ + prompt: generationPrompt, + provider: selectedProvider, + apiKeys: { + openai: apiKeys.openai || undefined, + google: apiKeys.google || undefined, + qwen: apiKeys.qwen || undefined, + }, + }) + ); + + const results = await Promise.all(generationPromises); + + const newImages: GeneratedImage[] = results.map((result, i) => ({ + id: `gen_${Date.now()}_${i}`, + url: result.url, + prompt: generationPrompt, + model: PROVIDER_CONFIGS[result.provider].displayName, + createdAt: new Date().toISOString(), + generationTime: (Date.now() - startTime) / 1000, + isFavorite: false, + })); + + setGeneratedImages(newImages); + setHistory(prev => [...newImages, ...prev]); + + toast({ + title: 'Generation Complete!', + description: `${values.numImages} image(s) generated using ${providerName}.`, + }); + } catch (error) { + console.error('[Page] Generation error:', error); + toast({ + variant: 'destructive', + title: 'Generation Failed', + description: + error instanceof Error + ? error.message + : 'An error occurred while generating images. Please try again.', + }); + } finally { + setIsGenerating(false); + } + }; + + return ( + +
+ + + +
+
+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33cf80146641dcd05d92cf80db8200bf6c1f9fd4 --- /dev/null +++ b/src/components/header.tsx @@ -0,0 +1,22 @@ +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { ThemeToggle } from "./theme-toggle"; +import { UserNav } from "./user-nav"; +import { Icons } from "./icons"; +import { SettingsPanel } from "./settings-panel"; + +export function AppHeader() { + return ( +
+ +
+ +

ImageForge AI

+
+
+ + + +
+
+ ); +} diff --git a/src/components/history-sidebar.tsx b/src/components/history-sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f9fc8c5a52282dd0a29c2fec938ce7576e6afebb --- /dev/null +++ b/src/components/history-sidebar.tsx @@ -0,0 +1,63 @@ +import { History } from "lucide-react" +import Image from "next/image" +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarFooter, + SidebarSeparator, +} from "@/components/ui/sidebar" +import { Button } from "./ui/button" +import { GeneratedImage } from "./image-gallery" + +interface HistorySidebarProps { + history: GeneratedImage[]; +} + +export function HistorySidebar({ history }: HistorySidebarProps) { + return ( + + +
+ +

History

+
+
+ + + + {history.map((item) => ( + + + {item.prompt} +
+ + {item.prompt} + + + {item.model} + +
+
+
+ ))} +
+
+ + + +
+ ) +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22ad7a7bc7d488bf3057c6f4d81b58127ccd0676 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,53 @@ +import { type LucideProps } from "lucide-react"; + +export const Icons = { + logo: (props: LucideProps) => ( + + + + ), + openai: (props: LucideProps) => ( + + + + ), + qwen: (props: LucideProps) => ( + + + + ), + google: (props: LucideProps) => ( + + + + + + + ), +}; diff --git a/src/components/image-card.tsx b/src/components/image-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d47cd81ab9865c82d44679ce705691481bbedcc --- /dev/null +++ b/src/components/image-card.tsx @@ -0,0 +1,94 @@ +"use client" + +import { + Copy, + Download, + Heart, + MoreVertical, + RefreshCw, +} from "lucide-react" +import Image from "next/image" + +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { type GeneratedImage } from "./image-gallery" +import { cn } from "@/lib/utils" +import { useState } from "react" + +interface ImageCardProps { + image: GeneratedImage +} + +export function ImageCard({ image }: ImageCardProps) { + const [isFavorite, setIsFavorite] = useState(image.isFavorite); + + const toggleFavorite = () => { + setIsFavorite(!isFavorite); + } + + return ( + + + {image.prompt} +
+
+ + + + + + + + + + Copy URL + + + + Regenerate + + + +
+
+

+ {image.model} · {image.generationTime.toFixed(1)}s +

+

{image.prompt}

+
+ + + ) +} diff --git a/src/components/image-gallery.tsx b/src/components/image-gallery.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b405842c2f76bd46b35cb6c0c7f6e5a9b245bdda --- /dev/null +++ b/src/components/image-gallery.tsx @@ -0,0 +1,55 @@ +import { ImageOff } from "lucide-react" +import { Card, CardContent } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { ImageCard } from "./image-card" + +export interface GeneratedImage { + id: string + url: string + prompt: string + model: string + createdAt: string + generationTime: number + isFavorite: boolean + data_ai_hint?: string +} + +interface ImageGalleryProps { + images: GeneratedImage[] + isGenerating: boolean + numImages: number +} + +export function ImageGallery({ + images, + isGenerating, + numImages, +}: ImageGalleryProps) { + return ( + + + {isGenerating && images.length === 0 ? ( +
+ {Array.from({ length: numImages }).map((_, i) => ( + + ))} +
+ ) : images.length > 0 ? ( +
+ {images.map((image) => ( + + ))} +
+ ) : ( +
+ +

No Images Yet

+

+ Your generated images will appear here. +

+
+ )} +
+
+ ) +} diff --git a/src/components/model-card.tsx b/src/components/model-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e97d69c5851903dd66d78920753072b27d4988e1 --- /dev/null +++ b/src/components/model-card.tsx @@ -0,0 +1,47 @@ +"use client" + +import { CheckCircle } from "lucide-react" +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" +import { Icons } from "./icons" + +interface ModelCardProps { + model: "openai" | "qwen" | "googleNanoBanana" + useCase: string + isSelected: boolean + onClick: () => void +} + +export function ModelCard({ + model, + useCase, + isSelected, + onClick, +}: ModelCardProps) { + const modelDetails = { + openai: { name: "OpenAI", icon: Icons.openai }, + qwen: { name: "Qwen", icon: Icons.qwen }, + googleNanoBanana: { name: "Google", icon: Icons.google }, + } + + const { name, icon: Icon } = modelDetails[model] + + return ( + + + {isSelected && ( + + )} + +

{name}

+

{useCase}

+
+
+ ) +} diff --git a/src/components/prompt-form.tsx b/src/components/prompt-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e3cfc47a8aae49f4e3322fe5ee4b7db10c2e6f59 --- /dev/null +++ b/src/components/prompt-form.tsx @@ -0,0 +1,242 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { Sparkles, Wand2 } from "lucide-react" +import { useForm } from "react-hook-form" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" +import { ModelCard } from "./model-card" + +const formSchema = z.object({ + prompt: z.string().min(1, "Prompt cannot be empty.").max(2000), + smartRouting: z.boolean(), + model: z.enum(["openai", "qwen", "googleNanoBanana"]), + aspectRatio: z.enum(["1:1", "16:9", "9:16"]), + numImages: z.number().min(1).max(4), +}) + +type PromptFormValues = z.infer + +interface PromptFormProps { + onSubmit: (values: PromptFormValues) => void + isGenerating: boolean +} + +export function PromptForm({ onSubmit, isGenerating }: PromptFormProps) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + prompt: "", + smartRouting: true, + model: "openai", + aspectRatio: "1:1", + numImages: 1, + }, + }) + + const watchPrompt = form.watch("prompt") + const watchSmartRouting = form.watch("smartRouting") + + const handleEnhancePrompt = () => { + const currentPrompt = form.getValues("prompt") + if (currentPrompt) { + // Simulate API call to enhance prompt + const enhancedPrompt = `${currentPrompt}, cinematic lighting, hyper-detailed, 8k resolution` + form.setValue("prompt", enhancedPrompt, { shouldValidate: true }) + } + } + + return ( +
+ +
+ ( + + Prompt + +