From 21dca979ce87eef86c88c1a26701cf3400ca74d7 Mon Sep 17 00:00:00 2001 From: sudipnext Date: Fri, 18 Jul 2025 17:52:23 +0545 Subject: [PATCH] feat: add image provider configuration and validation - Updated LLMConfig interface to include IMAGE_PROVIDER and PIXABAY_API_KEY. - Enhanced handleSaveLLMConfig to log the saving process and validate IMAGE_PROVIDER. - Implemented image provider validation logic in hasValidLLMConfig to check for required API keys based on the selected provider. - Modified start.js to read IMAGE_PROVIDER and PIXABAY_API_KEY from environment variables and include them in the user configuration setup. --- servers/fastapi/api/lifespan.py | 9 +- servers/fastapi/enums/image_provider.py | 7 + servers/fastapi/models/user_config.py | 2 + .../services/image_generation_service.py | 47 +- .../fastapi/tests/test_image_generation.py | 400 ++++ servers/fastapi/utils/get_env.py | 6 + servers/fastapi/utils/image_provider.py | 41 + servers/fastapi/utils/model_availability.py | 28 +- servers/fastapi/utils/set_env.py | 7 + servers/fastapi/utils/user_config.py | 10 + servers/nextjs/app/api/user-config/route.ts | 49 +- servers/nextjs/app/settings/SettingPage.tsx | 1685 +++++++++------ servers/nextjs/components/Home.tsx | 1902 ++++++++++------- servers/nextjs/types/global.d.ts | 2 + servers/nextjs/utils/storeHelpers.ts | 69 +- start.js | 5 +- 16 files changed, 2717 insertions(+), 1552 deletions(-) create mode 100644 servers/fastapi/enums/image_provider.py create mode 100644 servers/fastapi/tests/test_image_generation.py create mode 100644 servers/fastapi/utils/image_provider.py diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py index 0d92a2d0..a74785dc 100644 --- a/servers/fastapi/api/lifespan.py +++ b/servers/fastapi/api/lifespan.py @@ -5,12 +5,17 @@ from fastapi import FastAPI from sqlmodel import SQLModel from services import SQL_ENGINE -from utils.model_availability import check_llm_model_availability +from utils.model_availability import check_llm_and_image_provider_api_or_model_availability @asynccontextmanager async def app_lifespan(_: FastAPI): + """ + Lifespan context manager for FastAPI application. + Initializes the application data directory and checks LLM model availability. + + """ os.makedirs(os.getenv("APP_DATA_DIRECTORY"), exist_ok=True) SQLModel.metadata.create_all(SQL_ENGINE) - await check_llm_model_availability() + await check_llm_and_image_provider_api_or_model_availability() yield diff --git a/servers/fastapi/enums/image_provider.py b/servers/fastapi/enums/image_provider.py new file mode 100644 index 00000000..cee115e5 --- /dev/null +++ b/servers/fastapi/enums/image_provider.py @@ -0,0 +1,7 @@ +from enum import Enum + +class ImageProvider(Enum): + PEXELS = "pexels" + PIXABAY = "pixabay" + IMAGEN = "imagen" + DALLE3 = "dall-e-3" diff --git a/servers/fastapi/models/user_config.py b/servers/fastapi/models/user_config.py index e04ab5e2..930aa1e5 100644 --- a/servers/fastapi/models/user_config.py +++ b/servers/fastapi/models/user_config.py @@ -12,3 +12,5 @@ class UserConfig(BaseModel): CUSTOM_LLM_API_KEY: Optional[str] = None CUSTOM_MODEL: Optional[str] = None PEXELS_API_KEY: Optional[str] = None + IMAGE_PROVIDER: Optional[str] = None + PIXABAY_API_KEY: Optional[str] = None diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 4acff9f1..5f66ec1d 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -8,43 +8,59 @@ from models.image_prompt import ImagePrompt from models.sql.image_asset import ImageAsset from utils.download_helpers import download_file from utils.get_env import get_pexels_api_key_env +from utils.get_env import get_pixabay_api_key_env from utils.llm_provider import ( get_llm_client, is_google_selected, is_openai_selected, ) - +from utils.image_provider import ( + is_pixels_selected, + is_pixabay_selected, + is_imagen_selected, + is_dalle3_selected +) class ImageGenerationService: def __init__(self, output_directory: str): self.output_directory = output_directory - - self.use_pexels = False - if get_pexels_api_key_env(): - self.use_pexels = True - self.image_gen_func = self.get_image_gen_func() def get_image_gen_func(self): - if self.use_pexels: + if is_pixabay_selected(): + return self.get_image_from_pixabay + elif is_pixels_selected(): return self.get_image_from_pexels - elif is_google_selected(): + elif is_imagen_selected(): return self.generate_image_google - elif is_openai_selected(): + elif is_dalle3_selected(): return self.generate_image_openai return None + def is_stock_provider_selected(self): + return is_pixels_selected() or is_pixabay_selected() + async def generate_image(self, prompt: ImagePrompt) -> str | ImageAsset: + """ + Generates an image based on the provided prompt. + - If no image generation function is available, returns a placeholder image. + - If the stock provider is selected, it uses the prompt directly, + otherwise it uses the full image prompt with theme. + - Output Directory is used for saving the generated image not the stock provider. + """ if not self.image_gen_func: print("No image generation function found. Using placeholder image.") return "/static/images/placeholder.jpg" - image_prompt = prompt.get_image_prompt(not self.use_pexels) + image_prompt = prompt.get_image_prompt(with_theme=not self.is_stock_provider_selected()) print(f"Request - Generating Image for {image_prompt}") try: - image_path = await self.image_gen_func(image_prompt, self.output_directory) + if self.is_stock_provider_selected(): + image_path = await self.image_gen_func(image_prompt) + else: + image_path = await self.image_gen_func(image_prompt, self.output_directory) if image_path: if image_path.startswith("http"): return image_path @@ -102,3 +118,12 @@ class ImageGenerationService: data = await response.json() image_url = data["photos"][0]["src"]["large"] return image_url + + async def get_image_from_pixabay(self, prompt: str) -> str: + async with aiohttp.ClientSession() as session: + response = await session.get( + f"https://pixabay.com/api/?key={os.getenv('PIXABAY_API_KEY')}&q={prompt}&image_type=photo&per_page=3" + ) + data = await response.json() + image_url = data["hits"][0]["largeImageURL"] + return image_url diff --git a/servers/fastapi/tests/test_image_generation.py b/servers/fastapi/tests/test_image_generation.py new file mode 100644 index 00000000..e13ef590 --- /dev/null +++ b/servers/fastapi/tests/test_image_generation.py @@ -0,0 +1,400 @@ +import pytest +import asyncio +import os +from unittest.mock import Mock, patch, AsyncMock +import httpx +from fastapi.testclient import TestClient +from fastapi import FastAPI +from api.v1.ppt.endpoints.images import IMAGES_ROUTER +from models.image_prompt import ImagePrompt +from services.image_generation_service import ImageGenerationService +from models.sql.image_asset import ImageAsset + + +class TestImageGenerationService: + """ + Testing the image Generation Service + """ + + @pytest.fixture + def mock_images_directory(self, tmp_path): + """ + Creates new images directory for every test case we run + """ + images_dir = tmp_path / "images" + images_dir.mkdir() + return str(images_dir) + + @pytest.fixture + def sample_image_prompt(self): + """ + Creates a sample ImagePrompt for testing + """ + return ImagePrompt(prompt="A beautiful sunset over mountains") + + def test_image_generation_service_initialization(self, mock_images_directory): + """ + Test initialization of ImageGenerationService with output directory + - Checks if the output directory is set correctly + - Checks if the image generation function is set based on environment variable + """ + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}): + service = ImageGenerationService(mock_images_directory) + assert service.output_directory == mock_images_directory + assert service.image_gen_func is not None or service.image_gen_func is None + + def test_get_image_gen_func_pixabay_selected(self, mock_images_directory): + """ + Testing the function selection when Pixabay is selected + - Checks if the correct function is selected based on environment variable + - Ensures that the function is set to get_image_from_pixabay when Pixabay is selected + """ + with patch('services.image_generation_service.is_pixabay_selected', return_value=True): + with patch('services.image_generation_service.is_pixels_selected', return_value=False): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=False): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}): + service = ImageGenerationService(mock_images_directory) + assert service.image_gen_func == service.get_image_from_pixabay + + def test_get_image_gen_func_pexels_selected(self, mock_images_directory): + """ + Test function selection when Pexels is selected + - Checks if the correct function is selected based on environment variable + - Ensures that the function is set to get_image_from_pexels when Pexels is selected + """ + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch('services.image_generation_service.is_pixels_selected', return_value=True): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=False): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}): + service = ImageGenerationService(mock_images_directory) + assert service.image_gen_func == service.get_image_from_pexels + + def test_get_image_gen_func_dalle3_selected(self, mock_images_directory): + """ + Test function selection when DALL-E 3 is selected + - Checks if the correct function is selected based on environment variable + - Ensures that the function is set to generate_image_openai when DALL-E 3 is selected + """ + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch('services.image_generation_service.is_pixels_selected', return_value=False): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=True): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "dall-e-3"}): + service = ImageGenerationService(mock_images_directory) + assert service.image_gen_func == service.generate_image_openai + + def test_is_stock_provider_selected(self, mock_images_directory): + """ + Test if stock provider is selected based on environment variable + - Checks if the stock provider is selected correctly based on environment variable + - Ensures that is_stock_provider_selected returns True for Pexels or Pixabay + """ + with patch('services.image_generation_service.is_pixels_selected', return_value=True): + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}): + service = ImageGenerationService(mock_images_directory) + assert service.is_stock_provider_selected() is True + + with patch('services.image_generation_service.is_pixels_selected', return_value=False): + with patch('services.image_generation_service.is_pixabay_selected', return_value=True): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}): + service = ImageGenerationService(mock_images_directory) + assert service.is_stock_provider_selected() is True + + with patch('services.image_generation_service.is_pixels_selected', return_value=False): + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "dall-e-3"}): + service = ImageGenerationService(mock_images_directory) + assert service.is_stock_provider_selected() is False + + def test_generate_image_with_pexels_success(self, mock_images_directory, sample_image_prompt): + """ + Test successful image generation with Pexels provider + - Mocks the Pexels API to return a valid image URL + - Ensures that the image generation function returns the expected URL + - Checks if the image generation function is called with the correct prompt + """ + async def run_test(): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels", "PEXELS_API_KEY": "test_key"}): + with patch('services.image_generation_service.is_pixels_selected', return_value=True): + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=False): + service = ImageGenerationService(mock_images_directory) + + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={ + "photos": [{ + "src": { + "large": "https://example.com/image.jpg" + } + }] + }) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('aiohttp.ClientSession', return_value=mock_session): + result = await service.generate_image(sample_image_prompt) + assert result == "https://example.com/image.jpg" + + asyncio.run(run_test()) + + def test_generate_image_with_dalle3_success(self, mock_images_directory, sample_image_prompt): + """ + Test successful image generation with DALL-E 3 provider + - Mocks the OpenAI client to return a valid image URL + - Ensures that the image generation function returns the expected URL + - Checks if the image generation function is called with the correct prompt + """ + async def run_test(): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "dall-e-3"}): + with patch('services.image_generation_service.is_pixels_selected', return_value=False): + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=True): + service = ImageGenerationService(mock_images_directory) + + # Create a real test file + test_image_path = f"{mock_images_directory}/test_image.jpg" + with open(test_image_path, 'w') as f: + f.write("fake image content") + + # Mock generate_image_openai to return the test file path + async def mock_openai_generate(prompt, output_dir): + return test_image_path + + service.generate_image_openai = mock_openai_generate + + result = await service.generate_image(sample_image_prompt) + + # Should return ImageAsset for AI providers + assert isinstance(result, ImageAsset) + assert result.path == test_image_path + assert result.extras["prompt"] == sample_image_prompt.prompt + + def test_generate_image_no_provider_selected(self, mock_images_directory, sample_image_prompt): + """ + Test generate_image when no provider is selected + - Mocks the environment variable to simulate no provider selected + - Ensures that the function returns a placeholder image path + - Checks if the image generation function is called with the correct prompt + """ + async def run_test(): + with patch('services.image_generation_service.is_pixels_selected', return_value=False): + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=False): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}): + service = ImageGenerationService(mock_images_directory) + + result = await service.generate_image(sample_image_prompt) + + # Should return placeholder + assert result == "/static/images/placeholder.jpg" + + asyncio.run(run_test()) + + def test_generate_image_provider_error(self, mock_images_directory, sample_image_prompt): + """ + Test generate_image when provider function raises an error + - Mocks the Pexels API to raise an exception + - Ensures that the function returns a placeholder image path + - Checks if the image generation function is called with the correct prompt + """ + async def run_test(): + with patch('services.image_generation_service.is_pixels_selected', return_value=True): + with patch('services.image_generation_service.is_pixabay_selected', return_value=False): + with patch('services.image_generation_service.is_imagen_selected', return_value=False): + with patch('services.image_generation_service.is_dalle3_selected', return_value=False): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}): + service = ImageGenerationService(mock_images_directory) + + async def mock_pexels_error(*args, **kwargs): + raise Exception("API Error") + + service.get_image_from_pexels = mock_pexels_error + + result = await service.generate_image(sample_image_prompt) + + assert result == "/static/images/placeholder.jpg" + + asyncio.run(run_test()) + + def test_get_image_from_pexels_real_function(self, mock_images_directory): + """T + Test REAL Pexels function with mocked HTTP call + - Mocks the Pexels API to return a valid image URL + - Ensures that the function returns the expected URL + - Checks if the HTTP call is made with the correct parameters + """ + async def run_test(): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels", "PEXELS_API_KEY": "test_pexels_key"}): + service = ImageGenerationService(mock_images_directory) + + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={ + "photos": [{ + "src": { + "large": "https://example.com/pexels_image.jpg" + } + }] + }) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('aiohttp.ClientSession', return_value=mock_session): + result = await service.get_image_from_pexels("sunset") + + assert result == "https://example.com/pexels_image.jpg" + mock_session.get.assert_called_once() + + asyncio.run(run_test()) + + def test_get_image_from_pixabay_real_function(self, mock_images_directory): + """ + Test REAL Pixabay function with mocked HTTP call + - Mocks the Pixabay API to return a valid image URL + - Ensures that the function returns the expected URL + - Checks if the HTTP call is made with the correct parameters + """ + async def run_test(): + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay", "PIXABAY_API_KEY": "test_pixabay_key"}): + service = ImageGenerationService(mock_images_directory) + + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={ + "hits": [{ + "largeImageURL": "https://example.com/pixabay_image.jpg" + }] + }) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('aiohttp.ClientSession', return_value=mock_session): + result = await service.get_image_from_pixabay("sunset") + + assert result == "https://example.com/pixabay_image.jpg" + mock_session.get.assert_called_once() + + asyncio.run(run_test()) + + +class TestImageGenerationEndpoint: + """ + Testing the Image Generation API Endpoint + """ + + @pytest.fixture + def app(self): + """Create FastAPI app with the images router""" + app = FastAPI() + app.include_router(IMAGES_ROUTER) + return app + + @pytest.fixture + def client(self, app): + """Create test client""" + return TestClient(app) + + @pytest.fixture + def mock_images_directory(self, tmp_path): + """Mock images directory""" + images_dir = tmp_path / "images" + images_dir.mkdir() + return str(images_dir) + + def test_generate_image_endpoint_success_stock_provider(self, client, mock_images_directory): + """ + Test successful image generation via API endpoint with stock provider + - Mocks the ImageGenerationService to return a stock image URL + - Ensures that the endpoint returns the expected URL + - Checks if the image generation function is called with the correct prompt + """ + test_prompt = "A beautiful sunset over mountains" + + with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory): + with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class: + mock_service_instance = Mock() + mock_service_instance.generate_image = AsyncMock(return_value="https://example.com/stock_image.jpg") + mock_service_class.return_value = mock_service_instance + response = client.get(f"/images/generate?prompt={test_prompt}") + assert response.status_code == 200 + + def test_generate_image_endpoint_success_ai_provider(self, client, mock_images_directory): + """ + Test successful image generation via API endpoint with AI provider + - Mocks the ImageGenerationService to return an ImageAsset object + - Ensures that the endpoint returns the expected ImageAsset object + - Checks if the image generation function is called with the correct prompt + """ + test_prompt = "A beautiful sunset over mountains" + + test_image_asset = ImageAsset( + path=f"{mock_images_directory}/test_image.jpg", + extras={"prompt": test_prompt, "theme_prompt": "professional"} + ) + + with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory): + with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class: + mock_service_instance = Mock() + mock_service_instance.generate_image = AsyncMock(return_value=test_image_asset) + mock_service_class.return_value = mock_service_instance + + response = client.get(f"/images/generate?prompt={test_prompt}") + + assert response.status_code == 200 + + def test_generate_image_endpoint_placeholder_response(self, client, mock_images_directory): + """ + Test endpoint returns placeholder image when no provider is selected + - Mocks the ImageGenerationService to return a placeholder image path + - Ensures that the endpoint returns the placeholder image path + - Checks if the image generation function is called with the correct prompt + """ + test_prompt = "Test prompt" + + with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory): + with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class: + mock_service_instance = Mock() + mock_service_instance.generate_image = AsyncMock(return_value="/static/images/placeholder.jpg") + mock_service_class.return_value = mock_service_instance + + response = client.get(f"/images/generate?prompt={test_prompt}") + + assert response.status_code == 200 + + def test_generate_image_endpoint_with_async_client(self, mock_images_directory): + """ + Test the image generation endpoint using an async client + - Mocks the ImageGenerationService to return a valid image URL + - Ensures that the endpoint returns the expected URL + - Checks if the image generation function is called with the correct prompt + """ + async def run_test(): + app = FastAPI() + app.include_router(IMAGES_ROUTER) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac: + with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory): + with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class: + mock_service_instance = Mock() + mock_service_instance.generate_image = AsyncMock(return_value="https://example.com/image.jpg") + mock_service_class.return_value = mock_service_instance + + response = await ac.get("/images/generate?prompt=test") + assert response.status_code == 200 + + asyncio.run(run_test()) + diff --git a/servers/fastapi/utils/get_env.py b/servers/fastapi/utils/get_env.py index 6544498b..f8ca88c7 100644 --- a/servers/fastapi/utils/get_env.py +++ b/servers/fastapi/utils/get_env.py @@ -55,3 +55,9 @@ def get_custom_model_env(): def get_pexels_api_key_env(): return os.getenv("PEXELS_API_KEY") + +def get_image_provider_env(): + return os.getenv("IMAGE_PROVIDER") + +def get_pixabay_api_key_env(): + return os.getenv("PIXABAY_API_KEY") \ No newline at end of file diff --git a/servers/fastapi/utils/image_provider.py b/servers/fastapi/utils/image_provider.py new file mode 100644 index 00000000..f095465f --- /dev/null +++ b/servers/fastapi/utils/image_provider.py @@ -0,0 +1,41 @@ +import os +from enums.image_provider import ImageProvider + + +def is_pixels_selected() -> bool: + return ImageProvider.PEXELS == get_selected_image_provider() + + +def is_pixabay_selected() -> bool: + return ImageProvider.PIXABAY == get_selected_image_provider() + + +def is_imagen_selected() -> bool: + return ImageProvider.IMAGEN == get_selected_image_provider() + + +def is_dalle3_selected() -> bool: + return ImageProvider.DALLE3 == get_selected_image_provider() + + +def get_selected_image_provider() -> ImageProvider: + """ + Get the selected image provider from environment variables. + Returns: + ImageProvider: The selected image provider. + """ + return ImageProvider(os.getenv("IMAGE_PROVIDER")) + + +def get_image_provider_api_key() -> str: + selected_image_provider = get_selected_image_provider() + if selected_image_provider == ImageProvider.PEXELS: + return os.getenv("PEXELS_API_KEY") + elif selected_image_provider == ImageProvider.PIXABAY: + return os.getenv("PIXABAY_API_KEY") + elif selected_image_provider == ImageProvider.IMAGEN: + return os.getenv("GOOGLE_API_KEY") + elif selected_image_provider == ImageProvider.DALLE3: + return os.getenv("OPENAI_API_KEY") + else: + raise ValueError(f"Invalid image provider: {selected_image_provider}") diff --git a/servers/fastapi/utils/model_availability.py b/servers/fastapi/utils/model_availability.py index 7d56291a..2a29476e 100644 --- a/servers/fastapi/utils/model_availability.py +++ b/servers/fastapi/utils/model_availability.py @@ -9,9 +9,14 @@ from utils.llm_provider import ( is_ollama_selected, ) from utils.ollama import pull_ollama_model +from utils.image_provider import ( + is_pixels_selected, + is_pixabay_selected, + is_imagen_selected, + is_dalle3_selected, +) - -async def check_llm_model_availability(): +async def check_llm_and_image_provider_api_or_model_availability(): can_change_keys = get_can_change_keys_env() != "false" if not can_change_keys: if get_llm_provider() == LLMProvider.OPENAI: @@ -58,3 +63,22 @@ async def check_llm_model_availability(): print("-" * 50) if custom_model not in models: raise Exception(f"Model {custom_model} is not available") + elif is_pixels_selected(): + pexels_api_key = os.getenv("PEXELS_API_KEY") + if not pexels_api_key: + raise Exception("PEXELS_API_KEY must be provided") + + elif is_pixabay_selected(): + pixabay_api_key = os.getenv("PIXABAY_API_KEY") + if not pixabay_api_key: + raise Exception("PIXABAY_API_KEY must be provided") + + elif is_imagen_selected(): + google_api_key = os.getenv("GOOGLE_API_KEY") + if not google_api_key: + raise Exception("GOOGLE_API_KEY must be provided") + + elif is_dalle3_selected(): + openai_api_key = os.getenv("OPENAI_API_KEY") + if not openai_api_key: + raise Exception("OPENAI_API_KEY must be provided") \ No newline at end of file diff --git a/servers/fastapi/utils/set_env.py b/servers/fastapi/utils/set_env.py index c43d85a9..fbfaf221 100644 --- a/servers/fastapi/utils/set_env.py +++ b/servers/fastapi/utils/set_env.py @@ -43,3 +43,10 @@ def set_custom_model_env(value): def set_pexels_api_key_env(value): os.environ["PEXELS_API_KEY"] = value + +def set_image_provider_env(value): + os.environ["IMAGE_PROVIDER"] = value + + +def set_pixabay_api_key_env(value): + os.environ["PIXABAY_API_KEY"] = value \ No newline at end of file diff --git a/servers/fastapi/utils/user_config.py b/servers/fastapi/utils/user_config.py index af4d6624..b1065a2b 100644 --- a/servers/fastapi/utils/user_config.py +++ b/servers/fastapi/utils/user_config.py @@ -13,6 +13,8 @@ from utils.get_env import ( get_openai_api_key_env, get_pexels_api_key_env, get_user_config_path_env, + get_image_provider_env, + get_pixabay_api_key_env ) from utils.set_env import ( set_custom_llm_api_key_env, @@ -24,6 +26,8 @@ from utils.set_env import ( set_ollama_url_env, set_openai_api_key_env, set_pexels_api_key_env, + set_image_provider_env, + set_pixabay_api_key_env ) @@ -49,6 +53,8 @@ def get_user_config(): CUSTOM_LLM_API_KEY=existing_config.CUSTOM_LLM_API_KEY or get_custom_llm_api_key_env(), CUSTOM_MODEL=existing_config.CUSTOM_MODEL or get_custom_model_env(), + IMAGE_PROVIDER=existing_config.IMAGE_PROVIDER or get_image_provider_env(), + PIXABAY_API_KEY=existing_config.PIXABAY_API_KEY or get_pixabay_api_key_env(), PEXELS_API_KEY=existing_config.PEXELS_API_KEY or get_pexels_api_key_env(), ) @@ -71,5 +77,9 @@ def update_env_with_user_config(): set_custom_llm_api_key_env(user_config.CUSTOM_LLM_API_KEY) if user_config.CUSTOM_MODEL: set_custom_model_env(user_config.CUSTOM_MODEL) + if user_config.IMAGE_PROVIDER: + set_image_provider_env(user_config.IMAGE_PROVIDER) + if user_config.PIXABAY_API_KEY: + set_pixabay_api_key_env(user_config.PIXABAY_API_KEY) if user_config.PEXELS_API_KEY: set_pexels_api_key_env(user_config.PEXELS_API_KEY) diff --git a/servers/nextjs/app/api/user-config/route.ts b/servers/nextjs/app/api/user-config/route.ts index 6fe3bccf..39ba481e 100644 --- a/servers/nextjs/app/api/user-config/route.ts +++ b/servers/nextjs/app/api/user-config/route.ts @@ -1,36 +1,36 @@ -import { NextResponse } from 'next/server'; -import fs from 'fs'; +import { NextResponse } from "next/server"; +import fs from "fs"; const userConfigPath = process.env.USER_CONFIG_PATH!; -const canChangeKeys = process.env.CAN_CHANGE_KEYS !== 'false'; - +const canChangeKeys = process.env.CAN_CHANGE_KEYS !== "false"; +console.log("UserConfigPath:", userConfigPath); export async function GET() { if (!canChangeKeys) { return NextResponse.json({ - error: 'You are not allowed to access this resource', - }) + error: "You are not allowed to access this resource", + }); } if (!fs.existsSync(userConfigPath)) { - return NextResponse.json({}) + return NextResponse.json({}); } - const configData = fs.readFileSync(userConfigPath, 'utf-8') - return NextResponse.json(JSON.parse(configData)) + const configData = fs.readFileSync(userConfigPath, "utf-8"); + return NextResponse.json(JSON.parse(configData)); } export async function POST(request: Request) { if (!canChangeKeys) { return NextResponse.json({ - error: 'You are not allowed to access this resource', - }) + error: "You are not allowed to access this resource", + }); } - const userConfig = await request.json() + const userConfig = await request.json(); - let existingConfig: LLMConfig = {} + let existingConfig: LLMConfig = {}; if (fs.existsSync(userConfigPath)) { - const configData = fs.readFileSync(userConfigPath, 'utf-8') - existingConfig = JSON.parse(configData) + const configData = fs.readFileSync(userConfigPath, "utf-8"); + existingConfig = JSON.parse(configData); } const mergedConfig: LLMConfig = { LLM: userConfig.LLM || existingConfig.LLM, @@ -39,11 +39,18 @@ export async function POST(request: Request) { OLLAMA_URL: userConfig.OLLAMA_URL || existingConfig.OLLAMA_URL, OLLAMA_MODEL: userConfig.OLLAMA_MODEL || existingConfig.OLLAMA_MODEL, CUSTOM_LLM_URL: userConfig.CUSTOM_LLM_URL || existingConfig.CUSTOM_LLM_URL, - CUSTOM_LLM_API_KEY: userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY, + CUSTOM_LLM_API_KEY: + userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY, CUSTOM_MODEL: userConfig.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL, + PIXABAY_API_KEY: + userConfig.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY, + IMAGE_PROVIDER: userConfig.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER, PEXELS_API_KEY: userConfig.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY, - USE_CUSTOM_URL: userConfig.USE_CUSTOM_URL === undefined ? existingConfig.USE_CUSTOM_URL : userConfig.USE_CUSTOM_URL, - } - fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig)) - return NextResponse.json(mergedConfig) -} \ No newline at end of file + USE_CUSTOM_URL: + userConfig.USE_CUSTOM_URL === undefined + ? existingConfig.USE_CUSTOM_URL + : userConfig.USE_CUSTOM_URL, + }; + fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig)); + return NextResponse.json(mergedConfig); +} diff --git a/servers/nextjs/app/settings/SettingPage.tsx b/servers/nextjs/app/settings/SettingPage.tsx index f42ae744..0a16c58f 100644 --- a/servers/nextjs/app/settings/SettingPage.tsx +++ b/servers/nextjs/app/settings/SettingPage.tsx @@ -1,735 +1,1044 @@ -'use client'; +"use client"; 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 { toast } from '@/hooks/use-toast'; +import { Settings, Key, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { toast } from "@/hooks/use-toast"; import { RootState } from "@/store/store"; import { useSelector } from "react-redux"; import { handleSaveLLMConfig } from "@/utils/storeHelpers"; import { useRouter } from "next/navigation"; -import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, } from "@/components/ui/command"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; +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", + }, + imagen: { + title: "imagen", + description: "Required for using Imagen 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", - } + 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; + title: string; + description: string; + placeholder: string; +} +interface ImageProviderConfig { + title: string; + description: string; + placeholder: string; + apiKeyField?: keyof LLMConfig; } const SettingsPage = () => { - const router = useRouter(); + const router = useRouter(); + const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false); - const userConfigState = useSelector((state: RootState) => state.userConfig); - 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 userConfigState = useSelector((state: RootState) => state.userConfig); + 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 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 handleSaveConfig = async () => { + try { + await handleSaveLLMConfig(llmConfig); + if (llmConfig.LLM === "ollama") { + setIsLoading(true); + await pullOllamaModels(); + } + toast({ + title: "Success", + description: "Configuration saved successfully", + }); + setIsLoading(false); + router.back(); + } catch (error) { + console.error("Error:", error); + toast({ + title: "Error", + description: + error instanceof Error + ? error.message + : "Failed to save configuration", + variant: "destructive", + }); + setIsLoading(false); + } + }; + + const fetchOllamaModelsWithConfig = async (config: any) => { + try { + const response = await fetch("/api/v1/ppt/ollama/list-supported-models"); + const data = await response.json(); + setOllamaModels(data.models); + + // Check if currently selected model is still available + if (config.OLLAMA_MODEL && data.models.length > 0) { + const isModelAvailable = data.models.some( + (model: any) => model.value === config.OLLAMA_MODEL + ); + if (!isModelAvailable) { + setLlmConfig({ ...config, OLLAMA_MODEL: "" }); + } + } + } catch (error) { + console.error("Error fetching ollama models:", error); + } + }; + + 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 [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 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 }); - } - } - - const handleSaveConfig = async () => { + const pullOllamaModels = async (): Promise => { + return new Promise((resolve, reject) => { + const interval = setInterval(async () => { try { - await handleSaveLLMConfig(llmConfig); - if (llmConfig.LLM === 'ollama') { - setIsLoading(true); - await pullOllamaModels(); - } - toast({ - title: 'Success', - description: 'Configuration saved successfully', - }); - setIsLoading(false); - router.back(); - } catch (error) { - console.error('Error:', error); - toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to save configuration', - variant: 'destructive', - }); - setIsLoading(false); - } - }; - - const fetchOllamaModelsWithConfig = async (config: any) => { - try { - const response = await fetch('/api/v1/ppt/ollama/list-supported-models'); + const response = await fetch( + `/api/v1/ppt/ollama/pull-model?name=${llmConfig.OLLAMA_MODEL}` + ); + if (response.status === 200) { const data = await response.json(); - setOllamaModels(data.models); - - // Check if currently selected model is still available - if (config.OLLAMA_MODEL && data.models.length > 0) { - const isModelAvailable = data.models.some((model: any) => model.value === config.OLLAMA_MODEL); - if (!isModelAvailable) { - setLlmConfig({ ...config, OLLAMA_MODEL: '' }); - } + 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) { - console.error('Error fetching ollama models:', error); + clearInterval(interval); + resetDownloadingModel(); + reject(error); } - } + }, 1000); + }); + }; - 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 fetchOllamaModels = async () => { + await fetchOllamaModelsWithConfig(llmConfig); + }; + + const fetchCustomModels = async () => { + try { + setCustomModelsLoading(true); + const response = await fetch("/api/v1/ppt/models/list/custom", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: llmConfig.CUSTOM_LLM_URL || "", + api_key: llmConfig.CUSTOM_LLM_API_KEY || "", + }), + }); + + 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({ + title: "Model Unavailable", + description: `The selected model "${llmConfig.CUSTOM_MODEL}" is no longer available. Please select a different model.`, + variant: "destructive", + }); } + } + } catch (error) { + console.error("Error fetching custom models:", error); + // Don't set customModelsChecked to true on error, so the button remains visible + setCustomModels([]); + toast({ + title: "Error", + description: + "Failed to fetch available models. Please check your URL and API key.", + variant: "destructive", + }); + } finally { + setCustomModelsLoading(false); } + }; - const resetDownloadingModel = () => { - setDownloadingModel({ - name: '', - size: null, - downloaded: null, - status: '', - done: false, - }); + const setOllamaConfig = () => { + if (!useCustomOllamaUrl) { + setLlmConfig({ + ...llmConfig, + OLLAMA_URL: "http://localhost:11434", + USE_CUSTOM_URL: false, + }); + } else { + setLlmConfig({ ...llmConfig, USE_CUSTOM_URL: true }); } + }; - const pullOllamaModels = async (): Promise => { - return new Promise((resolve, reject) => { - const interval = setInterval(async () => { - try { - const response = await fetch(`/api/v1/ppt/ollama/pull-model?name=${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/models/list/custom', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url: llmConfig.CUSTOM_LLM_URL || '', - api_key: llmConfig.CUSTOM_LLM_API_KEY || '' - }) - }); - - 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({ - title: 'Model Unavailable', - description: `The selected model "${llmConfig.CUSTOM_MODEL}" is no longer available. Please select a different model.`, - variant: 'destructive', - }); - } - } - } catch (error) { - console.error('Error fetching custom models:', error); - // Don't set customModelsChecked to true on error, so the button remains visible - setCustomModels([]); - toast({ - title: 'Error', - description: 'Failed to fetch available models. Please check your URL and API key.', - variant: 'destructive', - }); - } 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 }); - } - } - - 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 }); - } - - 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]); - + 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, + }); + }; + useEffect(() => { if (!canChangeKeys) { - return null; + 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]); - return ( -
-
- -
- {/* Settings Header */} -
- -

Settings

+ useEffect(() => { + setOllamaConfig(); + }, [useCustomOllamaUrl]); + + if (!canChangeKeys) { + return null; + } + + return ( +
+
+ +
+ {/* Settings Header */} +
+ +

Settings

+
+ + {/* API Configuration Section */} +
+
+ +

+ API Configuration +

+
+ + {/* Provider Selection */} +
+ +
+ {Object.keys(PROVIDER_CONFIGS).map((provider) => ( + + ))} +
+
- {/* API Configuration Section */} -
-
- -

API Configuration

-
- - {/* 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 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

-
-
- )} - - {/* Save Button */} - - - { - llmConfig.LLM === 'ollama' && downloadingModel.status && downloadingModel.status !== 'pulled' && ( -
- {downloadingModel.status} -
- ) - } -
+ {/* 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 === "imagen" && 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 !== "pulled" && ( +
+ {downloadingModel.status} +
+ )} +
- ); +
+
+ ); }; export default SettingsPage; diff --git a/servers/nextjs/components/Home.tsx b/servers/nextjs/components/Home.tsx index 36d1bc30..1f960d91 100644 --- a/servers/nextjs/components/Home.tsx +++ b/servers/nextjs/components/Home.tsx @@ -2,864 +2,1142 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { toast } from "@/hooks/use-toast"; -import { Info, ExternalLink, PlayCircle, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { + Info, + ExternalLink, + PlayCircle, + Loader2, + Check, + ChevronsUpDown, +} from "lucide-react"; import Link from "next/link"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, } from "@/components/ui/accordion"; import { useSelector } from "react-redux"; import { RootState } from "@/store/store"; import { handleSaveLLMConfig } from "@/utils/storeHelpers"; -import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { Button } from "./ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, } from "./ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "./ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { cn } from "@/lib/utils"; import { Switch } from "./ui/switch"; import { setLLMConfig } from "@/store/slices/userConfig"; interface ModelOption { - value: string; - label: string; - description?: string; - icon?: string; - size: string; + value: string; + label: string; + description?: string; + icon?: string; + size: 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; - }; + 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", + }, + imagen: { + value: "imagen", + label: "Imagen", + 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", - }, + 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: "imagen", - label: "Imagen", - 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", - }, + }, + google: { + textModels: [ + { + value: "gemini-pro", + label: "Gemini Pro", + description: "Balanced model for most tasks", + icon: "/icons/google.png", + size: "8GB", + }, + ], + imageModels: [ + { + value: "imagen", + label: "Imagen", + 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/", - }, + }, + 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/", - }, + }, + 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 config = useSelector((state: RootState) => state.userConfig); - const [llmConfig, setLlmConfig] = useState(config.llm_config); - 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 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 [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 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 handleSaveConfig = async () => { + try { + await handleSaveLLMConfig(llmConfig); + if (llmConfig.LLM === "ollama") { + setIsLoading(true); + await pullOllamaModels(); + } + toast({ + title: "Success", + description: "Configuration saved successfully", + }); + setIsLoading(false); + router.push("/upload"); + } catch (error) { + console.error("Error:", error); + toast({ + title: "Error", + description: + error instanceof Error + ? error.message + : "Failed to save configuration", + variant: "destructive", + }); + setIsLoading(false); + } + }; + + const fetchOllamaModelsWithConfig = async (config: any) => { + try { + const response = await fetch("/api/v1/ppt/ollama/list-supported-models"); + const data = await response.json(); + setOllamaModels(data.models || []); + + // Check if currently selected model is still available + if (config.OLLAMA_MODEL && data.models && data.models.length > 0) { + const isModelAvailable = data.models.some( + (model: any) => model.value === config.OLLAMA_MODEL + ); + if (!isModelAvailable) { + setLlmConfig({ ...config, OLLAMA_MODEL: "" }); + } + } + } catch (error) { + console.error("Error fetching ollama models:", error); + setOllamaModels([]); // Ensure we always set an empty array on error + } + }; + + 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 = "imagen"; + } 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 [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 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 }); - } - } - - const handleSaveConfig = async () => { + const pullOllamaModels = async (): Promise => { + return new Promise((resolve, reject) => { + const interval = setInterval(async () => { try { - await handleSaveLLMConfig(llmConfig); - if (llmConfig.LLM === 'ollama') { - setIsLoading(true); - await pullOllamaModels(); - } - toast({ - title: 'Success', - description: 'Configuration saved successfully', - }); - setIsLoading(false); - router.push("/upload"); - } catch (error) { - console.error('Error:', error); - toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to save configuration', - variant: 'destructive', - }); - setIsLoading(false); - } - }; - - const fetchOllamaModelsWithConfig = async (config: any) => { - try { - const response = await fetch('/api/v1/ppt/ollama/list-supported-models'); + const response = await fetch( + `/api/v1/ppt/ollama/pull-model?name=${llmConfig.OLLAMA_MODEL}` + ); + if (response.status === 200) { const data = await response.json(); - setOllamaModels(data.models); - - // Check if currently selected model is still available - if (config.OLLAMA_MODEL && data.models.length > 0) { - const isModelAvailable = data.models.some((model: any) => model.value === config.OLLAMA_MODEL); - if (!isModelAvailable) { - setLlmConfig({ ...config, OLLAMA_MODEL: '' }); - } + 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); } - } catch (error) { - console.error('Error fetching ollama models:', error); - } - } - - 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/pull-model?name=${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/models/list/custom', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url: llmConfig.CUSTOM_LLM_URL || '', - api_key: llmConfig.CUSTOM_LLM_API_KEY || '' - }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + } else { + clearInterval(interval); + resetDownloadingModel(); + if (response.status === 403) { + reject(new Error("Request to Ollama Not Authorized")); } - - const data = await response.json(); - setCustomModels(data); - // Only set customModelsChecked to true if the API call succeeds - setCustomModelsChecked(true); + reject(new Error("Error occurred while pulling 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({ - title: 'Error', - description: 'Failed to fetch available models. Please check your URL and API key.', - variant: 'destructive', - }); - } finally { - setCustomModelsLoading(false); + 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/models/list/custom", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: llmConfig.CUSTOM_LLM_URL || "", + api_key: llmConfig.CUSTOM_LLM_API_KEY || "", + }), + }); + + 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({ + title: "Error", + description: + "Failed to fetch available models. Please check your URL and API key.", + variant: "destructive", + }); + } 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 }); - } + const setOllamaConfig = () => { + if (!useCustomOllamaUrl) { + setLlmConfig({ + ...llmConfig, + OLLAMA_URL: "http://localhost:11434", + USE_CUSTOM_URL: false, + }); + } else { + setLlmConfig({ ...llmConfig, USE_CUSTOM_URL: true }); } + }; - 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]); - + useEffect(() => { if (!canChangeKeys) { - return null; + router.push("/upload"); } + if (llmConfig.LLM === "ollama") { + fetchOllamaModels(); + } + }, []); - return ( -
-
- {/* Branding Header */} -
-
- Presenton Logo -
-

- Open-source AI presentation generator -

-
+ useEffect(() => { + setOllamaConfig(); + }, [useCustomOllamaUrl]); - {/* Main Configuration Card */} -
- {/* Provider Selection */} -
- -
- {Object.keys(PROVIDER_CONFIGS).map((provider) => ( - - ))} -
-
+ // 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]); - {/* 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" - /> -
-

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

-
} - { - llmConfig.LLM === 'ollama' && (
-
- -
- {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.length === 0 && ( -

- Loading available models... -

- )} -
-
-
- - -
- {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 -

-
-
) - } - { - llmConfig.LLM === 'custom' && ( - <> -
- -
- input_field_changed(e.target.value, 'custom_llm_url')} - /> -
-
-
- -
- input_field_changed(e.target.value, '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 + // } - {/* 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) => ( - { - input_field_changed(value, 'custom_model'); - setOpenModelSelect(false); - }} - > - - - {model} - - - ))} - - - - - -
-
- )} + // setLlmConfig((prev) => ({ + // ...prev, + // IMAGE_PROVIDER: defaultImageProvider, + // })); + // } + // }, [llmConfig.LLM]); - {/* Check for available models button - show when no models checked or no models found */} - {(!customModelsChecked || (customModelsChecked && customModels.length === 0)) && ( -
- -
- )} + if (!canChangeKeys) { + return null; + } - {/* Show message if no models found */} - {customModelsChecked && customModels.length === 0 && ( -
-

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

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

- - Provide a Pexels API key to generate presentation images -

-
- - ) - } - - {/* 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 {PROVIDER_CONFIGS[llmConfig.LLM!].imageModels[0].label} for - images -

-

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

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

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

-
-
- -
-
    - {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.steps.map((step, index) => ( -
  1. - {step} -
  2. - ))} -
- -
- {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.videoUrl && ( - - - Watch Video Tutorial - - - )} - - Official Documentation - - -
-
-
-
-
- - {/* Save Button */} - - - { - llmConfig.LLM === 'ollama' && downloadingModel.status && downloadingModel.status !== 'pulled' && ( -
- {downloadingModel.status} -
- ) - } -
-
+ return ( +
+
+ {/* Branding Header */} +
+
+ 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" + /> +
+

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

+
+ )} + {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 && ( +
+
+

+ 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 === "imagen" && 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 ?? "_____" + : 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} +

+
+
+ +
+
    + {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.steps.map( + (step, index) => ( +
  1. + {step} +
  2. + ) + )} +
+ +
+ {PROVIDER_CONFIGS[llmConfig.LLM!].apiGuide.videoUrl && ( + + + Watch Video Tutorial + + + )} + + Official Documentation + + +
+
+
+
+
+ + {/* Save Button */} + + + {llmConfig.LLM === "ollama" && + downloadingModel.status && + downloadingModel.status !== "pulled" && ( +
+ {downloadingModel.status} +
+ )} +
+
+
+ ); } diff --git a/servers/nextjs/types/global.d.ts b/servers/nextjs/types/global.d.ts index 1e869dae..2dee9ebb 100644 --- a/servers/nextjs/types/global.d.ts +++ b/servers/nextjs/types/global.d.ts @@ -22,6 +22,8 @@ interface LLMConfig { CUSTOM_LLM_URL?: string; CUSTOM_LLM_API_KEY?: string; CUSTOM_MODEL?: string; + IMAGE_PROVIDER?: string; + PIXABAY_API_KEY?: string; PEXELS_API_KEY?: string; // Only used in UI settings diff --git a/servers/nextjs/utils/storeHelpers.ts b/servers/nextjs/utils/storeHelpers.ts index 07b05069..773f9637 100644 --- a/servers/nextjs/utils/storeHelpers.ts +++ b/servers/nextjs/utils/storeHelpers.ts @@ -3,29 +3,68 @@ import { store } from "@/store/store"; export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => { if (!hasValidLLMConfig(llmConfig)) { - throw new Error('Provided configuration is not valid'); + throw new Error("Provided configuration is not valid"); } - - await fetch('/api/user-config', { - method: 'POST', - body: JSON.stringify(llmConfig) + console.log("StoreHelperLLMConfig: Saving LLM config", llmConfig); + await fetch("/api/user-config", { + method: "POST", + body: JSON.stringify(llmConfig), }); store.dispatch(setLLMConfig(llmConfig)); -} +}; export const hasValidLLMConfig = (llmConfig: LLMConfig) => { if (!llmConfig.LLM) return false; + if (!llmConfig.IMAGE_PROVIDER) return false; const OPENAI_API_KEY = llmConfig.OPENAI_API_KEY; const GOOGLE_API_KEY = llmConfig.GOOGLE_API_KEY; - const isOllamaConfigValid = llmConfig.OLLAMA_MODEL !== '' && llmConfig.OLLAMA_MODEL !== null && llmConfig.OLLAMA_MODEL !== undefined && llmConfig.OLLAMA_URL !== '' && llmConfig.OLLAMA_URL !== null && llmConfig.OLLAMA_URL !== undefined; - const isCustomConfigValid = llmConfig.CUSTOM_LLM_URL !== '' && llmConfig.CUSTOM_LLM_URL !== null && llmConfig.CUSTOM_LLM_URL !== undefined && llmConfig.CUSTOM_MODEL !== '' && llmConfig.CUSTOM_MODEL !== null && llmConfig.CUSTOM_MODEL !== undefined; + const isOllamaConfigValid = + llmConfig.OLLAMA_MODEL !== "" && + llmConfig.OLLAMA_MODEL !== null && + llmConfig.OLLAMA_MODEL !== undefined && + llmConfig.OLLAMA_URL !== "" && + llmConfig.OLLAMA_URL !== null && + llmConfig.OLLAMA_URL !== undefined; - return llmConfig.LLM === 'openai' ? - OPENAI_API_KEY !== '' && OPENAI_API_KEY !== null && OPENAI_API_KEY !== undefined : - llmConfig.LLM === 'google' ? - GOOGLE_API_KEY !== '' && GOOGLE_API_KEY !== null && GOOGLE_API_KEY !== undefined : - llmConfig.LLM === 'ollama' ? isOllamaConfigValid : - llmConfig.LLM === 'custom' ? isCustomConfigValid : false; -} \ No newline at end of file + const isCustomConfigValid = + llmConfig.CUSTOM_LLM_URL !== "" && + llmConfig.CUSTOM_LLM_URL !== null && + llmConfig.CUSTOM_LLM_URL !== undefined && + llmConfig.CUSTOM_MODEL !== "" && + llmConfig.CUSTOM_MODEL !== null && + llmConfig.CUSTOM_MODEL !== undefined; + + const isImageConfigValid = () => { + switch (llmConfig.IMAGE_PROVIDER) { + case "pexels": + return llmConfig.PEXELS_API_KEY && llmConfig.PEXELS_API_KEY !== ""; + case "pixabay": + return llmConfig.PIXABAY_API_KEY && llmConfig.PIXABAY_API_KEY !== ""; + case "dall-e-3": + return OPENAI_API_KEY && OPENAI_API_KEY !== ""; + case "imagen": + return GOOGLE_API_KEY && GOOGLE_API_KEY !== ""; + default: + return false; + } + }; + + const isLLMConfigValid = + llmConfig.LLM === "openai" + ? OPENAI_API_KEY !== "" && + OPENAI_API_KEY !== null && + OPENAI_API_KEY !== undefined + : llmConfig.LLM === "google" + ? GOOGLE_API_KEY !== "" && + GOOGLE_API_KEY !== null && + GOOGLE_API_KEY !== undefined + : llmConfig.LLM === "ollama" + ? isOllamaConfigValid + : llmConfig.LLM === "custom" + ? isCustomConfigValid + : false; + + return isLLMConfigValid && isImageConfigValid(); +}; diff --git a/start.js b/start.js index 18bdf238..67056780 100644 --- a/start.js +++ b/start.js @@ -1,3 +1,5 @@ +/* This script starts the FastAPI and Next.js servers, setting up user configuration if necessary. It reads environment variables to configure API keys and other settings, ensuring that the user configuration file is created if it doesn't exist. The script also handles the starting of both servers and keeps the Node.js process alive until one of the servers exits. */ + const path = require('path'); const { spawn } = require('child_process'); const fs = require('fs'); @@ -43,12 +45,13 @@ const setupUserConfigFromEnv = () => { CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY, CUSTOM_MODEL: process.env.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL, PEXELS_API_KEY: process.env.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY, + PIXABAY_API_KEY: process.env.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY, + IMAGE_PROVIDER: process.env.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER, USE_CUSTOM_URL: process.env.USE_CUSTOM_URL || existingConfig.USE_CUSTOM_URL, }; fs.writeFileSync(userConfigPath, JSON.stringify(userConfig)); } - const startServers = async () => { const fastApiProcess = spawn(