From 801f103c2ade8bb91df5af0ec53595a8a2812146 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Wed, 23 Jul 2025 00:00:06 +0545 Subject: [PATCH] style(nextjs): changes home llm selection layout --- servers/nextjs/app/globals.css | 20 +- servers/nextjs/app/settings/SettingPage.tsx | 1013 ++------------- servers/nextjs/app/storeInitializer.tsx | 12 +- servers/nextjs/components/CustomConfig.tsx | 183 +++ servers/nextjs/components/GoogleConfig.tsx | 27 + servers/nextjs/components/Home.tsx | 1221 +++---------------- servers/nextjs/components/LLMSelection.tsx | 405 ++++++ servers/nextjs/components/OllamaConfig.tsx | 226 ++++ servers/nextjs/components/OpenAIConfig.tsx | 27 + servers/nextjs/components/ui/sonner.tsx | 2 - servers/nextjs/package-lock.json | 12 +- servers/nextjs/package.json | 2 +- servers/nextjs/utils/providerConstants.ts | 91 ++ servers/nextjs/utils/providerUtils.ts | 284 +++++ servers/nextjs/utils/storeHelpers.ts | 17 +- 15 files changed, 1540 insertions(+), 2002 deletions(-) create mode 100644 servers/nextjs/components/CustomConfig.tsx create mode 100644 servers/nextjs/components/GoogleConfig.tsx create mode 100644 servers/nextjs/components/LLMSelection.tsx create mode 100644 servers/nextjs/components/OllamaConfig.tsx create mode 100644 servers/nextjs/components/OpenAIConfig.tsx create mode 100644 servers/nextjs/utils/providerConstants.ts create mode 100644 servers/nextjs/utils/providerUtils.ts diff --git a/servers/nextjs/app/globals.css b/servers/nextjs/app/globals.css index 49e38ed9..7a5cb946 100644 --- a/servers/nextjs/app/globals.css +++ b/servers/nextjs/app/globals.css @@ -147,23 +147,35 @@ thead { .custom_scrollbar::-webkit-scrollbar { - width: 8px; + width: 6px; } .custom_scrollbar::-webkit-scrollbar-thumb { - background-color: #4A90E2; - border-radius: 6px; + background-color: #d1d5db; + border-radius: 8px; cursor: pointer; + transition: background-color 0.2s ease; +} + +.custom_scrollbar::-webkit-scrollbar-thumb:hover { + background-color: #9ca3af; } .custom_scrollbar::-webkit-scrollbar-track { - background-color: #F0F0F0; + background-color: #f9fafb; + border-radius: 8px; } .custom_scrollbar::-webkit-scrollbar-corner { background-color: transparent; } +/* Firefox scrollbar styling */ +.custom_scrollbar { + scrollbar-width: thin; + scrollbar-color: #d1d5db #f9fafb; +} + /* word animation */ diff --git a/servers/nextjs/app/settings/SettingPage.tsx b/servers/nextjs/app/settings/SettingPage.tsx index d70d90a7..b9538fbe 100644 --- a/servers/nextjs/app/settings/SettingPage.tsx +++ b/servers/nextjs/app/settings/SettingPage.tsx @@ -2,162 +2,77 @@ import React, { useState, useEffect } from "react"; import Header from "../dashboard/components/Header"; import Wrapper from "@/components/Wrapper"; -import { Settings, Key, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { Settings, Key, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { RootState } from "@/store/store"; import { useSelector } from "react-redux"; import { handleSaveLLMConfig } from "@/utils/storeHelpers"; +import { + checkIfSelectedOllamaModelIsPulled, + pullOllamaModel, + LLMConfig +} from "@/utils/providerUtils"; import { useRouter } from "next/navigation"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; -import { Switch } from "@/components/ui/switch"; +import LLMProviderSelection from "@/components/LLMSelection"; -const IMAGE_PROVIDERS: Record = { - pexels: { - title: "pexels", - description: "Required for using Pexels services", - placeholder: "Enter your Pexels API key", - apiKeyField: "PEXELS_API_KEY", - }, - pixabay: { - title: "pixabay", - description: "Required for using Pixabay services", - placeholder: "Enter your Pixabay API key", - apiKeyField: "PIXABAY_API_KEY", - }, - "dall-e-3": { - title: "dall-e-3", - description: "Required for using DALL-E 3 image generation OpenAI services", - placeholder: "Enter your OpenAI API key", - apiKeyField: "OPENAI_API_KEY", - }, - gemini_flash: { - title: "gemini_flash", - description: "Required for using Gemini Flash services from Google", - placeholder: "Enter your Google API key", - apiKeyField: "GOOGLE_API_KEY", - }, -}; - -const PROVIDER_CONFIGS: Record = { - openai: { - title: "OpenAI API Key", - description: "Required for using OpenAI services", - placeholder: "Enter your OpenAI API key", - }, - google: { - title: "Google API Key", - description: "Required for using Google services", - placeholder: "Enter your Google API key", - }, - ollama: { - title: "Ollama API Key", - description: "Required for using Ollama services", - placeholder: "Choose a model", - }, - custom: { - title: "Custom Model Configuration", - description: "Configure your own OpenAI-compatible model", - placeholder: "Enter your custom model details", - }, -}; - -interface ProviderConfig { - title: string; - description: string; - placeholder: string; -} -interface ImageProviderConfig { - title: string; - description: string; - placeholder: string; - apiKeyField?: keyof LLMConfig; +// Button state interface +interface ButtonState { + isLoading: boolean; + isDisabled: boolean; + text: string; + showProgress: boolean; + progressPercentage?: number; + status?: string; } const SettingsPage = () => { const router = useRouter(); - const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false); - const userConfigState = useSelector((state: RootState) => state.userConfig); - const [llmConfig, setLlmConfig] = useState(userConfigState.llm_config); + const [llmConfig, setLlmConfig] = useState(userConfigState.llm_config); const canChangeKeys = userConfigState.can_change_keys; - const [ollamaModels, setOllamaModels] = useState< - { - label: string; - value: string; - description: string; - size: string; - icon: string; - }[] - >([]); - const [customModels, setCustomModels] = useState([]); - const [downloadingModel, setDownloadingModel] = useState({ - name: "", - size: null, - downloaded: null, - status: "", - done: false, - }); const [isLoading, setIsLoading] = useState(false); - const [openModelSelect, setOpenModelSelect] = useState(false); - const [useCustomOllamaUrl, setUseCustomOllamaUrl] = useState( - userConfigState.llm_config.USE_CUSTOM_URL || false - ); - const [customModelsLoading, setCustomModelsLoading] = - useState(false); - const [customModelsChecked, setCustomModelsChecked] = - useState(false); + const [buttonState, setButtonState] = useState({ + isLoading: false, + isDisabled: false, + text: "Save Configuration", + showProgress: false + }); - const input_field_changed = (new_value: string, field: string) => { - if (field === "openai_api_key") { - setLlmConfig({ ...llmConfig, OPENAI_API_KEY: new_value }); - } else if (field === "google_api_key") { - setLlmConfig({ ...llmConfig, GOOGLE_API_KEY: new_value }); - } else if (field === "ollama_url") { - setLlmConfig({ ...llmConfig, OLLAMA_URL: new_value }); - } else if (field === "ollama_model") { - setLlmConfig({ ...llmConfig, OLLAMA_MODEL: new_value }); - } else if (field === "custom_llm_url") { - setLlmConfig({ ...llmConfig, CUSTOM_LLM_URL: new_value }); - } else if (field === "custom_llm_api_key") { - setLlmConfig({ ...llmConfig, CUSTOM_LLM_API_KEY: new_value }); - } else if (field === "custom_model") { - setLlmConfig({ ...llmConfig, CUSTOM_MODEL: new_value }); - } else if (field === "pexels_api_key") { - setLlmConfig({ ...llmConfig, PEXELS_API_KEY: new_value }); - } else if (field === "pixabay_api_key") { - setLlmConfig({ ...llmConfig, PIXABAY_API_KEY: new_value }); - } - }; + const [downloadingModel, setDownloadingModel] = useState<{ + name: string; + size: number | null; + downloaded: number | null; + status: string; + done: boolean; + } | null>(null); const handleSaveConfig = async () => { try { + setIsLoading(true); + setButtonState(prev => ({ + ...prev, + isLoading: true, + isDisabled: true, + text: "Saving Configuration..." + })); + await handleSaveLLMConfig(llmConfig); - if (llmConfig.LLM === "ollama") { - setIsLoading(true); - await pullOllamaModels(); + + if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) { + const isPulled = await checkIfSelectedOllamaModelIsPulled(llmConfig.OLLAMA_MODEL); + if (!isPulled) { + await handleModelDownload(); + } } + toast.success("Configuration saved successfully"); setIsLoading(false); + setButtonState(prev => ({ + ...prev, + isLoading: false, + isDisabled: false, + text: "Save Configuration" + })); router.back(); } catch (error) { console.error("Error:", error); @@ -167,177 +82,50 @@ const SettingsPage = () => { : "Failed to save configuration" ); setIsLoading(false); + setButtonState(prev => ({ + ...prev, + isLoading: false, + isDisabled: false, + text: "Save Configuration" + })); } }; - const fetchOllamaModelsWithConfig = async (config: any) => { + const handleModelDownload = async () => { try { - const response = await fetch("/api/v1/ppt/ollama/models/supported"); - const models = await response.json(); - setOllamaModels(models); - - // Check if currently selected model is still available - if (config.OLLAMA_MODEL && models.length > 0) { - const isModelAvailable = models.some( - (model: any) => model.value === config.OLLAMA_MODEL - ); - if (!isModelAvailable) { - setLlmConfig({ ...config, OLLAMA_MODEL: "" }); - } - } + await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel); } catch (error) { - console.error("Error fetching ollama models:", error); + console.error("Error downloading model:", error); + setDownloadingModel(null); } }; - const changeProvider = (provider: string) => { - const newConfig = { ...llmConfig, LLM: provider }; - setLlmConfig(newConfig); - if (provider === "ollama") { - // Use the new config to avoid stale state issues - fetchOllamaModelsWithConfig(newConfig); - } - }; - - const resetDownloadingModel = () => { - setDownloadingModel({ - name: "", - size: null, - downloaded: null, - status: "", - done: false, - }); - }; - - const pullOllamaModels = async (): Promise => { - return new Promise((resolve, reject) => { - const interval = setInterval(async () => { - try { - const response = await fetch( - `/api/v1/ppt/ollama/model/pull?model=${llmConfig.OLLAMA_MODEL}` - ); - if (response.status === 200) { - const data = await response.json(); - if (data.done && data.status !== "error") { - clearInterval(interval); - setDownloadingModel(data); - resolve(); - } else if (data.status === "error") { - clearInterval(interval); - resetDownloadingModel(); - reject(new Error("Error occurred while pulling model")); - } else { - setDownloadingModel(data); - } - } else { - clearInterval(interval); - resetDownloadingModel(); - if (response.status === 403) { - reject(new Error("Request to Ollama Not Authorized")); - } - reject(new Error("Error occurred while pulling model")); - } - } catch (error) { - clearInterval(interval); - resetDownloadingModel(); - reject(error); - } - }, 1000); - }); - }; - - const fetchOllamaModels = async () => { - await fetchOllamaModelsWithConfig(llmConfig); - }; - - const fetchCustomModels = async () => { - try { - setCustomModelsLoading(true); - const response = await fetch("/api/v1/ppt/custom_llm/models/available", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: llmConfig.CUSTOM_LLM_URL || "", - api_key: llmConfig.CUSTOM_LLM_API_KEY || "", - }), + useEffect(() => { + if (downloadingModel && downloadingModel.downloaded !== null && downloadingModel.size !== null) { + const percentage = Math.round(((downloadingModel.downloaded / downloadingModel.size) * 100)); + setButtonState({ + isLoading: true, + isDisabled: true, + text: `Downloading Model (${percentage}%)`, + showProgress: true, + progressPercentage: percentage, + status: downloadingModel.status }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - setCustomModels(data); - // Only set customModelsChecked to true if the API call succeeds - setCustomModelsChecked(true); - - // Check if currently selected model is still available - if (llmConfig.CUSTOM_MODEL && data.length > 0) { - const isModelAvailable = data.includes(llmConfig.CUSTOM_MODEL); - if (!isModelAvailable) { - setLlmConfig({ ...llmConfig, CUSTOM_MODEL: "" }); - toast.error( - `The selected model "${llmConfig.CUSTOM_MODEL}" is no longer available. Please select a different model.` - ); - } - } - } catch (error) { - console.error("Error fetching custom models:", error); - // Don't set customModelsChecked to true on error, so the button remains visible - setCustomModels([]); - toast.error( - "Failed to fetch available models. Please check your URL and API key." - ); - } finally { - setCustomModelsLoading(false); } - }; - const setOllamaConfig = () => { - if (!useCustomOllamaUrl) { - setLlmConfig({ - ...llmConfig, - OLLAMA_URL: "http://localhost:11434", - USE_CUSTOM_URL: false, - }); - } else { - setLlmConfig({ ...llmConfig, USE_CUSTOM_URL: true }); + if (downloadingModel && downloadingModel.done) { + setTimeout(() => { + setDownloadingModel(null); + toast.success("Model downloaded successfully!"); + }, 2000); } - }; - - const onCustomModelInfoChange = (value: string, field: string) => { - setCustomModels([]); - setCustomModelsChecked(false); - setLlmConfig({ - ...llmConfig, - CUSTOM_MODEL: "", - CUSTOM_LLM_URL: - field === "custom_llm_url" ? value : llmConfig.CUSTOM_LLM_URL, - CUSTOM_LLM_API_KEY: - field === "custom_llm_api_key" ? value : llmConfig.CUSTOM_LLM_API_KEY, - }); - }; + }, [downloadingModel]); useEffect(() => { if (!canChangeKeys) { router.push("/dashboard"); } - if (userConfigState.llm_config.LLM === "ollama") { - fetchOllamaModels(); - } else if ( - userConfigState.llm_config.LLM === "custom" && - userConfigState.llm_config.CUSTOM_MODEL && - userConfigState.llm_config.CUSTOM_LLM_URL - ) { - fetchCustomModels(); - } - }, [userConfigState.llm_config.LLM]); - - useEffect(() => { - setOllamaConfig(); - }, [useCustomOllamaUrl]); + }, [canChangeKeys, router]); if (!canChangeKeys) { return null; @@ -363,656 +151,37 @@ const SettingsPage = () => { - {/* Provider Selection */} -
- -
- {Object.keys(PROVIDER_CONFIGS).map((provider) => ( - - ))} -
+ {/* LLM Selection Component */} +
+
- {/* API Key Input */} - {llmConfig.LLM !== "ollama" && llmConfig.LLM !== "custom" && ( -
-
- -
- - input_field_changed( - e.target.value, - llmConfig.LLM === "openai" - ? "openai_api_key" - : "google_api_key" - ) - } - className="w-full px-4 py-2.5 border border-gray-300 outline-none rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors" - placeholder={PROVIDER_CONFIGS[llmConfig.LLM!].placeholder} - /> -
-

- {PROVIDER_CONFIGS[llmConfig.LLM!].description} -

-
-
- )} - - {/* Ollama Configuration */} - {llmConfig.LLM === "ollama" && ( -
-
- -
- {ollamaModels.length > 0 ? ( - - - - - - - - - No model found. - - {ollamaModels.map((model, index) => ( - { - setLlmConfig({ - ...llmConfig, - OLLAMA_MODEL: value, - }); - setOpenModelSelect(false); - }} - > - -
-
- {`${model.label} -
-
-
- - {model.label} - - - {model.size} - -
- - {model.description} - -
-
-
- ))} -
-
-
-
-
- ) : ( -
-
-
-
-
-
-
-
-
- )} -
- {ollamaModels.length === 0 && ( -

- Loading available models... -

- )} -
- - {/* Custom Ollama URL Configuration */} -
-
- - -
- {useCustomOllamaUrl && ( - <> -
- -
- - input_field_changed(e.target.value, "ollama_url") - } - /> -
-

- - Change this if you are using a custom Ollama instance -

-
- - )} -
- -
- -
- - input_field_changed(e.target.value, "pexels_api_key") - } - /> -
-

- Provide a Pexels API key to generate presentation images -

-
-
- )} - - {/* Custom Model Configuration */} - {llmConfig.LLM === "custom" && ( -
-
- -
- - onCustomModelInfoChange( - e.target.value, - "custom_llm_url" - ) - } - /> -
-
- -
- -
- - onCustomModelInfoChange( - e.target.value, - "custom_llm_api_key" - ) - } - /> -
-
- - {/* Model selection dropdown - show if models are available or if there's a selected model */} - {((customModelsChecked && customModels.length > 0) || - llmConfig.CUSTOM_MODEL) && ( -
-
-

- Important: Only models with function - calling capabilities (tool calls) or JSON schema support - will work. -

-
- -
- - - - - - - - - No model found. - - {customModels.map((model, index) => ( - { - setLlmConfig({ - ...llmConfig, - CUSTOM_MODEL: value, - }); - setOpenModelSelect(false); - }} - > - - - {model} - - - ))} - - - - - -
-
- )} - - {/* Check for available models button - show when no models checked or no models found, and no model is selected */} - {(!customModelsChecked || - (customModelsChecked && customModels.length === 0)) && - !llmConfig.CUSTOM_MODEL && ( -
- -
- )} - - {/* Show message if no models found */} - {customModelsChecked && customModels.length === 0 && ( -
-

- No models found. Please make sure models are available. -

-
- )} - - {/* Refresh models button - show when there's a selected model but we want to refresh */} - {llmConfig.CUSTOM_MODEL && customModelsChecked && ( -
- -
- )} - -
- -
- - input_field_changed(e.target.value, "pexels_api_key") - } - /> -
-

- Provide a Pexels API key to generate presentation images -

-
-
- )} - - {/* Image Provider Selection */} -
- -
- - - - - - - - - No provider found. - - {Object.values(IMAGE_PROVIDERS).map( - (provider, index) => ( - { - console.log("Image Provider", value); - setLlmConfig({ - ...llmConfig, - IMAGE_PROVIDER: value, - }); - console.log("LLM config", llmConfig); - setOpenImageProviderSelect(false); - }} - > - -
-
-
- - {provider.title} - -
- - {provider.description} - -
-
-
- ) - )} -
-
-
-
-
-
-
- {/* Dynamic API Key Input for Image Provider */} - {llmConfig.IMAGE_PROVIDER && - IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] && - (() => { - const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]; - // Show info message when using same API key as main provider - if ( - provider.title === "dall-e-3" && - llmConfig.LLM === "openai" - ) { - return <>; - } - - if (provider.title === "gemini_flash" && llmConfig.LLM === "google") { - return <> ; - } - - // Show API key input for other providers - return ( -
- -
- { - if (provider.apiKeyField === "PEXELS_API_KEY") { - input_field_changed( - e.target.value, - "pexels_api_key" - ); - } else if ( - provider.apiKeyField === "PIXABAY_API_KEY" - ) { - input_field_changed( - e.target.value, - "pixabay_api_key" - ); - } - }} - /> -
-

- - API key for {provider.title} image generation -

-
- ); - })()} - {/* Save Button */} {llmConfig.LLM === "ollama" && - downloadingModel.status && + downloadingModel?.status && downloadingModel.status !== "pulled" && (
{downloadingModel.status} diff --git a/servers/nextjs/app/storeInitializer.tsx b/servers/nextjs/app/storeInitializer.tsx index 6077f7bf..04eff944 100644 --- a/servers/nextjs/app/storeInitializer.tsx +++ b/servers/nextjs/app/storeInitializer.tsx @@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react'; import { hasValidLLMConfig } from '@/utils/storeHelpers'; import { usePathname, useRouter } from 'next/navigation'; import { useDispatch } from 'react-redux'; +import { checkIfSelectedOllamaModelIsPulled } from '@/utils/providerUtils'; export function StoreInitializer({ children }: { children: React.ReactNode }) { const dispatch = useDispatch(); @@ -82,17 +83,6 @@ export function StoreInitializer({ children }: { children: React.ReactNode }) { } } - const checkIfSelectedOllamaModelIsPulled = async (ollamaModel: string) => { - try { - const response = await fetch('/api/v1/ppt/ollama/models/available'); - const models = await response.json(); - const pulledModels = models.map((model: any) => model.name); - return pulledModels.includes(ollamaModel); - } catch (error) { - console.error('Error checking if selected Ollama model is pulled:', error); - return false; - } - } const checkIfSelectedCustomModelIsAvailable = async (customModel: string) => { try { diff --git a/servers/nextjs/components/CustomConfig.tsx b/servers/nextjs/components/CustomConfig.tsx new file mode 100644 index 00000000..f0a9cb4c --- /dev/null +++ b/servers/nextjs/components/CustomConfig.tsx @@ -0,0 +1,183 @@ +"use client"; +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { cn } from "@/lib/utils"; + +interface CustomConfigProps { + customLlmUrl: string; + customLlmApiKey: string; + customModel: string; + customModels: string[]; + customModelsLoading: boolean; + customModelsChecked: boolean; + openModelSelect: boolean; + onInputChange: (value: string, field: string) => void; + onOpenModelSelectChange: (open: boolean) => void; + onFetchCustomModels: () => void; +} + +export default function CustomConfig({ + customLlmUrl, + customLlmApiKey, + customModel, + customModels, + customModelsLoading, + customModelsChecked, + openModelSelect, + onInputChange, + onOpenModelSelectChange, + onFetchCustomModels, +}: CustomConfigProps) { + return ( + <> +
+ +
+ + onInputChange(e.target.value, "custom_llm_url") + } + /> +
+
+
+ +
+ + onInputChange(e.target.value, "custom_llm_api_key") + } + /> +
+
+ + {/* Model selection dropdown - only show if models are available */} + {customModelsChecked && customModels.length > 0 && ( +
+
+

+ Important: Only models with function + calling capabilities (tool calls) or JSON schema support + will work. +

+
+ +
+ + + + + + + + + No model found. + + {customModels.map((model, index) => ( + { + onInputChange(value, "custom_model"); + onOpenModelSelectChange(false); + }} + > + + + {model} + + + ))} + + + + + +
+
+ )} + + {/* Check for available models button - show when no models checked or no models found */} + {(!customModelsChecked || + (customModelsChecked && customModels.length === 0)) && ( +
+ +
+ )} + + {/* Show message if no models found */} + {customModelsChecked && customModels.length === 0 && ( +
+

+ No models found. Please make sure models are available. +

+
+ )} + + ); +} \ No newline at end of file diff --git a/servers/nextjs/components/GoogleConfig.tsx b/servers/nextjs/components/GoogleConfig.tsx new file mode 100644 index 00000000..d595fbd4 --- /dev/null +++ b/servers/nextjs/components/GoogleConfig.tsx @@ -0,0 +1,27 @@ +interface GoogleConfigProps { + googleApiKey: string; + onInputChange: (value: string, field: string) => void; +} + +export default function GoogleConfig({ googleApiKey, onInputChange }: GoogleConfigProps) { + return ( +
+ +
+ onInputChange(e.target.value, "google_api_key")} + className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors" + placeholder="Enter your API key" + /> +
+

+ + Your API key will be stored locally and never shared +

+
+ ); +} \ No newline at end of file diff --git a/servers/nextjs/components/Home.tsx b/servers/nextjs/components/Home.tsx index deeea0a3..3c1ebeb3 100644 --- a/servers/nextjs/components/Home.tsx +++ b/servers/nextjs/components/Home.tsx @@ -1,1139 +1,258 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { - Info, - ExternalLink, - PlayCircle, - Loader2, - Check, - ChevronsUpDown, -} from "lucide-react"; -import Link from "next/link"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; +import { Loader2, Download, CheckCircle, X } from "lucide-react"; import { useSelector } from "react-redux"; import { RootState } from "@/store/store"; import { handleSaveLLMConfig } from "@/utils/storeHelpers"; -import { Button } from "./ui/button"; +import LLMProviderSelection from "./LLMSelection"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "./ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { cn } from "@/lib/utils"; -import { Switch } from "./ui/switch"; -import { setLLMConfig } from "@/store/slices/userConfig"; + checkIfSelectedOllamaModelIsPulled, + LLMConfig, + pullOllamaModel, +} from "@/utils/providerUtils"; -interface ModelOption { - value: string; - label: string; - description?: string; - icon?: string; - size: string; +// Button state interface +interface ButtonState { + isLoading: boolean; + isDisabled: boolean; + text: string; + showProgress: boolean; + progressPercentage?: number; + status?: string; } -interface ImageProviderOption { - value: string; - label: string; - description?: string; - icon?: string; - requiresApiKey?: boolean; - apiKeyField?: keyof LLMConfig; -} - -interface ProviderConfig { - textModels: ModelOption[]; - imageModels: ModelOption[]; - apiGuide: { - title: string; - steps: string[]; - videoUrl?: string; - docsUrl: string; - }; -} - -const IMAGE_PROVIDERS: Record = { - pexels: { - value: "pexels", - label: "Pexels", - description: "Free stock photo and video platform", - icon: "/icons/pexels.png", - requiresApiKey: true, - apiKeyField: "PEXELS_API_KEY", - }, - pixabay: { - value: "pixabay", - label: "Pixabay", - description: "Free images and videos", - icon: "/icons/pixabay.png", - requiresApiKey: true, - apiKeyField: "PIXABAY_API_KEY", - }, - "dall-e-3": { - value: "dall-e-3", - label: "DALL-E 3", - description: "OpenAI's latest image generation model", - icon: "/icons/dall-e.png", - requiresApiKey: true, - apiKeyField: "OPENAI_API_KEY", - }, - gemini_flash: { - value: "gemini_flash", - label: "Gemini Flash", - description: "Google's primary image generation model", - icon: "/icons/google.png", - requiresApiKey: true, - apiKeyField: "GOOGLE_API_KEY", - }, -}; - -const PROVIDER_CONFIGS: Record = { - openai: { - textModels: [ - { - value: "gpt-4", - label: "GPT-4", - description: "Most capable model, best for complex tasks", - icon: "/icons/openai.png", - size: "8GB", - }, - ], - imageModels: [ - { - value: "dall-e-3", - label: "DALL-E 3", - description: "Latest version with highest quality", - icon: "/icons/dall-e.png", - size: "8GB", - }, - ], - apiGuide: { - title: "How to get your OpenAI API Key", - steps: [ - "Go to platform.openai.com and sign in or create an account", - 'Click on your profile icon and select "View API keys"', - 'Click "Create new secret key" and give it a name', - "Copy your API key immediately (you won't be able to see it again)", - "Make sure you have sufficient credits in your account", - ], - videoUrl: "https://www.youtube.com/watch?v=OB99E7Y1cMA", - docsUrl: "https://platform.openai.com/docs/api-reference/authentication", - }, - }, - google: { - textModels: [ - { - value: "gemini-pro", - label: "Gemini Pro", - description: "Balanced model for most tasks", - icon: "/icons/google.png", - size: "8GB", - }, - ], - imageModels: [ - { - value: "gemini_flash", - label: "Gemini Flash", - description: "Google's primary image generation model", - icon: "/icons/google.png", - size: "8GB", - }, - ], - apiGuide: { - title: "How to get your Google AI Studio API Key", - steps: [ - "Visit aistudio.google.com", - 'Click on "Get API key" in the top navigation', - 'Click "Create API key" on the next page', - 'Choose either "Create API Key in new Project" or select an existing project', - "Copy your API key - you're ready to go!", - ], - videoUrl: "https://www.youtube.com/watch?v=o8iyrtQyrZM&t=66s", - docsUrl: "https://aistudio.google.com/app/apikey", - }, - }, - ollama: { - textModels: [], - imageModels: [ - { - value: "pexels", - label: "Pexels", - description: - "Pexels is a free stock photo and video platform that allows you to download high-quality images and videos for free.", - icon: "/icons/pexels.png", - size: "8GB", - }, - ], - apiGuide: { - title: "How to get your Pexels API Key", - steps: [ - "Visit pexels.com", - 'Click on "Get API key" in the top navigation', - "Copy your API key - you're ready to go!", - ], - videoUrl: "https://www.youtube.com/watch?v=o8iyrtQyrZM&t=66s", - docsUrl: "https://www.pexels.com/api/documentation/", - }, - }, - custom: { - textModels: [], - imageModels: [ - { - value: "pexels", - label: "Pexels", - description: - "Pexels is a free stock photo and video platform that allows you to download high-quality images and videos for free.", - icon: "/icons/pexels.png", - size: "8GB", - }, - ], - apiGuide: { - title: "How to get your Pexels API Key", - steps: [ - "Visit pexels.com", - 'Click on "Get API key" in the top navigation', - "Copy your API key - you're ready to go!", - ], - videoUrl: "https://www.youtube.com/watch?v=o8iyrtQyrZM&t=66s", - docsUrl: "https://www.pexels.com/api/documentation/", - }, - }, -}; - export default function Home() { const router = useRouter(); - const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false); const config = useSelector((state: RootState) => state.userConfig); - const [llmConfig, setLlmConfig] = useState({ - ...config.llm_config, - IMAGE_PROVIDER: "dall-e-3", - }); - const [ollamaModels, setOllamaModels] = useState< - { - label: string; - value: string; - description: string; - size: string; - icon: string; - }[] - >([]); - const [customModels, setCustomModels] = useState([]); - const [downloadingModel, setDownloadingModel] = useState({ - name: "", - size: null, - downloaded: null, - status: "", - done: false, - }); + const [llmConfig, setLlmConfig] = useState(config.llm_config); const [isLoading, setIsLoading] = useState(false); - const [openModelSelect, setOpenModelSelect] = useState(false); - const [useCustomOllamaUrl, setUseCustomOllamaUrl] = useState( - llmConfig.USE_CUSTOM_URL || false - ); - const [customModelsLoading, setCustomModelsLoading] = - useState(false); - const [customModelsChecked, setCustomModelsChecked] = - useState(false); + + const [downloadingModel, setDownloadingModel] = useState<{ + name: string; + size: number | null; + downloaded: number | null; + status: string; + done: boolean; + } | null>(null); + const [showDownloadModal, setShowDownloadModal] = useState(false); + const [buttonState, setButtonState] = useState({ + isLoading: false, + isDisabled: false, + text: "Save Configuration", + showProgress: false + }); const canChangeKeys = config.can_change_keys; - - const input_field_changed = (new_value: string, field: string) => { - if (field === "openai_api_key") { - setLlmConfig({ ...llmConfig, OPENAI_API_KEY: new_value }); - } else if (field === "google_api_key") { - setLlmConfig({ ...llmConfig, GOOGLE_API_KEY: new_value }); - } else if (field === "ollama_url") { - setLlmConfig({ ...llmConfig, OLLAMA_URL: new_value }); - } else if (field === "ollama_model") { - setLlmConfig({ ...llmConfig, OLLAMA_MODEL: new_value }); - } else if (field === "custom_llm_url") { - setLlmConfig({ ...llmConfig, CUSTOM_LLM_URL: new_value }); - } else if (field === "custom_llm_api_key") { - setLlmConfig({ ...llmConfig, CUSTOM_LLM_API_KEY: new_value }); - } else if (field === "custom_model") { - setLlmConfig({ ...llmConfig, CUSTOM_MODEL: new_value }); - } else if (field === "pexels_api_key") { - setLlmConfig({ ...llmConfig, PEXELS_API_KEY: new_value }); - } else if (field === "pixabay_api_key") { - setLlmConfig({ ...llmConfig, PIXABAY_API_KEY: new_value }); + const downloadProgress = useMemo(() => { + if (downloadingModel && downloadingModel.downloaded !== null && downloadingModel.size !== null) { + return Math.round((downloadingModel.downloaded / downloadingModel.size) * 100); } - }; + return 0; + }, [downloadingModel?.downloaded, downloadingModel?.size]); const handleSaveConfig = async () => { try { + setIsLoading(true); + setButtonState(prev => ({ + ...prev, + isLoading: true, + isDisabled: true, + text: "Saving Configuration..." + })); + console.log("llmConfig", llmConfig); await handleSaveLLMConfig(llmConfig); - if (llmConfig.LLM === "ollama") { - setIsLoading(true); - await pullOllamaModels(); + if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) { + const isPulled = await checkIfSelectedOllamaModelIsPulled(llmConfig.OLLAMA_MODEL); + if (!isPulled) { + setShowDownloadModal(true); + await handleModelDownload(); + } } - toast.success("Configuration saved successfully"); + toast.info("Configuration saved successfully"); setIsLoading(false); + setButtonState(prev => ({ + ...prev, + isLoading: false, + isDisabled: false, + text: "Save Configuration" + })); router.push("/upload"); } catch (error) { - console.error("Error:", error); - toast.error( - error instanceof Error - ? error.message - : "Failed to save configuration", - { - description: - "Failed to save configuration", - } - ); + toast.info("Failed to save configuration"); setIsLoading(false); + setButtonState(prev => ({ + ...prev, + isLoading: false, + isDisabled: false, + text: "Save Configuration" + })); } }; - const fetchOllamaModelsWithConfig = async (config: any) => { + const handleModelDownload = async () => { try { - const response = await fetch("/api/v1/ppt/ollama/models/supported"); - const models = await response.json(); - setOllamaModels(models || []); - - // Check if currently selected model is still available - if (config.OLLAMA_MODEL && models && models.length > 0) { - const isModelAvailable = models.some( - (model: any) => model.value === config.OLLAMA_MODEL - ); - if (!isModelAvailable) { - setLlmConfig({ ...config, OLLAMA_MODEL: "" }); - } - } + await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel); } catch (error) { - console.error("Error fetching ollama models:", error); - setOllamaModels([]); // Ensure we always set an empty array on error + console.info("Error downloading model:", error); + setDownloadingModel(null); + setShowDownloadModal(false); } }; - const changeProvider = (provider: string) => { - const newConfig = { ...llmConfig, LLM: provider }; - // Auto Select appropriate provider based on the text models - if (provider === "openai") { - newConfig.IMAGE_PROVIDER = "dall-e-3"; - } else if (provider === "google") { - newConfig.IMAGE_PROVIDER = "gemini_flash"; - } else { - newConfig.IMAGE_PROVIDER = "pexels"; // default for ollama and custom - } - - setLlmConfig(newConfig); - if (provider === "ollama") { - // Use the new config to avoid stale state issues - fetchOllamaModelsWithConfig(newConfig); - } - }; - - const resetDownloadingModel = () => { - setDownloadingModel({ - name: "", - size: null, - downloaded: null, - status: "", - done: false, - }); - }; - - const pullOllamaModels = async (): Promise => { - return new Promise((resolve, reject) => { - const interval = setInterval(async () => { - try { - const response = await fetch( - `/api/v1/ppt/ollama/model/pull?model=${llmConfig.OLLAMA_MODEL}` - ); - if (response.status === 200) { - const data = await response.json(); - if (data.done && data.status !== "error") { - clearInterval(interval); - setDownloadingModel(data); - resolve(); - } else if (data.status === "error") { - clearInterval(interval); - resetDownloadingModel(); - reject(new Error("Error occurred while pulling model")); - } else { - setDownloadingModel(data); - } - } else { - clearInterval(interval); - resetDownloadingModel(); - if (response.status === 403) { - reject(new Error("Request to Ollama Not Authorized")); - } - reject(new Error("Error occurred while pulling model")); - } - } catch (error) { - clearInterval(interval); - resetDownloadingModel(); - reject(error); - } - }, 1000); - }); - }; - - const fetchOllamaModels = async () => { - await fetchOllamaModelsWithConfig(llmConfig); - }; - - const fetchCustomModels = async () => { - try { - setCustomModelsLoading(true); - const response = await fetch("/api/v1/ppt/custom_llm/models/available", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: llmConfig.CUSTOM_LLM_URL || "", - api_key: llmConfig.CUSTOM_LLM_API_KEY || "", - }), + useEffect(() => { + if (downloadingModel && downloadingModel.downloaded !== null && downloadingModel.size !== null) { + const percentage = Math.round(((downloadingModel.downloaded / downloadingModel.size) * 100)); + setButtonState({ + isLoading: true, + isDisabled: true, + text: `Downloading Model (${percentage}%)`, + showProgress: true, + progressPercentage: percentage, + status: downloadingModel.status }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - setCustomModels(data); - // Only set customModelsChecked to true if the API call succeeds - setCustomModelsChecked(true); - } catch (error) { - console.error("Error fetching custom models:", error); - // Don't set customModelsChecked to true on error, so the button remains visible - setCustomModels([]); - toast.error( - "Failed to fetch available models. Please check your URL and API key.", - { - description: - "Failed to fetch available models. Please check your URL and API key.", - } - ); - } finally { - setCustomModelsLoading(false); } - }; - const setOllamaConfig = () => { - if (!useCustomOllamaUrl) { - setLlmConfig({ - ...llmConfig, - OLLAMA_URL: "http://localhost:11434", - USE_CUSTOM_URL: false, - }); - } else { - setLlmConfig({ ...llmConfig, USE_CUSTOM_URL: true }); + if (downloadingModel && downloadingModel.done) { + setTimeout(() => { + setShowDownloadModal(false); + setDownloadingModel(null); + toast.info("Model downloaded successfully!"); + }, 2000); } - }; + }, [downloadingModel]); useEffect(() => { if (!canChangeKeys) { router.push("/upload"); } - if (llmConfig.LLM === "ollama") { - fetchOllamaModels(); - } - }, []); - - useEffect(() => { - setOllamaConfig(); - }, [useCustomOllamaUrl]); - - // Reset custom models when URL or API key changes - useEffect(() => { - if (llmConfig.LLM === "custom") { - setCustomModels([]); - setCustomModelsChecked(false); - setLlmConfig({ ...llmConfig, CUSTOM_MODEL: "" }); - } - }, [llmConfig.CUSTOM_LLM_URL, llmConfig.CUSTOM_LLM_API_KEY]); - - // Load default image provider based on LLM selection - // useEffect(() => { - // if (llmConfig.LLM && !llmConfig.IMAGE_PROVIDER) { - // let defaultImageProvider = ""; - // if (llmConfig.LLM === "openai") { - // defaultImageProvider = "dall-e-3"; - // } else if (llmConfig.LLM === "google") { - // defaultImageProvider = "imagen"; - // } else { - // defaultImageProvider = "pexels"; // default for ollama and custom - // } - - // setLlmConfig((prev) => ({ - // ...prev, - // IMAGE_PROVIDER: defaultImageProvider, - // })); - // } - // }, [llmConfig.LLM]); + }, [canChangeKeys, router]); if (!canChangeKeys) { return null; } return ( -
-
+
+
{/* Branding Header */} -
-
- Presenton Logo +
+
+ Presenton Logo
-

+

Open-source AI presentation generator

{/* Main Configuration Card */} -
- {/* Provider Selection */} -
- -
- {Object.keys(PROVIDER_CONFIGS).map((provider) => ( - - ))} -
-
+
+ +
+
- {/* API Key Input */} - {llmConfig.LLM !== "ollama" && llmConfig.LLM !== "custom" && ( -
- -
- - input_field_changed( - e.target.value, - llmConfig.LLM === "openai" - ? "openai_api_key" - : "google_api_key" - ) - } - className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors" - placeholder="Enter your API key" - /> + {/* Download Progress Modal */} + {showDownloadModal && downloadingModel && ( +
+
+ {/* Modal Content */} +
+ {/* Icon */} +
+ {downloadingModel.done ? ( + + ) : ( + + )}
-

- - Your API key will be stored locally and never shared + + {/* Title */} +

+ {downloadingModel.done ? "Download Complete!" : "Downloading Model"} +

+ + {/* Model Name */} +

+ {llmConfig.OLLAMA_MODEL}

-
- )} - {llmConfig.LLM === "ollama" && ( -
-
- -
- {ollamaModels && ollamaModels.length > 0 ? ( - - - - - - - - - No model found. - - {ollamaModels?.map((model, index) => ( - { - input_field_changed(value, "ollama_model"); - setOpenModelSelect(false); - }} - > - -
-
- {`${model.label} -
-
-
- - {model.label} - - - {model.size} - -
- - {model.description} - -
-
-
- ))} -
-
-
-
-
- ) : ( -
-
-
-
-
-
-
-
-
- )} -
- {(!ollamaModels || ollamaModels.length === 0) && ( -

- Loading available models... -

- )} -
-
-
- - -
- {useCustomOllamaUrl && ( - <> -
- -
- - input_field_changed(e.target.value, "ollama_url") - } - /> -
-

- - Change this if you are using a custom Ollama instance -

-
- - )} -
-
- )} - {llmConfig.LLM === "custom" && ( - <> -
- -
- - input_field_changed(e.target.value, "custom_llm_url") - } - /> -
-
-
- -
- - input_field_changed(e.target.value, "custom_llm_api_key") - } - /> -
-
- {/* Model selection dropdown - only show if models are available */} - {customModelsChecked && customModels.length > 0 && ( + {/* Progress Bar */} + {downloadProgress > 0 && (
-
-

- Important: Only models with function - calling capabilities (tool calls) or JSON schema support - will work. -

-
- -
- - - - - - - - - No model found. - - {customModels.map((model, index) => ( - { - input_field_changed(value, "custom_model"); - setOpenModelSelect(false); - }} - > - - - {model} - - - ))} - - - - - -
-
- )} - - {/* Check for available models button - show when no models checked or no models found */} - {(!customModelsChecked || - (customModelsChecked && customModels.length === 0)) && ( -
- -
- )} - - {/* Show message if no models found */} - {customModelsChecked && customModels.length === 0 && ( -
-

- No models found. Please make sure models are available. -

-
- )} - - )} - {/* Image Provider Selection */} -
- -
- - - - - - - - - No provider found. - - {Object.values(IMAGE_PROVIDERS).map( - (provider, index) => ( - { - console.log("Image Provider", value) - setLlmConfig({ - ...llmConfig, - IMAGE_PROVIDER: value, - }); - console.log("LLM config", llmConfig) - setOpenImageProviderSelect(false); - }} - > - -
-
-
- - {provider.label} - -
- - {provider.description} - -
-
-
- ) - )} -
-
-
-
-
-
-
- - {/* Dynamic API Key Input for Image Provider */} - {llmConfig.IMAGE_PROVIDER && - IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] && - (() => { - const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]; - - // Show info message when using same API key as main provider - if (provider.value === "dall-e-3" && llmConfig.LLM === "openai") { - return <>; - } - - if (provider.value === "gemini_flash" && llmConfig.LLM === "google") { - return <> ; - } - - // Show API key input for other providers - return ( -
- -
- { - if (provider.apiKeyField === "PEXELS_API_KEY") { - input_field_changed(e.target.value, "pexels_api_key"); - } else if (provider.apiKeyField === "PIXABAY_API_KEY") { - input_field_changed( - e.target.value, - "pixabay_api_key" - ); - } - }} +
+
-

- - API key for {provider.label} image generation +

+ {downloadProgress}% Complete

- ); - })()} + )} - {/* Model Information */} -
-
- -
-

- Selected Models -

-

- Using{" "} - {llmConfig.LLM === "ollama" - ? llmConfig.OLLAMA_MODEL ?? "_____" - : llmConfig.LLM === "custom" - ? llmConfig.CUSTOM_MODEL ?? "_____" - : PROVIDER_CONFIGS[llmConfig.LLM!].textModels[0].label}{" "} - for text generation and{" "} - {llmConfig.IMAGE_PROVIDER && - IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] - ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER].label - : "_____"}{" "} - for images -

-

- We've pre-selected the best models for optimal presentation - generation -

-
-
-
- {/* API Guide Section */} - - - -
- -

- {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.title} -

+ {/* Status */} + {downloadingModel.status && ( +
+ + + {downloadingModel.status} +
- - -
-
    - {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.steps.map( - (step, index) => ( -
  1. - {step} -
  2. - ) - )} -
+ )} -
- {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.videoUrl && ( - - - Watch Video Tutorial - - - )} - - Official Documentation - - + {/* Status Message */} + {downloadingModel.status && downloadingModel.status !== "pulled" && ( +
+ {downloadingModel.status === "downloading" && "Downloading model files..."} + {downloadingModel.status === "verifying" && "Verifying model integrity..."} + {downloadingModel.status === "pulling" && "Pulling model from registry..."} +
+ )} + + {/* Download Info */} + {downloadingModel.downloaded && downloadingModel.size && ( +
+
+ Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB + Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB
- - - + )} +
+
+
+ )} - {/* Save Button */} + {/* Fixed Bottom Button */} +
+
- - {llmConfig.LLM === "ollama" && - downloadingModel.status && - downloadingModel.status !== "pulled" && ( -
- {downloadingModel.status} -
- )}
-
+
); } diff --git a/servers/nextjs/components/LLMSelection.tsx b/servers/nextjs/components/LLMSelection.tsx new file mode 100644 index 00000000..bdb1d857 --- /dev/null +++ b/servers/nextjs/components/LLMSelection.tsx @@ -0,0 +1,405 @@ +"use client"; +import { useState, useEffect } from "react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; +import { Check, ChevronsUpDown, Info } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { cn } from "@/lib/utils"; +import OpenAIConfig from "./OpenAIConfig"; +import GoogleConfig from "./GoogleConfig"; +import OllamaConfig from "./OllamaConfig"; +import CustomConfig from "./CustomConfig"; +import { + OllamaModel, + LLMConfig, + updateLLMConfig, + changeProvider as changeProviderUtil, + fetchOllamaModelsWithConfig, + setOllamaConfig, + fetchCustomModels, +} from "@/utils/providerUtils"; +import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants"; + +// Button state interface +interface ButtonState { + isLoading: boolean; + isDisabled: boolean; + text: string; + showProgress: boolean; + progressPercentage?: number; + status?: string; +} + +interface LLMProviderSelectionProps { + initialLLMConfig: LLMConfig; + onConfigChange: (config: LLMConfig) => void; + buttonState: ButtonState; + setButtonState: (state: ButtonState | ((prev: ButtonState) => ButtonState)) => void; +} + +export default function LLMProviderSelection({ + initialLLMConfig, + onConfigChange, + setButtonState, +}: LLMProviderSelectionProps) { + const [llmConfig, setLlmConfig] = useState(initialLLMConfig); + const [ollamaModels, setOllamaModels] = useState([]); + const [customModels, setCustomModels] = useState([]); + const [customModelsLoading, setCustomModelsLoading] = useState(false); + const [customModelsChecked, setCustomModelsChecked] = useState(false); + const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false); + const [useCustomOllamaUrl, setUseCustomOllamaUrl] = useState( + initialLLMConfig.USE_CUSTOM_URL || false + ); + const [openModelSelect, setOpenModelSelect] = useState(false); + const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false); + + useEffect(() => { + onConfigChange(llmConfig); + }, [llmConfig]); + + useEffect(() => { + const needsModelSelection = + (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || + (llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL); + + const needsApiKey = + ((llmConfig.IMAGE_PROVIDER === "dall-e-3" || llmConfig.LLM === "openai") && !llmConfig.OPENAI_API_KEY) || + ((llmConfig.IMAGE_PROVIDER === "gemini_flash" || llmConfig.LLM === "google") && !llmConfig.GOOGLE_API_KEY) || + (llmConfig.IMAGE_PROVIDER === "pexels" && !llmConfig.PEXELS_API_KEY) || + (llmConfig.IMAGE_PROVIDER === "pixabay" && !llmConfig.PIXABAY_API_KEY); + + setButtonState({ + isLoading: false, + isDisabled: needsModelSelection || needsApiKey, + text: needsModelSelection ? "Please Select a Model" : needsApiKey ? "Please Enter API Key" : "Save Configuration", + showProgress: false + }); + + }, [llmConfig]); + + const input_field_changed = (new_value: string, field: string) => { + const updatedConfig = updateLLMConfig(llmConfig, field, new_value); + setLlmConfig(updatedConfig); + }; + + const handleProviderChange = (provider: string) => { + const newConfig = changeProviderUtil(llmConfig, provider); + setLlmConfig(newConfig); + if (provider === "ollama") { + fetchOllamaModels(); + } + }; + + const fetchOllamaModels = async () => { + try { + setOllamaModelsLoading(true); + const result = await fetchOllamaModelsWithConfig(llmConfig); + setOllamaModels(result.models); + if (result.updatedConfig) { + setLlmConfig(result.updatedConfig); + } + } catch (error) { + console.error("Error fetching Ollama models:", error); + setOllamaModels([]); + } finally { + setOllamaModelsLoading(false); + } + }; + + const fetchCustomModelsHandler = async () => { + try { + setCustomModelsLoading(true); + const models = await fetchCustomModels( + llmConfig.CUSTOM_LLM_URL || "", + llmConfig.CUSTOM_LLM_API_KEY || "" + ); + setCustomModels(models); + setCustomModelsChecked(true); + } catch (error) { + console.error("Error fetching custom models:", error); + setCustomModels([]); + } finally { + setCustomModelsLoading(false); + } + }; + + const setOllamaConfigHandler = () => { + const updatedConfig = setOllamaConfig(llmConfig, useCustomOllamaUrl); + setLlmConfig(updatedConfig); + }; + + useEffect(() => { + if (llmConfig.LLM === "ollama") { + fetchOllamaModels(); + } + }, [llmConfig.LLM]); + + useEffect(() => { + setOllamaConfigHandler(); + }, [useCustomOllamaUrl]); + + useEffect(() => { + if (llmConfig.LLM === "custom") { + setCustomModels([]); + setCustomModelsChecked(false); + setLlmConfig({ ...llmConfig, CUSTOM_MODEL: "" }); + } + }, [llmConfig.CUSTOM_LLM_URL, llmConfig.CUSTOM_LLM_API_KEY]); + + useEffect(() => { + if (!llmConfig.IMAGE_PROVIDER) { + if (llmConfig.LLM === "openai") { + setLlmConfig({ ...llmConfig, IMAGE_PROVIDER: "dall-e-3" }); + } else if (llmConfig.LLM === "google") { + setLlmConfig({ ...llmConfig, IMAGE_PROVIDER: "gemini_flash" }); + } else { + setLlmConfig({ ...llmConfig, IMAGE_PROVIDER: "pexels" }); + } + } + }, []); + + return ( +
+ {/* Provider Selection - Fixed Header */} +
+ + + OpenAI + Google + Ollama + Custom + + +
+ + + {/* Scrollable Content */} +
+ + {/* OpenAI Content */} + + + + + {/* Google Content */} + + + + + {/* Ollama Content */} + + { + input_field_changed(modelName, "ollama_model"); + }} + /> + + + {/* Custom Content */} + + + + + + {/* Image Provider Selection */} +
+ +
+ + + + + + + + + No provider found. + + {Object.values(IMAGE_PROVIDERS).map( + (provider, index) => ( + { + input_field_changed(value, "image_provider"); + setOpenImageProviderSelect(false); + }} + > + +
+
+
+ + {provider.label} + +
+ + {provider.description} + +
+
+
+ ) + )} +
+
+
+
+
+
+
+ + {/* Dynamic API Key Input for Image Provider */} + {llmConfig.IMAGE_PROVIDER && + IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] && + (() => { + const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]; + + // Show info message when using same API key as main provider + if (provider.value === "dall-e-3" && llmConfig.LLM === "openai") { + return <>; + } + + if (provider.value === "gemini_flash" && llmConfig.LLM === "google") { + return <>; + } + + // Show API key input for other providers + return ( +
+ +
+ { + if (provider.apiKeyField === "PEXELS_API_KEY") { + input_field_changed(e.target.value, "pexels_api_key"); + } else if (provider.apiKeyField === "PIXABAY_API_KEY") { + input_field_changed(e.target.value, "pixabay_api_key"); + } + }} + /> +
+

+ + API key for {provider.label} image generation +

+
+ ); + })()} + + {/* Model Information */} +
+
+ +
+

+ Selected Models +

+

+ Using{" "} + {llmConfig.LLM === "ollama" + ? llmConfig.OLLAMA_MODEL ?? "xxxxx" + : llmConfig.LLM === "custom" + ? llmConfig.CUSTOM_MODEL ?? "xxxxx" + : LLM_PROVIDERS[llmConfig.LLM!]?.model_label || "xxxxx"}{" "} + for text generation and{" "} + {llmConfig.IMAGE_PROVIDER && + IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] + ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER].label + : "xxxxx"}{" "} + for images +

+
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/servers/nextjs/components/OllamaConfig.tsx b/servers/nextjs/components/OllamaConfig.tsx new file mode 100644 index 00000000..f0f09b76 --- /dev/null +++ b/servers/nextjs/components/OllamaConfig.tsx @@ -0,0 +1,226 @@ +"use client"; +import { useState } from "react"; +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { cn } from "@/lib/utils"; +import { Switch } from "./ui/switch"; + +interface OllamaModel { + label: string; + value: string; + description: string; + size: string; + icon: string; +} + +interface OllamaConfigProps { + ollamaModel: string; + ollamaUrl: string; + useCustomUrl: boolean; + ollamaModels: OllamaModel[]; + ollamaModelsLoading?: boolean; + onInputChange: (value: string, field: string) => void; + onUseCustomUrlChange: (checked: boolean) => void; + openModelSelect: boolean; + onOpenModelSelectChange: (open: boolean) => void; + onModelSelect?: (modelName: string) => void; +} + +export default function OllamaConfig({ + ollamaModel, + ollamaUrl, + useCustomUrl, + ollamaModels, + ollamaModelsLoading = false, + onInputChange, + onUseCustomUrlChange, + openModelSelect, + onOpenModelSelectChange, + onModelSelect, +}: OllamaConfigProps) { + return ( + <> +
+ +
+ {ollamaModelsLoading ? ( +
+
+ + Loading models... +
+
+ ) : ollamaModels && ollamaModels.length > 0 ? ( + + + + + + + + + No model found. + + {ollamaModels?.map((model, index) => ( + { + if (onModelSelect) { + onModelSelect(value); + } else { + onInputChange(value, "ollama_model"); + } + onOpenModelSelectChange(false); + }} + > + +
+
+ {`${model.label} +
+
+
+ + {model.label} + + + {model.size} + +
+ + {model.description} + +
+
+
+ ))} +
+
+
+
+
+ ) : ( +
+
+
+
+
+
+
+
+
+ )} +
+ {(!ollamaModels || ollamaModels.length === 0) && !ollamaModelsLoading && ( +

+ No models available. Please check your Ollama connection. +

+ )} +
+
+
+ + +
+ {useCustomUrl && ( + <> +
+ +
+ + onInputChange(e.target.value, "ollama_url") + } + /> +
+

+ + Change this if you are using a custom Ollama instance +

+
+ + )} +
+ + ); +} \ No newline at end of file diff --git a/servers/nextjs/components/OpenAIConfig.tsx b/servers/nextjs/components/OpenAIConfig.tsx new file mode 100644 index 00000000..6696493a --- /dev/null +++ b/servers/nextjs/components/OpenAIConfig.tsx @@ -0,0 +1,27 @@ +interface OpenAIConfigProps { + openaiApiKey: string; + onInputChange: (value: string, field: string) => void; +} + +export default function OpenAIConfig({ openaiApiKey, onInputChange }: OpenAIConfigProps) { + return ( +
+ +
+ onInputChange(e.target.value, "openai_api_key")} + className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors" + placeholder="Enter your API key" + /> +
+

+ + Your API key will be stored locally and never shared +

+
+ ); +} \ No newline at end of file diff --git a/servers/nextjs/components/ui/sonner.tsx b/servers/nextjs/components/ui/sonner.tsx index 261ee62d..452f4d9f 100644 --- a/servers/nextjs/components/ui/sonner.tsx +++ b/servers/nextjs/components/ui/sonner.tsx @@ -12,8 +12,6 @@ const Toaster = ({ ...props }: ToasterProps) => { = { + pexels: { + value: "pexels", + label: "Pexels", + description: "Free stock photo and video platform", + icon: "/icons/pexels.png", + requiresApiKey: true, + apiKeyField: "PEXELS_API_KEY", + apiKeyFieldLabel: "Pexels API Key" + }, + pixabay: { + value: "pixabay", + label: "Pixabay", + description: "Free images and videos", + icon: "/icons/pixabay.png", + requiresApiKey: true, + apiKeyField: "PIXABAY_API_KEY", + apiKeyFieldLabel: "Pixabay API Key" + }, + "dall-e-3": { + value: "dall-e-3", + label: "DALL-E 3", + description: "OpenAI's latest image generation model", + icon: "/icons/dall-e.png", + requiresApiKey: true, + apiKeyField: "OPENAI_API_KEY", + apiKeyFieldLabel: "OpenAI API Key" + }, + gemini_flash: { + value: "gemini_flash", + label: "Gemini Flash", + description: "Google's primary image generation model", + icon: "/icons/google.png", + requiresApiKey: true, + apiKeyField: "GOOGLE_API_KEY", + apiKeyFieldLabel: "Google API Key" + }, +}; + +export const LLM_PROVIDERS: Record = { + openai: { + value: "openai", + label: "OpenAI", + description: "OpenAI's latest image generation model", + model_value: "gpt-4.1", + model_label: "GPT-4.1" + }, + google: { + value: "google", + label: "Google", + description: "Google's primary image generation model", + model_value: "gemini-2.0-flash", + model_label: "Gemini 2.0 Flash" + }, + ollama: { + value: "ollama", + label: "Ollama", + description: "Ollama's primary text generation model", + }, + custom: { + value: "custom", + label: "Custom", + description: "Custom LLM", + }, +}; \ No newline at end of file diff --git a/servers/nextjs/utils/providerUtils.ts b/servers/nextjs/utils/providerUtils.ts new file mode 100644 index 00000000..37e27b92 --- /dev/null +++ b/servers/nextjs/utils/providerUtils.ts @@ -0,0 +1,284 @@ +import { toast } from "sonner"; + +export interface OllamaModel { + label: string; + value: string; + description: string; + size: string; + icon: string; +} + +export interface DownloadingModel { + name: string; + size: number | null; + downloaded: number | null; + status: string; + done: boolean; +} + +export interface LLMConfig { + LLM?: string; + OPENAI_API_KEY?: string; + GOOGLE_API_KEY?: string; + OLLAMA_URL?: string; + OLLAMA_MODEL?: string; + CUSTOM_LLM_URL?: string; + CUSTOM_LLM_API_KEY?: string; + CUSTOM_MODEL?: string; + PEXELS_API_KEY?: string; + PIXABAY_API_KEY?: string; + IMAGE_PROVIDER?: string; + USE_CUSTOM_URL?: boolean; +} + +export interface OllamaModelsResult { + models: OllamaModel[]; + updatedConfig?: LLMConfig; +} + +/** + * Updates LLM configuration based on field changes + */ +export const updateLLMConfig = ( + currentConfig: LLMConfig, + field: string, + value: string +): LLMConfig => { + const fieldMappings: Record = { + openai_api_key: "OPENAI_API_KEY", + google_api_key: "GOOGLE_API_KEY", + ollama_url: "OLLAMA_URL", + ollama_model: "OLLAMA_MODEL", + custom_llm_url: "CUSTOM_LLM_URL", + custom_llm_api_key: "CUSTOM_LLM_API_KEY", + custom_model: "CUSTOM_MODEL", + pexels_api_key: "PEXELS_API_KEY", + pixabay_api_key: "PIXABAY_API_KEY", + image_provider: "IMAGE_PROVIDER", + }; + + const configKey = fieldMappings[field]; + if (configKey) { + return { ...currentConfig, [configKey]: value }; + } + + return currentConfig; +}; + +/** + * Changes the provider and sets appropriate defaults + */ +export const changeProvider = ( + currentConfig: LLMConfig, + provider: string +): LLMConfig => { + const newConfig = { ...currentConfig, LLM: provider }; + + // Auto Select appropriate image provider based on the text models + if (provider === "openai") { + newConfig.IMAGE_PROVIDER = "dall-e-3"; + } else if (provider === "google") { + newConfig.IMAGE_PROVIDER = "gemini_flash"; + } else { + newConfig.IMAGE_PROVIDER = "pexels"; // default for ollama and custom + } + + return newConfig; +}; + +/** + * Fetches supported Ollama models + */ +export const fetchOllamaModels = async (): Promise => { + try { + const response = await fetch("/api/v1/ppt/ollama/models/supported"); + const models = await response.json(); + return models || []; + } catch (error) { + console.error("Error fetching ollama models:", error); + return []; // Ensure we always return an empty array on error + } +}; + +/** + * Fetches Ollama models and validates current selection + * Returns models and updated config if needed + */ +export const fetchOllamaModelsWithConfig = async ( + config: LLMConfig +): Promise => { + try { + const models = await fetchOllamaModels(); + + // Check if currently selected model is still available + let updatedConfig: LLMConfig | undefined; + if (config.OLLAMA_MODEL && models && models.length > 0) { + const isModelAvailable = models.some( + (model: OllamaModel) => model.value === config.OLLAMA_MODEL + ); + if (!isModelAvailable) { + updatedConfig = { ...config, OLLAMA_MODEL: "" }; + } + } + + return { + models, + updatedConfig + }; + } catch (error) { + console.error("Error fetching ollama models:", error); + return { + models: [], + updatedConfig: { ...config, OLLAMA_MODEL: "" } + }; + } +}; + +export const checkIfSelectedOllamaModelIsPulled = async (ollamaModel: string) => { + try { + const response = await fetch('/api/v1/ppt/ollama/models/available'); + const models = await response.json(); + const pulledModels = models.map((model: any) => model.name); + return pulledModels.includes(ollamaModel); + } catch (error) { + console.error('Error checking if selected Ollama model is pulled:', error); + return false; + } +} + +/** + * Fetches available custom models + */ +export const fetchCustomModels = async ( + url: string, + apiKey: string +): Promise => { + try { + const response = await fetch("/api/v1/ppt/custom_llm/models/available", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: url || "", + api_key: apiKey || "", + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + toast.info("Could not fetch custom models"); + console.error("Error fetching custom models:", error); + throw error; + } +}; + +/** + * Resets downloading model state + */ +export const resetDownloadingModel = (): DownloadingModel => ({ + name: "", + size: null, + downloaded: null, + status: "", + done: false, +}); + +/** + * Pulls Ollama model with progress tracking + * Returns a promise that resolves with the final downloading model state + */ +export const pullOllamaModel = async ( + model: string, + onProgress?: (model: DownloadingModel) => void +): Promise => { + return new Promise((resolve, reject) => { + const interval = setInterval(async () => { + try { + const response = await fetch( + `/api/v1/ppt/ollama/model/pull?model=${model}` + ); + if (response.status === 200) { + const data = await response.json(); + if (data.done && data.status !== "error") { + clearInterval(interval); + onProgress?.(data); + resolve(data); + } else if (data.status === "error") { + clearInterval(interval); + const resetData = resetDownloadingModel(); + onProgress?.(resetData); + reject(new Error("Error occurred while pulling model")); + } else { + onProgress?.(data); + } + } else { + clearInterval(interval); + const resetData = resetDownloadingModel(); + onProgress?.(resetData); + if (response.status === 403) { + reject(new Error("Request to Ollama Not Authorized")); + } + reject(new Error("Error occurred while pulling model")); + } + } catch (error) { + clearInterval(interval); + const resetData = resetDownloadingModel(); + onProgress?.(resetData); + reject(error); + } + }, 1000); + }); +}; + +/** + * Sets Ollama configuration based on custom URL preference + */ +export const setOllamaConfig = ( + currentConfig: LLMConfig, + useCustomUrl: boolean +): LLMConfig => { + let customUrl = "http://localhost:11434"; + if (!useCustomUrl) { + return { + ...currentConfig, + OLLAMA_URL: customUrl, + USE_CUSTOM_URL: false, + }; + } else { + return { ...currentConfig, USE_CUSTOM_URL: true, OLLAMA_URL: customUrl }; + } +}; + +/** + * Handles saving configuration with error handling + */ +export const handleSaveConfiguration = async ( + llmConfig: LLMConfig, + handleSaveLLMConfig: (config: LLMConfig) => Promise, + pullOllamaModels?: () => Promise +): Promise => { + try { + await handleSaveLLMConfig(llmConfig); + if (llmConfig.LLM === "ollama" && pullOllamaModels) { + await pullOllamaModels(); + } + toast.success("Configuration saved successfully"); + } catch (error) { + console.error("Error:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to save configuration", + { + description: "Failed to save configuration", + } + ); + throw error; + } +}; \ No newline at end of file diff --git a/servers/nextjs/utils/storeHelpers.ts b/servers/nextjs/utils/storeHelpers.ts index a761d0ce..3ed7d0f3 100644 --- a/servers/nextjs/utils/storeHelpers.ts +++ b/servers/nextjs/utils/storeHelpers.ts @@ -5,7 +5,6 @@ export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => { if (!hasValidLLMConfig(llmConfig)) { throw new Error("Provided configuration is not valid"); } - console.log("StoreHelperLLMConfig: Saving LLM config", llmConfig); await fetch("/api/user-config", { method: "POST", body: JSON.stringify(llmConfig), @@ -54,17 +53,17 @@ export const hasValidLLMConfig = (llmConfig: LLMConfig) => { const isLLMConfigValid = llmConfig.LLM === "openai" ? OPENAI_API_KEY !== "" && - OPENAI_API_KEY !== null && - OPENAI_API_KEY !== undefined + OPENAI_API_KEY !== null && + OPENAI_API_KEY !== undefined : llmConfig.LLM === "google" - ? GOOGLE_API_KEY !== "" && + ? GOOGLE_API_KEY !== "" && GOOGLE_API_KEY !== null && GOOGLE_API_KEY !== undefined - : llmConfig.LLM === "ollama" - ? isOllamaConfigValid - : llmConfig.LLM === "custom" - ? isCustomConfigValid - : false; + : llmConfig.LLM === "ollama" + ? isOllamaConfigValid + : llmConfig.LLM === "custom" + ? isCustomConfigValid + : false; return isLLMConfigValid && isImageConfigValid(); };