From 21dca979ce87eef86c88c1a26701cf3400ca74d7 Mon Sep 17 00:00:00 2001 From: sudipnext Date: Fri, 18 Jul 2025 17:52:23 +0545 Subject: [PATCH 01/10] 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( From e57b21838a1f6bb023640afdcbec0b0b313dceb1 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Fri, 18 Jul 2025 23:18:49 +0545 Subject: [PATCH 02/10] fix(Nextjs): Editable Text ClassName error --- .../components/TiptapText.tsx | 4 +- .../components/TiptapTextReplacer.tsx | 61 +++++++++++++++++-- .../hooks/useGroupLayouts.tsx | 1 - 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx index ee140619..aebfdfbf 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx @@ -61,10 +61,10 @@ const TiptapText: React.FC = ({ } return ( -
+
{!disabled && ( -
+
); } - if (isEditMode) { return ( Date: Sat, 19 Jul 2025 01:07:44 +0545 Subject: [PATCH 03/10] feat(Nextjs): Text saving to store & Auto saving --- .../components/SmartEditableWrapper.tsx | 6 - .../components/TiptapTextReplacer.tsx | 8 +- .../hooks/useGroupLayouts.tsx | 18 +- .../presentation/components/Header.tsx | 9 +- .../components/PresentationPage.tsx | 47 +-- .../presentation/hooks/index.ts | 3 +- .../presentation/hooks/useAutoSave.tsx | 80 ++++ .../presentation/hooks/usePresentationData.ts | 1 - .../hooks/usePresentationStreaming.ts | 1 - .../presentation/utils/debounce.ts | 21 - .../services/api/presentation-generation.ts | 108 +----- .../store/slices/presentationGeneration.ts | 361 +++--------------- 12 files changed, 190 insertions(+), 473 deletions(-) create mode 100644 servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx delete mode 100644 servers/nextjs/app/(presentation-generator)/presentation/utils/debounce.ts diff --git a/servers/nextjs/app/(presentation-generator)/components/SmartEditableWrapper.tsx b/servers/nextjs/app/(presentation-generator)/components/SmartEditableWrapper.tsx index 730c98d8..30781d00 100644 --- a/servers/nextjs/app/(presentation-generator)/components/SmartEditableWrapper.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/SmartEditableWrapper.tsx @@ -61,7 +61,6 @@ export const SmartEditableProvider: React.FC = ({ const findEditableElements = () => { const elements: EditableElement[] = []; - console.log('🔍 Starting smart detection with slideData:', slideData); // Detect Images and Icons only (text is now handled by SmartText components) const detectEditableElementsFromData = (data: any, path: string = '') => { @@ -69,7 +68,6 @@ export const SmartEditableProvider: React.FC = ({ // Check for __image_url__ pattern if (data.__image_url__) { - console.log(`📸 Found __image_url__ at ${path}:`, data.__image_url__); const imgElement = findDOMElementByImageUrl(container, data.__image_url__); if (imgElement) { elements.push({ @@ -83,13 +81,11 @@ export const SmartEditableProvider: React.FC = ({ imageIdx: elements.filter(e => e.type === 'image').length } }); - console.log(`✅ Matched image to DOM element:`, imgElement); } } // Check for __icon_url__ pattern if (data.__icon_url__) { - console.log(`🎯 Found __icon_url__ at ${path}:`, data.__icon_url__); const imgElement = findDOMElementByImageUrl(container, data.__icon_url__); if (imgElement) { elements.push({ @@ -106,7 +102,6 @@ export const SmartEditableProvider: React.FC = ({ icon_prompt: data.__icon_query__ ? [data.__icon_query__] : [] } }); - console.log(`✅ Matched icon to DOM element:`, imgElement); } } // Recursively scan nested objects and arrays @@ -125,7 +120,6 @@ export const SmartEditableProvider: React.FC = ({ }; detectEditableElementsFromData(slideData); - console.log('🎉 Final detected elements:', elements); setEditableElements(elements); }; diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx index ed1a14b1..742473fe 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx @@ -10,7 +10,8 @@ interface TiptapTextReplacerProps { }>; children: ReactNode; slideData?: any; - onContentChange?: (content: string, path: string) => void; + slideIndex?: number; + onContentChange?: (content: string, path: string, slideIndex?: number) => void; isEditMode?: boolean; } @@ -18,6 +19,7 @@ const TiptapTextReplacer: React.FC = ({ children, slideData, layout, + slideIndex, onContentChange = () => { }, isEditMode = true }) => { @@ -109,7 +111,7 @@ const TiptapTextReplacer: React.FC = ({ content={trimmedText} onContentChange={(content: string) => { if (dataPath && onContentChange) { - onContentChange(content, dataPath); + onContentChange(content, dataPath, slideIndex); } }} placeholder="Enter text..." @@ -257,7 +259,7 @@ const TiptapTextReplacer: React.FC = ({ return () => { clearTimeout(timer); }; - }, [slideData, isEditMode]); + }, [slideData, isEditMode, slideIndex]); return (
diff --git a/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx b/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx index 0725b096..506ca576 100644 --- a/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx +++ b/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx @@ -1,10 +1,13 @@ 'use client' import React, { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { useLayout } from '../context/LayoutContext'; import { SmartEditableProvider } from '../components/SmartEditableWrapper'; import TiptapTextReplacer from '../components/TiptapTextReplacer'; +import { updateSlideContent } from '../../../store/slices/presentationGeneration'; export const useGroupLayouts = () => { + const dispatch = useDispatch(); const { getLayoutByIdAndGroup, getLayoutsByGroup, @@ -53,11 +56,20 @@ export const useGroupLayouts = () => { > { - console.log(`Text content changed at ${dataPath}:`, content); + onContentChange={(content: string, dataPath: string, slideIndex?: number) => { + console.log(`Text content changed at slide ${slideIndex}, path ${dataPath}:`, content); + // Dispatch Redux action to update slide content + if (dataPath && slideIndex !== undefined) { + dispatch(updateSlideContent({ + slideIndex: slideIndex, + dataPath: dataPath, + content: content + })); + } }} > @@ -67,7 +79,7 @@ export const useGroupLayouts = () => { } return ; }; - }, [getGroupLayout]); + }, [getGroupLayout, dispatch]); return { getGroupLayout, diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx index 02a338c7..640beb24 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx @@ -47,7 +47,6 @@ import Modal from "./Modal"; import Announcement from "@/components/Announcement"; import { getFontLink, getStaticFileUrl } from "../../utils/others"; -import JSPowerPointExtractor from "../../components/JSPowerPointExtractor"; const Header = ({ @@ -108,13 +107,7 @@ const Header = ({ themeColors.slideBox ); - // Save in background - await PresentationGenerationApi.setThemeColors(presentation_id, { - name: themeType, - colors: { - ...themeColors, - }, - }); + } catch (error) { console.error("Failed to update theme:", error); toast({ diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx index 87f34916..7a368ce5 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx @@ -8,14 +8,14 @@ import SidePanel from "../components/SidePanel"; import SlideContent from "../components/SlideContent"; import LoadingState from "../../components/LoadingState"; import Header from "../components/Header"; -import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, Loader2 } from "lucide-react"; import Help from "./Help"; import { usePresentationStreaming, usePresentationData, - usePresentationNavigation + usePresentationNavigation, + useAutoSave } from "../hooks"; import { PresentationPageProps } from "../types"; @@ -26,7 +26,6 @@ const PresentationPage: React.FC = ({ presentation_id }) const [isFullscreen, setIsFullscreen] = useState(false); const [error, setError] = useState(false); const [isMobilePanelOpen, setIsMobilePanelOpen] = useState(false); - const [autoSaveLoading, setAutoSaveLoading] = useState(false); // Redux state const { currentTheme, currentColors } = useSelector( @@ -36,13 +35,19 @@ const PresentationPage: React.FC = ({ presentation_id }) (state: RootState) => state.presentationGeneration ); + // Auto-save functionality + const { isSaving } = useAutoSave({ + debounceMs: 2000, + enabled: !!presentationData && !isStreaming, + + }); + // Custom hooks const { fetchUserSlides, handleDeleteSlide } = usePresentationData( presentation_id, setLoading, setError ); - const { isPresentMode, stream, @@ -98,33 +103,29 @@ const PresentationPage: React.FC = ({ presentation_id }) role="alert" > - Oops! -

- We encountered an issue loading your presentation. +

+ Something went wrong +

+

+ We couldn't load your presentation. Please try again.

-

- Please check your internet connection or try again later. -

-
); } - return (
- {/* Auto save loading indicator */} - {autoSaveLoading && ( -
- -
- )} + +
+ {isSaving && ( + + )} + +
diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/index.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/index.ts index c416e2b9..9191e5d5 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/index.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/index.ts @@ -1,3 +1,4 @@ export { usePresentationStreaming } from './usePresentationStreaming'; export { usePresentationData } from './usePresentationData'; -export { usePresentationNavigation } from './usePresentationNavigation'; \ No newline at end of file +export { usePresentationNavigation } from './usePresentationNavigation'; +export { useAutoSave } from './useAutoSave'; \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx b/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx new file mode 100644 index 00000000..596e2405 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx @@ -0,0 +1,80 @@ +'use client' +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/store/store'; +import { PresentationGenerationApi } from '../../services/api/presentation-generation'; + +interface UseAutoSaveOptions { + debounceMs?: number; + enabled?: boolean; +} + +export const useAutoSave = ({ + debounceMs = 2000, + enabled = true, +}: UseAutoSaveOptions = {}) => { + const { presentationData } = useSelector( + (state: RootState) => state.presentationGeneration + ); + + const saveTimeoutRef = useRef(null); + const lastSavedDataRef = useRef(''); + const [isSaving, setIsSaving] = useState(false); + + // Debounced save function + const debouncedSave = useCallback(async (data: any) => { + // Clear existing timeout + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Set new timeout + saveTimeoutRef.current = setTimeout(async () => { + if (!data || isSaving) return; + + const currentDataString = JSON.stringify(data); + + // Skip if data hasn't changed since last save + if (currentDataString === lastSavedDataRef.current) { + return; + } + + try { + setIsSaving(true); + console.log('🔄 Auto-saving presentation data...'); + + // Call the API to update presentation content + await PresentationGenerationApi.updatePresentationContent(data); + + // Update last saved data reference + lastSavedDataRef.current = currentDataString; + + console.log('✅ Auto-save successful'); + + } catch (error) { + console.error('❌ Auto-save failed:', error); + } finally { + setIsSaving(false); + } + }, debounceMs); + }, [debounceMs, isSaving]); + + // Effect to trigger auto-save when presentation data changes + useEffect(() => { + if (!enabled || !presentationData) return; + + // Trigger debounced save + debouncedSave(presentationData); + + // Cleanup timeout on unmount + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [presentationData, enabled, debouncedSave]); + + return { + isSaving, + }; +}; \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts index 38710068..4c7681f9 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts @@ -16,7 +16,6 @@ export const usePresentationData = ( const fetchUserSlides = useCallback(async () => { try { const data = await DashboardApi.getPresentation(presentationId); - console.log('Presentation Data',data); if (data) { dispatch(setPresentationData(data)); setLoading(false); diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts index ad03efdd..66b7c1b1 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts @@ -1,6 +1,5 @@ import { useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; -import { toast } from "@/hooks/use-toast"; import { setPresentationData, setStreaming } from "@/store/slices/presentationGeneration"; import { jsonrepair } from "jsonrepair"; diff --git a/servers/nextjs/app/(presentation-generator)/presentation/utils/debounce.ts b/servers/nextjs/app/(presentation-generator)/presentation/utils/debounce.ts deleted file mode 100644 index 93c2a725..00000000 --- a/servers/nextjs/app/(presentation-generator)/presentation/utils/debounce.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useCallback, useRef } from "react"; - -export function useDebounce void>( - callback: T, - delay: number -) { - const timeoutRef = useRef(); - - return useCallback( - (...args: Parameters) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }, - [callback, delay] - ); -} \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts b/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts index 187808ea..6a53e489 100644 --- a/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts +++ b/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts @@ -3,25 +3,6 @@ import { IconSearch, ImageGenerate, ImageSearch } from "./params"; export class PresentationGenerationApi { - static async getChapterDetails() { - try { - const response = await fetch( - `/api/v1/ppt/chapter-details`, - { - method: "GET", - headers: getHeader(), - cache: "no-cache", - } - ); - if (response.status === 200) { - const data = await response.json(); - return data; - } - } catch (error) { - console.error("Error getting chapter details:", error); - throw error; - } - } static async uploadDoc(documents: File[]) { const formData = new FormData(); @@ -80,62 +61,9 @@ export class PresentationGenerationApi { throw error; } } - static async titleGeneration({ - presentation_id, - }: { - presentation_id: string; - }) { - try { - const response = await fetch( - `/api/v1/ppt/presentation/outlines/generate`, - { - method: "POST", - headers: getHeader(), - body: JSON.stringify({ - prompt: prompt, - presentation_id: presentation_id, - }), - cache: "no-cache", - } - ); - if (response.status === 200) { - const data = await response.json(); + - return data; - } else { - throw new Error(`Failed to generate titles: ${response.statusText}`); - } - } catch (error) { - console.error("error in title generation", error); - throw error; - } - } - - static async generatePresentation(presentationData: any) { - try { - const response = await fetch( - `/api/v1/ppt/generate`, - { - method: "POST", - headers: getHeader(), - body: JSON.stringify(presentationData), - cache: "no-cache", - } - ); - if (response.status === 200) { - const data = await response.json(); - - return data; - } else { - throw new Error( - `Failed to generate presentation: ${response.statusText}` - ); - } - } catch (error) { - console.error("error in presentation generation", error); - throw error; - } - } + static async editSlide( presentation_id: string, index: number, @@ -172,9 +100,9 @@ export class PresentationGenerationApi { static async updatePresentationContent(body: any) { try { const response = await fetch( - `/api/v1/ppt/slides/update`, + `/api/v1/ppt/presentation/update`, { - method: "POST", + method: "PUT", headers: getHeader(), body: JSON.stringify(body), cache: "no-cache", @@ -375,33 +303,7 @@ export class PresentationGenerationApi { throw error; } } - // SET THEME COLORS - static async setThemeColors(presentation_id: string, theme: any) { - try { - const response = await fetch( - `/api/v1/ppt/presentation/theme`, - { - method: "POST", - headers: getHeader(), - body: JSON.stringify({ - presentation_id, - theme, - }), - - } - ); - if (response.ok) { - const data = await response.json(); - return data; - } else { - throw new Error(`Failed to set theme colors: ${response.statusText}`); - } - } catch (error) { - console.error("error in theme colors set", error); - throw error; - } - } - // QUESTIONS + static async createPresentation({ prompt, diff --git a/servers/nextjs/store/slices/presentationGeneration.ts b/servers/nextjs/store/slices/presentationGeneration.ts index b65202c1..8223182d 100644 --- a/servers/nextjs/store/slices/presentationGeneration.ts +++ b/servers/nextjs/store/slices/presentationGeneration.ts @@ -1,40 +1,14 @@ import { Slide } from "@/app/(presentation-generator)/types/slide"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -interface Series { - data: number[]; - name?: string; -} -interface DataLabel { - dataLabelPosition: "Outside" | "Inside"; - dataLabelAlignment: "Base" | "Center" | "End"; -} -export interface ChartSettings { - showLegend: boolean; - showGrid: boolean; - showAxisLabel: boolean; - showDataLabel: boolean; - dataLabel: DataLabel; -} + export interface SlideOutline { title?: string; body?: string; } -export interface Chart { - id: string; - name: string; - type: string; - style: ChartSettings | {} | null; - unit?: string | null; - presentation: string; - postfix: string; - data: { - categories: string[]; - series: Series[]; - }; -} + export interface PresentationData { id: string; language: string; @@ -50,20 +24,18 @@ export interface PresentationData { interface PresentationGenerationState { presentation_id: string | null; - documents: string[]; - images: string[]; isLoading: boolean; isStreaming: boolean | null; outlines: SlideOutline[]; error: string | null; presentationData: PresentationData | null; + isSlidesRendered: boolean; } const initialState: PresentationGenerationState = { presentation_id: null, - documents: [], - images: [], outlines: [], + isSlidesRendered: false, isLoading: false, isStreaming: null, error: null, @@ -86,6 +58,10 @@ const presentationGenerationSlice = createSlice({ state.presentation_id = action.payload; state.error = null; }, + // Slides rendered + setSlidesRendered: (state, action: PayloadAction) => { + state.isSlidesRendered = action.payload; + }, // Error setError: (state, action: PayloadAction) => { state.error = action.payload; @@ -97,14 +73,6 @@ const presentationGenerationSlice = createSlice({ state.error = null; state.isLoading = false; }, - // Set documents - setDocs: (state, action: PayloadAction) => { - state.documents = action.payload; - }, - // Set images - setImgs: (state, action: PayloadAction) => { - state.images = action.payload; - }, // Set outlines setOutlines: (state, action: PayloadAction) => { state.outlines = action.payload; @@ -166,252 +134,61 @@ const presentationGenerationSlice = createSlice({ action.payload.slide; } }, - updateSlideVariant: ( + + // Update slide content at specific data path (for Tiptap text editing) + updateSlideContent: ( state, - action: PayloadAction<{ index: number; variant: number }> + action: PayloadAction<{ + slideIndex: number; + dataPath: string; + content: string; + }> ) => { if ( state.presentationData && - state.presentationData.slides[action.payload.index] + state.presentationData.slides && + state.presentationData.slides[action.payload.slideIndex] ) { - state.presentationData.slides[action.payload.index].design_index = - action.payload.variant; - } - }, - updateSlideTitle: ( - state, - action: PayloadAction<{ index: number; title: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - state.presentationData.slides[action.payload.index].content.title = - action.payload.title; - } - }, - updateSlideDescription: ( - state, - action: PayloadAction<{ index: number; description: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - state.presentationData.slides[ - action.payload.index - ].content.description = action.payload.description; - } - }, - updateSlideBodyString: ( - state, - action: PayloadAction<{ index: number; body: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - state.presentationData.slides[action.payload.index].content.body = - action.payload.body; - } - }, - updateSlideBodyHeading: ( - state, - action: PayloadAction<{ index: number; bodyIdx: number; heading: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - state.presentationData.slides[action.payload.index].content.body[ - action.payload.bodyIdx - // @ts-ignore - ].heading = action.payload.heading; - } - }, - updateSlideBodyDescription: ( - state, - action: PayloadAction<{ - index: number; - bodyIdx: number; - description: string; - }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - state.presentationData.slides[action.payload.index].content.body[ - action.payload.bodyIdx - // @ts-ignore - ].description = action.payload.description; - } - }, - updateSlideImage: ( - state, - action: PayloadAction<{ index: number; imageIdx: number; image: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]?.images) { - state.presentationData.slides[action.payload.index].images![ - action.payload.imageIdx - ] = action.payload.image; - } - }, - updateSlideIcon: ( - state, - action: PayloadAction<{ index: number; iconIdx: number; icon: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]?.icons) { - state.presentationData.slides[action.payload.index].icons![ - action.payload.iconIdx - ] = action.payload.icon; - } - }, - updateSlideChart: ( - state, - action: PayloadAction<{ index: number; chart: Chart }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - state.presentationData.slides[action.payload.index].content.graph = - action.payload.chart; - } - }, - updateSlideChartSettings: ( - state, - action: PayloadAction<{ index: number; chartSettings: ChartSettings }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - const defaultSettings: ChartSettings = { - showLegend: false, - showGrid: false, - showAxisLabel: true, - showDataLabel: true, - dataLabel: { - dataLabelPosition: "Outside", - dataLabelAlignment: "Center", - }, + const slide = state.presentationData.slides[action.payload.slideIndex]; + const { dataPath, content } = action.payload; + + // Helper function to set nested property value + const setNestedValue = (obj: any, path: string, value: string) => { + const keys = path.split(/[.\[\]]+/).filter(Boolean); + let current = obj; + + // Navigate to the parent object + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (isNaN(Number(key))) { + // String key + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } else { + // Array index + const index = Number(key); + if (!current[index]) { + current[index] = {}; + } + current = current[index]; + } + } + + // Set the final value + const finalKey = keys[keys.length - 1]; + if (isNaN(Number(finalKey))) { + current[finalKey] = value; + } else { + current[Number(finalKey)] = value; + } }; - state.presentationData.slides[ - action.payload.index - ].content.graph.style = { - ...defaultSettings, - ...action.payload.chartSettings, - }; - } - }, - - addSlideBodyItem: ( - state, - action: PayloadAction<{ - index: number; - item: { heading: string; description: string }; - }> - ) => { - if (state.presentationData?.slides[action.payload.index]?.content.body) { - // @ts-ignore - state.presentationData.slides[action.payload.index].content.body.push( - action.payload.item - ); - } - }, - addSlideImage: ( - state, - action: PayloadAction<{ index: number; image: string }> - ) => { - if (state.presentationData?.slides[action.payload.index]?.images) { - state.presentationData.slides[action.payload.index].images!.push( - action.payload.image - ); - } - }, - deleteSlideImage: ( - state, - action: PayloadAction<{ index: number; imageIdx: number }> - ) => { - if (state.presentationData?.slides[action.payload.index]?.images) { - state.presentationData.slides[action.payload.index].images!.splice( - action.payload.imageIdx, - 1 - ); - } - }, - updateSlideProperties: ( - state, - action: PayloadAction<{ index: number; itemIdx: number; properties: any }> - ) => { - if (state.presentationData?.slides[action.payload.index]) { - // Initialize properties object if it doesn't exist - if (!state.presentationData.slides[action.payload.index].properties) { - state.presentationData.slides[action.payload.index].properties = {}; + + // Update the slide content + if (dataPath && slide.content) { + setNestedValue(slide.content, dataPath, content); } - // Assign the properties to the specific item index - state.presentationData.slides[action.payload.index].properties[ - action.payload.itemIdx - ] = action.payload.properties; - } - }, - // Infographics - addInfographics: ( - state, - action: PayloadAction<{ slideIndex: number; item: any }> - ) => { - if (state.presentationData?.slides[action.payload.slideIndex]?.content) { - // @ts-ignore - state.presentationData.slides[ - action.payload.slideIndex - ].content.infographics.push(action.payload.item); - } - }, - deleteInfographics: ( - state, - action: PayloadAction<{ slideIndex: number; itemIdx: number }> - ) => { - if (state.presentationData?.slides[action.payload.slideIndex]?.content) { - // @ts-ignore - state.presentationData.slides[ - action.payload.slideIndex - ].content.infographics.splice(action.payload.itemIdx, 1); - } - }, - updateInfographicsTitle: ( - state, - action: PayloadAction<{ - slideIndex: number; - itemIdx: number; - title: string; - }> - ) => { - if (state.presentationData?.slides[action.payload.slideIndex]?.content) { - // @ts-ignore - state.presentationData.slides[ - action.payload.slideIndex - ].content.infographics[action.payload.itemIdx].title = - action.payload.title; - } - }, - updateInfographicsDescription: ( - state, - action: PayloadAction<{ - slideIndex: number; - itemIdx: number; - description: string; - }> - ) => { - if (state.presentationData?.slides[action.payload.slideIndex]?.content) { - // @ts-ignore - state.presentationData.slides[ - action.payload.slideIndex - ].content.infographics[action.payload.itemIdx].description = - action.payload.description; - } - }, - updateInfographicsChart: ( - state, - action: PayloadAction<{ slideIndex: number; itemIdx: number; chart: any }> - ) => { - if (state.presentationData?.slides[action.payload.slideIndex]?.content) { - // @ts-ignore - state.presentationData.slides[ - action.payload.slideIndex - ].content.infographics[action.payload.itemIdx].chart = - action.payload.chart; - } - }, - deleteSlideBodyItem: ( - state, - action: PayloadAction<{ index: number; itemIdx: number }> - ) => { - if (state.presentationData?.slides[action.payload.index]?.content.body) { - // @ts-ignore - state.presentationData.slides[action.payload.index].content.body.splice( - action.payload.itemIdx, - 1 - ); } }, }, @@ -421,39 +198,17 @@ export const { setStreaming, setLoading, setPresentationId, + setSlidesRendered, setError, clearPresentationData, - setDocs, - setImgs, - deleteSlideOutline, setPresentationData, setOutlines, // slides operations addSlide, updateSlide, - updateSlideVariant, - updateSlideChart, - updateSlideChartSettings, - updateSlideTitle, - updateSlideDescription, - updateSlideBodyString, - updateSlideBodyHeading, - updateSlideBodyDescription, - updateSlideImage, - updateSlideIcon, deletePresentationSlide, - addSlideBodyItem, - addSlideImage, - deleteSlideImage, - deleteSlideBodyItem, - updateSlideProperties, - // infographics - addInfographics, - deleteInfographics, - updateInfographicsTitle, - updateInfographicsDescription, - updateInfographicsChart, + updateSlideContent, } = presentationGenerationSlice.actions; export default presentationGenerationSlice.reducer; From 19b6ecfc5c5ae752f91bd660cc36462a90232883 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Sat, 19 Jul 2025 01:18:54 +0545 Subject: [PATCH 04/10] feat(Nextjs): Markdown support on text --- .../app/(presentation-generator)/components/TiptapText.tsx | 4 ++-- .../components/TiptapTextReplacer.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx index aebfdfbf..8b901739 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx @@ -40,9 +40,9 @@ const TiptapText: React.FC = ({ }, }, onBlur: ({ editor }) => { - const text = editor.getText(); + const markdown = editor?.storage.markdown.getMarkdown(); if (onContentChange) { - onContentChange(text); + onContentChange(markdown); } }, editable: !disabled, diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx index 742473fe..3cceb669 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx @@ -60,7 +60,6 @@ const TiptapTextReplacer: React.FC = ({ // Skip certain element types that shouldn't be editable if (shouldSkipElement(htmlElement)) return; - console.log('Making element editable:', trimmedText, htmlElement); // Get all computed styles to preserve them const computedStyles = window.getComputedStyle(htmlElement); From 578792de765eb0595475dd1968284f91fff571ff Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Sat, 19 Jul 2025 10:35:53 +0545 Subject: [PATCH 05/10] feat(fastapi): adds missing ollama and custom llm endpoints and redis service --- .../fastapi/api/v1/ppt/background_tasks.py | 59 ++++++++++ .../api/v1/ppt/endpoints/custom_llm.py | 14 +++ servers/fastapi/api/v1/ppt/endpoints/files.py | 8 +- .../fastapi/api/v1/ppt/endpoints/ollama.py | 72 ++++++++++++ servers/fastapi/api/v1/ppt/router.py | 4 + .../constants/supported_ollama_models.py | 4 +- servers/fastapi/requirements.txt | 2 + servers/fastapi/services/__init__.py | 2 + servers/fastapi/services/redis_service.py | 109 ++++++++++++++++++ 9 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 servers/fastapi/api/v1/ppt/background_tasks.py create mode 100644 servers/fastapi/api/v1/ppt/endpoints/custom_llm.py create mode 100644 servers/fastapi/api/v1/ppt/endpoints/ollama.py create mode 100644 servers/fastapi/services/redis_service.py diff --git a/servers/fastapi/api/v1/ppt/background_tasks.py b/servers/fastapi/api/v1/ppt/background_tasks.py new file mode 100644 index 00000000..e9a604f6 --- /dev/null +++ b/servers/fastapi/api/v1/ppt/background_tasks.py @@ -0,0 +1,59 @@ +import json + +from fastapi import HTTPException + +from models.ollama_model_status import OllamaModelStatus +from services import REDIS_SERVICE +from utils.ollama import pull_ollama_model + + +async def pull_ollama_model_background_task(model: str): + saved_model_status = OllamaModelStatus( + name=model, + status="pulling", + done=False, + ) + log_event_count = 0 + + try: + async for event in pull_ollama_model(model): + log_event_count += 1 + if log_event_count != 1 and log_event_count % 20 != 0: + continue + + if "completed" in event: + saved_model_status.downloaded = event["completed"] + + if not saved_model_status.size and "total" in event: + saved_model_status.size = event["total"] + + if "status" in event: + saved_model_status.status = event["status"] + + REDIS_SERVICE.set( + f"ollama_models/{model}", + json.dumps(saved_model_status.model_dump(mode="json")), + ) + + except Exception as e: + saved_model_status.status = "error" + saved_model_status.done = True + REDIS_SERVICE.set( + f"ollama_models/{model}", + json.dumps(saved_model_status.model_dump(mode="json")), + ) + raise HTTPException( + status_code=500, + detail=f"Failed to pull model: {e}", + ) + + saved_model_status.done = True + saved_model_status.status = "pulled" + saved_model_status.downloaded = saved_model_status.size + + REDIS_SERVICE.set( + f"ollama_models/{model}", + json.dumps(saved_model_status.model_dump(mode="json")), + ) + + return saved_model_status diff --git a/servers/fastapi/api/v1/ppt/endpoints/custom_llm.py b/servers/fastapi/api/v1/ppt/endpoints/custom_llm.py new file mode 100644 index 00000000..8a44cb22 --- /dev/null +++ b/servers/fastapi/api/v1/ppt/endpoints/custom_llm.py @@ -0,0 +1,14 @@ +from typing import Annotated, List, Optional +from fastapi import APIRouter, Body + +from utils.custom_llm_provider import list_available_custom_models + +CUSTOM_LLM_ROUTER = APIRouter(prefix="/custom_llm", tags=["Custom LLM"]) + + +@CUSTOM_LLM_ROUTER.post("/models/available", response_model=List[str]) +async def get_available_models( + url: Annotated[Optional[str], Body()] = None, + api_key: Annotated[Optional[str], Body()] = None, +): + return await list_available_custom_models(url, api_key) diff --git a/servers/fastapi/api/v1/ppt/endpoints/files.py b/servers/fastapi/api/v1/ppt/endpoints/files.py index e2f43329..b19e31d0 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/files.py +++ b/servers/fastapi/api/v1/ppt/endpoints/files.py @@ -1,13 +1,13 @@ from http.client import HTTPException import os from typing import Annotated, List, Optional -import uuid from fastapi import APIRouter, Body, File, UploadFile from constants.documents import UPLOAD_ACCEPTED_FILE_TYPES from models.decomposed_file_info import DecomposedFileInfo from services import TEMP_FILE_SERVICE from services.documents_loader import DocumentsLoader +from utils.randomizers import get_random_uuid from utils.validators import validate_files FILES_ROUTER = APIRouter(prefix="/files", tags=["Files"]) @@ -18,7 +18,7 @@ async def upload_files(files: Optional[List[UploadFile]]): if not files: raise HTTPException(400, "Documents are required") - temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4())) + temp_dir = TEMP_FILE_SERVICE.create_temp_dir(get_random_uuid()) validate_files(files, True, True, 50, UPLOAD_ACCEPTED_FILE_TYPES) @@ -39,7 +39,7 @@ async def upload_files(files: Optional[List[UploadFile]]): @FILES_ROUTER.post("/decompose", response_model=List[DecomposedFileInfo]) async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]): - temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4())) + temp_dir = TEMP_FILE_SERVICE.create_temp_dir(get_random_uuid()) txt_files = [] other_files = [] @@ -56,7 +56,7 @@ async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]): response = [] for index, parsed_doc in enumerate(parsed_documents): file_path = TEMP_FILE_SERVICE.create_temp_file_path( - f"{str(uuid.uuid4())}.txt", temp_dir + f"{get_random_uuid()}.txt", temp_dir ) parsed_doc = parsed_doc.replace("
", "\n") with open(file_path, "w") as text_file: diff --git a/servers/fastapi/api/v1/ppt/endpoints/ollama.py b/servers/fastapi/api/v1/ppt/endpoints/ollama.py new file mode 100644 index 00000000..04b1a505 --- /dev/null +++ b/servers/fastapi/api/v1/ppt/endpoints/ollama.py @@ -0,0 +1,72 @@ +import json +from typing import List +from fastapi import APIRouter, BackgroundTasks, HTTPException + +from api.v1.ppt.background_tasks import pull_ollama_model_background_task +from constants.supported_ollama_models import SUPPORTED_OLLAMA_MODELS +from models.ollama_model_metadata import OllamaModelMetadata +from models.ollama_model_status import OllamaModelStatus +from services import REDIS_SERVICE +from utils.ollama import list_pulled_ollama_models + +OLLAMA_ROUTER = APIRouter(prefix="/ollama", tags=["Ollama"]) + + +@OLLAMA_ROUTER.get("/models/supported", response_model=List[OllamaModelMetadata]) +def get_supported_models(): + return SUPPORTED_OLLAMA_MODELS.values() + + +@OLLAMA_ROUTER.get("/models/available", response_model=List[OllamaModelStatus]) +async def get_available_models(): + return await list_pulled_ollama_models() + + +@OLLAMA_ROUTER.get("/models/pull", response_model=OllamaModelStatus) +async def pull_model(model: str, background_tasks: BackgroundTasks): + + if model not in SUPPORTED_OLLAMA_MODELS: + raise HTTPException( + status_code=400, + detail=f"Model {model} is not supported", + ) + + try: + pulled_models = await list_pulled_ollama_models() + filtered_models = [ + pulled_model for pulled_model in pulled_models if pulled_model.name == model + ] + if filtered_models: + return filtered_models[0] + except HTTPException as e: + raise e + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to check pulled models: {e}", + ) + + saved_model_status = REDIS_SERVICE.get(f"ollama_models/{model}") + + # If the model is being pulled, return the model + if saved_model_status: + saved_model_status_json = json.loads(saved_model_status) + # If the model is being pulled, return the model + # ? If the model status is pulled in redis but was not found while listing pulled models, + # ? it means the model was deleted and we need to pull it again + if ( + saved_model_status_json["status"] == "error" + or saved_model_status_json["status"] == "pulled" + ): + REDIS_SERVICE.delete(f"ollama_models/{model}") + else: + return saved_model_status_json + + # If the model is not being pulled, pull the model + background_tasks.add_task(pull_ollama_model_background_task, model) + + return OllamaModelStatus( + name=model, + status="pulling", + done=False, + ) diff --git a/servers/fastapi/api/v1/ppt/router.py b/servers/fastapi/api/v1/ppt/router.py index ddff5676..15fb1b52 100644 --- a/servers/fastapi/api/v1/ppt/router.py +++ b/servers/fastapi/api/v1/ppt/router.py @@ -1,8 +1,10 @@ from fastapi import APIRouter +from api.v1.ppt.endpoints.custom_llm import CUSTOM_LLM_ROUTER from api.v1.ppt.endpoints.files import FILES_ROUTER from api.v1.ppt.endpoints.icons import ICONS_ROUTER from api.v1.ppt.endpoints.images import IMAGES_ROUTER +from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER @@ -14,3 +16,5 @@ API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER) API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER) API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER) API_V1_PPT_ROUTER.include_router(ICONS_ROUTER) +API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER) +API_V1_PPT_ROUTER.include_router(CUSTOM_LLM_ROUTER) diff --git a/servers/fastapi/constants/supported_ollama_models.py b/servers/fastapi/constants/supported_ollama_models.py index 2589d54e..57a9a257 100644 --- a/servers/fastapi/constants/supported_ollama_models.py +++ b/servers/fastapi/constants/supported_ollama_models.py @@ -1,7 +1,7 @@ from models.ollama_model_metadata import OllamaModelMetadata -SUPPORTED_LLAMA_MODELS = { +SUPPORTED_OLLAMA_MODELS = { "llama3:8b": OllamaModelMetadata( label="Llama 3:8b", value="llama3:8b", @@ -246,7 +246,7 @@ SUPPORTED_QWEN_MODELS = { } SUPPORTED_OLLAMA_MODELS = { - **SUPPORTED_LLAMA_MODELS, + **SUPPORTED_OLLAMA_MODELS, **SUPPORTED_GEMMA_MODELS, **SUPPORTED_DEEPSEEK_MODELS, **SUPPORTED_QWEN_MODELS, diff --git a/servers/fastapi/requirements.txt b/servers/fastapi/requirements.txt index 3edb9b66..afeb38a0 100644 --- a/servers/fastapi/requirements.txt +++ b/servers/fastapi/requirements.txt @@ -3,6 +3,7 @@ aiohttp==3.12.14 aiosignal==1.4.0 annotated-types==0.7.0 anyio==4.9.0 +async-timeout==5.0.1 attrs==25.3.0 cachetools==5.5.2 certifi==2025.7.14 @@ -55,6 +56,7 @@ python-dotenv==1.1.1 python-multipart==0.0.20 python-pptx==1.0.2 PyYAML==6.0.2 +redis==6.2.0 requests==2.32.4 rich==14.0.0 rich-toolkit==0.14.8 diff --git a/servers/fastapi/services/__init__.py b/servers/fastapi/services/__init__.py index 89bac591..56843e2b 100644 --- a/servers/fastapi/services/__init__.py +++ b/servers/fastapi/services/__init__.py @@ -1,6 +1,8 @@ +from services.redis_service import RedisService from services.temp_file_service import TempFileService from services.database import sql_engine TEMP_FILE_SERVICE = TempFileService() SQL_ENGINE = sql_engine +REDIS_SERVICE = RedisService() diff --git a/servers/fastapi/services/redis_service.py b/servers/fastapi/services/redis_service.py new file mode 100644 index 00000000..5d9dff22 --- /dev/null +++ b/servers/fastapi/services/redis_service.py @@ -0,0 +1,109 @@ +import os +from typing import Any, Optional +import redis +from redis.exceptions import RedisError + + +class RedisService: + def __init__(self): + self.redis_host = os.getenv("REDIS_HOST", "localhost") + self.redis_port = int(os.getenv("REDIS_PORT", "6379")) + self.redis_db = int(os.getenv("REDIS_DB", "0")) + self.redis_password = os.getenv("REDIS_PASSWORD") + self.client = self._create_client() + + def _create_client(self) -> redis.Redis: + return redis.Redis( + host=self.redis_host, + port=self.redis_port, + db=self.redis_db, + password=self.redis_password, + decode_responses=True, + ) + + def set(self, key: str, value: Any, expire: Optional[int] = None) -> bool: + try: + return self.client.set(key, value, ex=expire) + except RedisError: + return False + + def get(self, key: str) -> Optional[str]: + try: + return self.client.get(key) + except RedisError: + return None + + def delete(self, key: str) -> bool: + try: + return bool(self.client.delete(key)) + except RedisError: + return False + + def exists(self, key: str) -> bool: + try: + return bool(self.client.exists(key)) + except RedisError: + return False + + def set_hash(self, name: str, mapping: dict) -> bool: + try: + return self.client.hmset(name, mapping) + except RedisError: + return False + + def get_hash(self, name: str) -> Optional[dict]: + try: + return self.client.hgetall(name) + except RedisError: + return None + + def delete_hash(self, name: str, *fields: str) -> int: + try: + return self.client.hdel(name, *fields) + except RedisError: + return 0 + + def set_list(self, name: str, values: list) -> bool: + try: + self.client.delete(name) + if values: + self.client.rpush(name, *values) + return True + except RedisError: + return False + + def get_list(self, name: str, start: int = 0, end: int = -1) -> Optional[list]: + try: + return self.client.lrange(name, start, end) + except RedisError: + return None + + def add_to_set(self, name: str, *values: str) -> int: + try: + return self.client.sadd(name, *values) + except RedisError: + return 0 + + def get_set(self, name: str) -> Optional[set]: + try: + return self.client.smembers(name) + except RedisError: + return None + + def remove_from_set(self, name: str, *values: str) -> int: + try: + return self.client.srem(name, *values) + except RedisError: + return 0 + + def clear(self) -> bool: + try: + return self.client.flushdb() + except RedisError: + return False + + def close(self): + try: + self.client.close() + except RedisError: + pass From 2171dba4e544367620db8bb46b0d2c88d3d83c01 Mon Sep 17 00:00:00 2001 From: sudipnext Date: Sat, 19 Jul 2025 11:22:20 +0545 Subject: [PATCH 06/10] feat: replace 'imagen' with 'gemini_flash' across image provider configurations and related services --- servers/fastapi/enums/image_provider.py | 2 +- .../fastapi/services/image_generation_service.py | 4 ++-- servers/fastapi/tests/test_image_generation.py | 14 +++++++------- servers/fastapi/utils/image_provider.py | 6 +++--- servers/fastapi/utils/model_availability.py | 4 ++-- servers/nextjs/app/settings/SettingPage.tsx | 8 ++++---- servers/nextjs/components/Home.tsx | 14 +++++++------- servers/nextjs/utils/storeHelpers.ts | 2 +- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/servers/fastapi/enums/image_provider.py b/servers/fastapi/enums/image_provider.py index cee115e5..2c7b3bb2 100644 --- a/servers/fastapi/enums/image_provider.py +++ b/servers/fastapi/enums/image_provider.py @@ -3,5 +3,5 @@ from enum import Enum class ImageProvider(Enum): PEXELS = "pexels" PIXABAY = "pixabay" - IMAGEN = "imagen" + GEMINI_FLASH = "gemini_flash" DALLE3 = "dall-e-3" diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 5f66ec1d..4a1d979c 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -17,7 +17,7 @@ from utils.llm_provider import ( from utils.image_provider import ( is_pixels_selected, is_pixabay_selected, - is_imagen_selected, + is_gemini_flash_selected, is_dalle3_selected ) @@ -32,7 +32,7 @@ class ImageGenerationService: return self.get_image_from_pixabay elif is_pixels_selected(): return self.get_image_from_pexels - elif is_imagen_selected(): + elif is_gemini_flash_selected(): return self.generate_image_google elif is_dalle3_selected(): return self.generate_image_openai diff --git a/servers/fastapi/tests/test_image_generation.py b/servers/fastapi/tests/test_image_generation.py index e13ef590..bf0db108 100644 --- a/servers/fastapi/tests/test_image_generation.py +++ b/servers/fastapi/tests/test_image_generation.py @@ -51,7 +51,7 @@ class TestImageGenerationService: """ 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_gemini_flash_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) @@ -65,7 +65,7 @@ class TestImageGenerationService: """ 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_gemini_flash_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) @@ -79,7 +79,7 @@ class TestImageGenerationService: """ 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_gemini_flash_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) @@ -120,7 +120,7 @@ class TestImageGenerationService: 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_gemini_flash_selected', return_value=False): with patch('services.image_generation_service.is_dalle3_selected', return_value=False): service = ImageGenerationService(mock_images_directory) @@ -155,7 +155,7 @@ class TestImageGenerationService: 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_gemini_flash_selected', return_value=False): with patch('services.image_generation_service.is_dalle3_selected', return_value=True): service = ImageGenerationService(mock_images_directory) @@ -187,7 +187,7 @@ class TestImageGenerationService: 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_gemini_flash_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) @@ -209,7 +209,7 @@ class TestImageGenerationService: 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_gemini_flash_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) diff --git a/servers/fastapi/utils/image_provider.py b/servers/fastapi/utils/image_provider.py index f095465f..1dd8f217 100644 --- a/servers/fastapi/utils/image_provider.py +++ b/servers/fastapi/utils/image_provider.py @@ -10,8 +10,8 @@ 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_gemini_flash_selected() -> bool: + return ImageProvider.GEMINI_FLASH == get_selected_image_provider() def is_dalle3_selected() -> bool: @@ -33,7 +33,7 @@ def get_image_provider_api_key() -> str: return os.getenv("PEXELS_API_KEY") elif selected_image_provider == ImageProvider.PIXABAY: return os.getenv("PIXABAY_API_KEY") - elif selected_image_provider == ImageProvider.IMAGEN: + elif selected_image_provider == ImageProvider.GEMINI_FLASH: return os.getenv("GOOGLE_API_KEY") elif selected_image_provider == ImageProvider.DALLE3: return os.getenv("OPENAI_API_KEY") diff --git a/servers/fastapi/utils/model_availability.py b/servers/fastapi/utils/model_availability.py index 2a29476e..4b3f1c10 100644 --- a/servers/fastapi/utils/model_availability.py +++ b/servers/fastapi/utils/model_availability.py @@ -12,7 +12,7 @@ from utils.ollama import pull_ollama_model from utils.image_provider import ( is_pixels_selected, is_pixabay_selected, - is_imagen_selected, + is_gemini_flash_selected, is_dalle3_selected, ) @@ -73,7 +73,7 @@ async def check_llm_and_image_provider_api_or_model_availability(): if not pixabay_api_key: raise Exception("PIXABAY_API_KEY must be provided") - elif is_imagen_selected(): + elif is_gemini_flash_selected(): google_api_key = os.getenv("GOOGLE_API_KEY") if not google_api_key: raise Exception("GOOGLE_API_KEY must be provided") diff --git a/servers/nextjs/app/settings/SettingPage.tsx b/servers/nextjs/app/settings/SettingPage.tsx index 0a16c58f..4eb7c3c3 100644 --- a/servers/nextjs/app/settings/SettingPage.tsx +++ b/servers/nextjs/app/settings/SettingPage.tsx @@ -50,9 +50,9 @@ const IMAGE_PROVIDERS: Record = { placeholder: "Enter your OpenAI API key", apiKeyField: "OPENAI_API_KEY", }, - imagen: { - title: "imagen", - description: "Required for using Imagen services from Google", + gemini_flash: { + title: "gemini_flash", + description: "Required for using Gemini Flash services from Google", placeholder: "Enter your Google API key", apiKeyField: "GOOGLE_API_KEY", }, @@ -943,7 +943,7 @@ const SettingsPage = () => { return <>; } - if (provider.title === "imagen" && llmConfig.LLM === "google") { + if (provider.title === "gemini_flash" && llmConfig.LLM === "google") { return <> ; } diff --git a/servers/nextjs/components/Home.tsx b/servers/nextjs/components/Home.tsx index 1f960d91..4017c50e 100644 --- a/servers/nextjs/components/Home.tsx +++ b/servers/nextjs/components/Home.tsx @@ -87,9 +87,9 @@ const IMAGE_PROVIDERS: Record = { requiresApiKey: true, apiKeyField: "OPENAI_API_KEY", }, - imagen: { - value: "imagen", - label: "Imagen", + gemini_flash: { + value: "gemini_flash", + label: "Gemini Flash", description: "Google's primary image generation model", icon: "/icons/google.png", requiresApiKey: true, @@ -142,8 +142,8 @@ const PROVIDER_CONFIGS: Record = { ], imageModels: [ { - value: "imagen", - label: "Imagen", + value: "gemini_flash", + label: "Gemini Flash", description: "Google's primary image generation model", icon: "/icons/google.png", size: "8GB", @@ -323,7 +323,7 @@ export default function Home() { if (provider === "openai") { newConfig.IMAGE_PROVIDER = "dall-e-3"; } else if (provider === "google") { - newConfig.IMAGE_PROVIDER = "imagen"; + newConfig.IMAGE_PROVIDER = "gemini_flash"; } else { newConfig.IMAGE_PROVIDER = "pexels"; // default for ollama and custom } @@ -967,7 +967,7 @@ export default function Home() { return <>; } - if (provider.value === "imagen" && llmConfig.LLM === "google") { + if (provider.value === "gemini_flash" && llmConfig.LLM === "google") { return <> ; } diff --git a/servers/nextjs/utils/storeHelpers.ts b/servers/nextjs/utils/storeHelpers.ts index 773f9637..a761d0ce 100644 --- a/servers/nextjs/utils/storeHelpers.ts +++ b/servers/nextjs/utils/storeHelpers.ts @@ -44,7 +44,7 @@ export const hasValidLLMConfig = (llmConfig: LLMConfig) => { return llmConfig.PIXABAY_API_KEY && llmConfig.PIXABAY_API_KEY !== ""; case "dall-e-3": return OPENAI_API_KEY && OPENAI_API_KEY !== ""; - case "imagen": + case "gemini_flash": return GOOGLE_API_KEY && GOOGLE_API_KEY !== ""; default: return false; From 19e69739b8e14cef83f10aa719f33884c2492371 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Sat, 19 Jul 2025 12:43:48 +0545 Subject: [PATCH 07/10] feat(fastapi): adds slide element attributes to pptx_model and improves element attributes scraping --- servers/fastapi/models/pptx_models.py | 1 + .../services/pptx_presentation_creator.py | 3 + .../generate_presentation_outlines.py | 30 +- .../api/presentation_to_pptx_model/route.ts | 287 ++++++++++++++---- servers/nextjs/types/element_attibutes.ts | 19 ++ servers/nextjs/types/pptx_models.ts | 5 +- servers/nextjs/utils/pptx_models_utils.ts | 243 +++++++++++++++ 7 files changed, 503 insertions(+), 85 deletions(-) create mode 100644 servers/nextjs/utils/pptx_models_utils.ts diff --git a/servers/fastapi/models/pptx_models.py b/servers/fastapi/models/pptx_models.py index ba565ca5..12a8aa91 100644 --- a/servers/fastapi/models/pptx_models.py +++ b/servers/fastapi/models/pptx_models.py @@ -144,6 +144,7 @@ class PptxConnectorModel(PptxShapeModel): class PptxSlideModel(BaseModel): + background: Optional[PptxFillModel] = None shapes: List[ PptxTextBoxModel | PptxAutoShapeBoxModel diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index af21f0fd..875951b5 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -108,6 +108,9 @@ class PptxPresentationCreator: def add_and_populate_slide(self, slide_model: PptxSlideModel): slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT]) + if slide_model.background: + self.apply_fill_to_shape(slide.background, slide_model.background) + for shape_model in slide_model.shapes: model_type = type(shape_model) diff --git a/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py b/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py index f3fac493..3d0ac08f 100644 --- a/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py +++ b/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py @@ -11,29 +11,7 @@ from utils.llm_provider import ( is_google_selected, ) -# system_prompt = """ -# Create a presentation based on the provided prompt, number of slides, output language, and additional informational details. -# Format the output in the specified JSON schema with structured markdown content. -# # Steps - -# 1. Identify key points from the provided prompt, including the topic, number of slides, output language, and additional content directions. -# 2. Create a concise and descriptive title reflecting the main topic, adhering to the specified language. -# 3. Generate a clear title for each slide. -# 4. Develop comprehensive content using markdown structure: -# * Use bullet points (- or *) for lists. -# * Use **bold** for emphasis, *italic* for secondary emphasis, and `code` for technical terms. -# 5. Provide important points from prompt as notes. - -# # Notes -# - Content must be generated for every slide. -# - Images or Icons information provided in **Input** must be included in the **notes**. -# - Notes should cleary define if it is for specific slide or for the presentation. -# - Slide **body** should not contain slide **title**. -# - Slide **title** should not contain "Slide 1", "Slide 2", etc. -# - Slide **title** should not be in markdown format. -# - There must be exact **Number of Slides** as specified. -# """ system_prompt = """ You are an expert presentation creator. Generate structured presentations based on user requirements and format them according to the specified JSON schema with markdown content. @@ -183,13 +161,7 @@ async def generate_ppt_outline( async with client.beta.chat.completions.stream( model=model, messages=get_prompt_template(prompt, n_slides, language, content), - response_format={ - "type": "json_schema", - "json_schema": { - "name": "PresentationOutline", - "schema": response_model.model_json_schema(), - }, - }, + response_format=response_model, ) as stream: async for event in stream: if isinstance(event, ContentDeltaEvent): diff --git a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts index 716e07e2..c8c9f72b 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -1,7 +1,9 @@ import { ApiError } from "@/models/errors"; import { NextRequest, NextResponse } from "next/server"; import puppeteer, { ElementHandle } from "puppeteer"; -import { ElementAttributes } from "@/types/element_attibutes"; +import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes"; +import { convertElementAttributesToPptxSlides } from "@/utils/pptx_models_utils"; +import { PptxPresentationModel } from "@/types/pptx_models"; export async function GET(request: NextRequest) { @@ -9,14 +11,12 @@ export async function GET(request: NextRequest) { try { const id = await getPresentationId(request); const slides = await getSlides(id); - const slide = slides[0]; - const attributes = await getAllChildElementsAttributes(slide); - console.log(attributes); - - // Temporary - return NextResponse.json({ - attributes: attributes, - }); + const slides_attributes = await getSlidesAttributes(slides); + const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes.elements, slides_attributes.backgroundColors); + const presentation_pptx_model: PptxPresentationModel = { + slides: slides_pptx_models, + }; + return NextResponse.json(presentation_pptx_model); } catch (error: any) { console.error(error); if (error instanceof ApiError) { @@ -34,6 +34,38 @@ async function getPresentationId(request: NextRequest) { return id; } +async function getSlidesAttributes(slides: ElementHandle[]) { + const slideResults = await Promise.all(slides.map(async (slide) => { + return await getAllChildElementsAttributes(slide); + })); + + // Extract elements and background colors from each slide result + const elements = slideResults.map(result => result.elements); + const backgroundColors = slideResults.map(result => result.backgroundColor); + + return { + elements, + backgroundColors + }; +} + + +async function getSlides(id: string) { + const slides_wrapper = await getSlidesWrapper(id); + const slides = await slides_wrapper.$$(":scope > div > div"); + return slides; +} + +async function getSlidesWrapper(id: string): Promise> { + const page = await getPresentationPage(id); + const slides_wrapper = await page.$("#presentation-slides-wrapper"); + if (!slides_wrapper) { + throw new ApiError("Presentation slides not found"); + } + return slides_wrapper; +} + + async function getPresentationPage(id: string) { const browser = await puppeteer.launch({ headless: true, @@ -48,20 +80,111 @@ async function getPresentationPage(id: string) { return page; } -async function getSlidesWrapper(id: string): Promise> { - const page = await getPresentationPage(id); - const slides_wrapper = await page.$("#presentation-slides-wrapper"); - if (!slides_wrapper) { - throw new ApiError("Presentation slides not found"); + +async function getAllChildElementsAttributes(element: ElementHandle): Promise { + // Get the root element's bounding rect for relative positioning + const rootRect = await element.evaluate((el) => { + const rect = el.getBoundingClientRect(); + return { + left: isFinite(rect.left) ? rect.left : 0, + top: isFinite(rect.top) ? rect.top : 0, + width: isFinite(rect.width) ? rect.width : 0, + height: isFinite(rect.height) ? rect.height : 0, + }; + }); + + // Get all child elements as ElementHandles + const childElementHandles = await element.$$(':scope *'); + + // Get attributes and depth for each child element + const attributesPromises = childElementHandles.map(async (childElementHandle) => { + const attributes = await getElementAttributes(childElementHandle); + + // Calculate the depth of the element in the DOM tree + const depth = await childElementHandle.evaluate((el) => { + let depth = 0; + let current = el; + while (current.parentElement) { + depth++; + current = current.parentElement; + } + return depth; + }); + + // Convert positions to relative positions + if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) { + attributes.position = { + left: attributes.position.left - rootRect.left, + top: attributes.position.top - rootRect.top, + width: attributes.position.width, + height: attributes.position.height, + }; + } + + return { attributes, depth }; + }); + + const allResults = await Promise.all(attributesPromises); + + // Extract background color from elements whose position is the same as root element + let backgroundColor: string | undefined; + const elementsWithRootPosition = allResults.filter(({ attributes }) => { + return attributes.position && + attributes.position.left === 0 && + attributes.position.top === 0 && + attributes.position.width === rootRect.width && + attributes.position.height === rootRect.height; + }); + + // Get the background color from the first element with root position that has a background + for (const { attributes } of elementsWithRootPosition) { + if (attributes.background && attributes.background.color) { + backgroundColor = attributes.background.color; + break; + } } - return slides_wrapper; + + // Filter out elements with no meaningful styling and elements with same position as root + const filteredResults = allResults.filter(({ attributes }) => { + // Check if element has any meaningful styling or content + const hasBackground = attributes.background && attributes.background.color; + const hasBorder = attributes.border && attributes.border.color; + const hasShadow = attributes.shadow && attributes.shadow.color; + const hasText = attributes.innerText && attributes.innerText.trim().length > 0; + + // Check if element position is the same as root (exclude these elements) + const isRootPosition = attributes.position && + attributes.position.left === 0 && + attributes.position.top === 0 && + attributes.position.width === rootRect.width && + attributes.position.height === rootRect.height; + + // Return true if element has at least one of these properties AND is not at root position + return (hasBackground || hasBorder || hasShadow || hasText) && !isRootPosition; + }); + + // Sort elements by z-index first, then by depth if z-index is not provided + const sortedElements = filteredResults + .sort((a, b) => { + const zIndexA = a.attributes.zIndex || 0; + const zIndexB = b.attributes.zIndex || 0; + + // If both elements have the same z-index (including 0), sort by depth + if (zIndexA === zIndexB) { + return b.depth - a.depth; // Higher depth first (children before parents) + } + + // Otherwise sort by z-index (higher z-index first, as elements below come first) + return zIndexB - zIndexA; + }) + .map(({ attributes }) => attributes); // Extract just the attributes + + return { + elements: sortedElements, + backgroundColor + }; } -async function getSlides(id: string) { - const slides_wrapper = await getSlidesWrapper(id); - const slides = await slides_wrapper.$$(":scope > div > div"); - return slides; -} async function getElementAttributes(element: ElementHandle): Promise { const attributes = await element.evaluate((el) => { @@ -80,15 +203,28 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise= 4) { const offsetX = parseFloat(shadowParts[0]); const offsetY = parseFloat(shadowParts[1]); + const blurRadius = parseFloat(shadowParts[2]); shadow = { offset: (!isNaN(offsetX) && !isNaN(offsetY)) ? [offsetX, offsetY] as [number, number] : undefined, color: colorToHex(shadowParts[3]), opacity: 1, + radius: !isNaN(blurRadius) ? blurRadius : undefined, + angle: !isNaN(offsetX) && !isNaN(offsetY) ? Math.atan2(offsetY, offsetX) * (180 / Math.PI) : undefined, }; } } @@ -132,10 +273,22 @@ async function getElementAttributes(element: ElementHandle): Promise "Hack") + let fontName = undefined; + if (fontFamily !== 'initial') { + const firstFont = fontFamily.split(',')[0].trim().replace(/['"]/g, ''); + fontName = firstFont; + } + const font = { + name: fontName, size: isNaN(fontSize) ? undefined : fontSize, weight: isNaN(fontWeight) ? undefined : fontWeight, color: fontColor, + italic: fontStyle === 'italic', }; // Parse margin @@ -143,30 +296,73 @@ async function getElementAttributes(element: ElementHandle): Promise parseFloat(part)); + if (radiusParts.length === 1) { + borderRadiusValue = radiusParts[0]; + } else if (radiusParts.length === 4) { + borderRadiusValue = radiusParts; + } + } + + // Determine shape for images + let shape: 'rectangle' | 'circle' | undefined; + if (el.tagName.toLowerCase() === 'img') { + shape = borderRadiusValue === 50 ? 'circle' : 'rectangle'; + } + + // Check for text wrap + const textWrap = computedStyles.whiteSpace !== 'nowrap'; + return { tagName: el.tagName.toLowerCase(), id: el.id || undefined, className: el.className || undefined, - innerText: el.textContent || undefined, + innerText, background, border, shadow, @@ -174,34 +370,17 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise { - // Get the root element's bounding rect for relative positioning - const rootRect = await element.evaluate((el) => el.getBoundingClientRect()); - - // Get all child elements as ElementHandles - const childElementHandles = await element.$$(':scope *'); - - // Get attributes for each child element using getElementAttributes - const attributesPromises = childElementHandles.map(async (childElementHandle) => { - const attributes = await getElementAttributes(childElementHandle); - - // Convert positions to relative positions - if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) { - attributes.position = { - left: attributes.position.left - rootRect.left, - top: attributes.position.top - rootRect.top, - width: attributes.position.width, - height: attributes.position.height, - }; - } - - return attributes; - }); - - return Promise.all(attributesPromises); -} \ No newline at end of file diff --git a/servers/nextjs/types/element_attibutes.ts b/servers/nextjs/types/element_attibutes.ts index 5913e730..68adab9b 100644 --- a/servers/nextjs/types/element_attibutes.ts +++ b/servers/nextjs/types/element_attibutes.ts @@ -15,11 +15,15 @@ export interface ElementAttributes { offset?: [number, number]; color?: string; opacity?: number; + radius?: number; + angle?: number; }, font?: { + name?: string; size?: number; weight?: number; color?: string; + italic?: boolean; }; position?: { left?: number; @@ -39,4 +43,19 @@ export interface ElementAttributes { left?: number; right?: number; }; + zIndex?: number; + textAlign?: 'left' | 'center' | 'right' | 'justify'; + borderRadius?: number | number[]; + imageSrc?: string; + objectFit?: 'contain' | 'cover' | 'fill'; + clip?: boolean; + overlay?: string; + shape?: 'rectangle' | 'circle'; + connectorType?: string; + textWrap?: boolean; +} + +export interface SlideAttributesResult { + elements: ElementAttributes[]; + backgroundColor?: string; } \ No newline at end of file diff --git a/servers/nextjs/types/pptx_models.ts b/servers/nextjs/types/pptx_models.ts index 83bd9ee3..e3224998 100644 --- a/servers/nextjs/types/pptx_models.ts +++ b/servers/nextjs/types/pptx_models.ts @@ -112,12 +112,13 @@ export interface PptxConnectorModel extends PptxShapeModel { color?: string; } + export interface PptxSlideModel { + background?: PptxFillModel; shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]; } export interface PptxPresentationModel { - background_color: string; shapes?: PptxShapeModel[]; slides: PptxSlideModel[]; } @@ -145,6 +146,6 @@ export const positionToPtXyxy = (position: PptxPositionModel): number[] => { const top = position.top || 0; const width = position.width || 0; const height = position.height || 0; - + return [left, top, left + width, top + height]; }; diff --git a/servers/nextjs/utils/pptx_models_utils.ts b/servers/nextjs/utils/pptx_models_utils.ts new file mode 100644 index 00000000..e209a084 --- /dev/null +++ b/servers/nextjs/utils/pptx_models_utils.ts @@ -0,0 +1,243 @@ +import { ElementAttributes } from "@/types/element_attibutes"; +import { + PptxSlideModel, + PptxTextBoxModel, + PptxAutoShapeBoxModel, + PptxPictureBoxModel, + PptxConnectorModel, + PptxPositionModel, + PptxSpacingModel, + PptxFillModel, + PptxStrokeModel, + PptxShadowModel, + PptxFontModel, + PptxParagraphModel, + PptxPictureModel, + PptxObjectFitModel, + PptxBoxShapeEnum, + PptxObjectFitEnum +} from "@/types/pptx_models"; + +/** + * Converts ElementAttributes[][] to PptxSlideModel[] + * Each inner array represents elements on a slide + */ +export function convertElementAttributesToPptxSlides( + slidesAttributes: ElementAttributes[][], + backgroundColors?: (string | undefined)[] +): PptxSlideModel[] { + return slidesAttributes.map((slideElements, index) => { + const shapes = slideElements.map(element => { + return convertElementToPptxShape(element); + }).filter(Boolean); // Remove any null/undefined shapes + + const slide: PptxSlideModel = { + shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[] + }; + + // Add background color if available + if (backgroundColors && backgroundColors[index]) { + slide.background = { + color: backgroundColors[index] + }; + } + + return slide; + }); +} + +/** + * Converts a single ElementAttributes to the appropriate PPTX shape model + */ +function convertElementToPptxShape( + element: ElementAttributes +): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null { + // Skip elements without position + if (!element.position) { + return null; + } + + // Check if it's an image element + if (element.tagName === 'img' || element.className?.includes('image')) { + return convertToPictureBox(element); + } + + // Check if it's a text element + if (element.innerText && element.innerText.trim().length > 0) { + return convertToTextBox(element); + } + + // Check if it's a connector/line element + if (element.tagName === 'hr' || element.className?.includes('connector') || element.className?.includes('line')) { + return convertToConnector(element); + } + + // Default to auto shape box for other elements + return convertToAutoShapeBox(element); +} + +/** + * Converts element to PptxTextBoxModel + */ +function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { + const position: PptxPositionModel = { + left: element.position?.left, + top: element.position?.top, + width: element.position?.width, + height: element.position?.height + }; + + const margin: PptxSpacingModel | undefined = element.margin ? { + top: element.margin.top, + bottom: element.margin.bottom, + left: element.margin.left, + right: element.margin.right + } : undefined; + + const fill: PptxFillModel | undefined = element.background?.color ? { + color: element.background.color + } : undefined; + + const font: PptxFontModel | undefined = element.font ? { + name: element.font.name, + size: element.font.size, + bold: element.font.weight ? element.font.weight >= 600 : undefined, + italic: element.font.italic, + color: element.font.color + } : undefined; + + const paragraph: PptxParagraphModel = { + spacing: undefined, + alignment: element.textAlign, + font, + text: element.innerText + }; + + return { + margin, + fill, + position, + text_wrap: element.textWrap ?? true, + paragraphs: [paragraph] + }; +} + +/** + * Converts element to PptxAutoShapeBoxModel + */ +function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel { + const position: PptxPositionModel = { + left: element.position?.left, + top: element.position?.top, + width: element.position?.width, + height: element.position?.height + }; + + const margin: PptxSpacingModel | undefined = element.margin ? { + top: element.margin.top, + bottom: element.margin.bottom, + left: element.margin.left, + right: element.margin.right + } : undefined; + + const fill: PptxFillModel | undefined = element.background?.color ? { + color: element.background.color + } : undefined; + + const stroke: PptxStrokeModel | undefined = element.border?.color ? { + color: element.border.color, + thickness: element.border.width || 1 + } : undefined; + + const shadow: PptxShadowModel | undefined = element.shadow?.color ? { + radius: element.shadow.radius ?? 4, + offset: element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : undefined, + color: element.shadow.color, + opacity: element.shadow.opacity, + angle: element.shadow.angle + } : undefined; + + // Check if element has text content + const paragraphs: PptxParagraphModel[] | undefined = element.innerText ? [{ + spacing: undefined, + alignment: element.textAlign, + font: element.font ? { + name: element.font.name, + size: element.font.size, + bold: element.font.weight ? element.font.weight >= 600 : undefined, + italic: element.font.italic, + color: element.font.color + } : undefined, + text: element.innerText + }] : undefined; + + return { + margin, + fill, + stroke, + shadow, + position, + text_wrap: element.textWrap ?? true, + border_radius: element.borderRadius ? (Array.isArray(element.borderRadius) ? element.borderRadius[0] : element.borderRadius) : 0, + paragraphs + }; +} + +/** + * Converts element to PptxPictureBoxModel + */ +function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel { + const position: PptxPositionModel = { + left: element.position?.left, + top: element.position?.top, + width: element.position?.width, + height: element.position?.height + }; + + const margin: PptxSpacingModel | undefined = element.margin ? { + top: element.margin.top, + bottom: element.margin.bottom, + left: element.margin.left, + right: element.margin.right + } : undefined; + + const objectFit: PptxObjectFitModel = { + fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN + }; + + // Extract image path from element attributes + const picture: PptxPictureModel = { + is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false, + path: element.imageSrc || '' + }; + + return { + position, + margin, + clip: element.clip ?? false, + overlay: element.overlay, + border_radius: element.borderRadius ? (Array.isArray(element.borderRadius) ? element.borderRadius : [element.borderRadius]) : undefined, + shape: element.shape ? (element.shape as PptxBoxShapeEnum) : PptxBoxShapeEnum.RECTANGLE, + object_fit: objectFit, + picture + }; +} + +/** + * Converts element to PptxConnectorModel + */ +function convertToConnector(element: ElementAttributes): PptxConnectorModel { + const position: PptxPositionModel = { + left: element.position?.left, + top: element.position?.top, + width: element.position?.width, + height: element.position?.height + }; + + return { + type: element.connectorType, + position, + thickness: element.border?.width || 1, + color: element.border?.color || element.background?.color || '#000000' + }; +} From 41a17da47d776b18bd70ca301b332d047161a856 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Sat, 19 Jul 2025 13:00:45 +0545 Subject: [PATCH 08/10] chore(fastapi): organizes all env usage in get and set env utilities --- servers/fastapi/api/lifespan.py | 3 +- .../services/image_generation_service.py | 2 +- servers/fastapi/services/redis_service.py | 16 ++++++--- servers/fastapi/services/temp_file_service.py | 4 ++- servers/fastapi/utils/get_env.py | 20 ++++++++++- servers/fastapi/utils/image_provider.py | 17 ++++++--- servers/fastapi/utils/llm_provider.py | 14 ++++---- servers/fastapi/utils/model_availability.py | 35 ++++++++++++------- 8 files changed, 79 insertions(+), 32 deletions(-) diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py index a74785dc..184a55eb 100644 --- a/servers/fastapi/api/lifespan.py +++ b/servers/fastapi/api/lifespan.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from sqlmodel import SQLModel from services import SQL_ENGINE +from utils.get_env import get_app_data_directory_env from utils.model_availability import check_llm_and_image_provider_api_or_model_availability @@ -15,7 +16,7 @@ async def app_lifespan(_: FastAPI): Initializes the application data directory and checks LLM model availability. """ - os.makedirs(os.getenv("APP_DATA_DIRECTORY"), exist_ok=True) + os.makedirs(get_app_data_directory_env(), exist_ok=True) SQLModel.metadata.create_all(SQL_ENGINE) await check_llm_and_image_provider_api_or_model_availability() yield diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 4a1d979c..2bad97dd 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -122,7 +122,7 @@ class ImageGenerationService: 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" + f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=3" ) data = await response.json() image_url = data["hits"][0]["largeImageURL"] diff --git a/servers/fastapi/services/redis_service.py b/servers/fastapi/services/redis_service.py index 5d9dff22..f2e3d8c9 100644 --- a/servers/fastapi/services/redis_service.py +++ b/servers/fastapi/services/redis_service.py @@ -1,15 +1,21 @@ -import os from typing import Any, Optional import redis from redis.exceptions import RedisError +from utils.get_env import ( + get_redis_db_env, + get_redis_host_env, + get_redis_password_env, + get_redis_port_env, +) + class RedisService: def __init__(self): - self.redis_host = os.getenv("REDIS_HOST", "localhost") - self.redis_port = int(os.getenv("REDIS_PORT", "6379")) - self.redis_db = int(os.getenv("REDIS_DB", "0")) - self.redis_password = os.getenv("REDIS_PASSWORD") + self.redis_host = get_redis_host_env() or "localhost" + self.redis_port = int(get_redis_port_env() or "6379") + self.redis_db = int(get_redis_db_env() or "0") + self.redis_password = get_redis_password_env() or None self.client = self._create_client() def _create_client(self) -> redis.Redis: diff --git a/servers/fastapi/services/temp_file_service.py b/servers/fastapi/services/temp_file_service.py index 31a39035..f4c59cf5 100644 --- a/servers/fastapi/services/temp_file_service.py +++ b/servers/fastapi/services/temp_file_service.py @@ -2,11 +2,13 @@ import os import uuid from typing import Optional, Union +from utils.get_env import get_temp_directory_env + class TempFileService: def __init__(self): - self.base_dir = os.getenv("TEMP_DIRECTORY") + self.base_dir = get_temp_directory_env() # TODO: Uncomment this when we want to cleanup the base dir on startup # self.cleanup_base_dir() os.makedirs(self.base_dir, exist_ok=True) diff --git a/servers/fastapi/utils/get_env.py b/servers/fastapi/utils/get_env.py index f8ca88c7..265e3698 100644 --- a/servers/fastapi/utils/get_env.py +++ b/servers/fastapi/utils/get_env.py @@ -56,8 +56,26 @@ 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 + return os.getenv("PIXABAY_API_KEY") + + +def get_redis_host_env(): + return os.getenv("REDIS_HOST") + + +def get_redis_port_env(): + return os.getenv("REDIS_PORT") + + +def get_redis_db_env(): + return os.getenv("REDIS_DB") + + +def get_redis_password_env(): + return os.getenv("REDIS_PASSWORD") diff --git a/servers/fastapi/utils/image_provider.py b/servers/fastapi/utils/image_provider.py index 1dd8f217..53167d1e 100644 --- a/servers/fastapi/utils/image_provider.py +++ b/servers/fastapi/utils/image_provider.py @@ -1,5 +1,12 @@ import os from enums.image_provider import ImageProvider +from utils.get_env import ( + get_google_api_key_env, + get_image_provider_env, + get_openai_api_key_env, + get_pexels_api_key_env, + get_pixabay_api_key_env, +) def is_pixels_selected() -> bool: @@ -24,18 +31,18 @@ def get_selected_image_provider() -> ImageProvider: Returns: ImageProvider: The selected image provider. """ - return ImageProvider(os.getenv("IMAGE_PROVIDER")) + return ImageProvider(get_image_provider_env()) 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") + return get_pexels_api_key_env() elif selected_image_provider == ImageProvider.PIXABAY: - return os.getenv("PIXABAY_API_KEY") + return get_pixabay_api_key_env() elif selected_image_provider == ImageProvider.GEMINI_FLASH: - return os.getenv("GOOGLE_API_KEY") + return get_google_api_key_env() elif selected_image_provider == ImageProvider.DALLE3: - return os.getenv("OPENAI_API_KEY") + return get_openai_api_key_env() else: raise ValueError(f"Invalid image provider: {selected_image_provider}") diff --git a/servers/fastapi/utils/llm_provider.py b/servers/fastapi/utils/llm_provider.py index bb069773..7999ad4a 100644 --- a/servers/fastapi/utils/llm_provider.py +++ b/servers/fastapi/utils/llm_provider.py @@ -7,8 +7,10 @@ from enums.llm_provider import LLMProvider from utils.get_env import ( get_custom_llm_api_key_env, get_custom_llm_url_env, + get_custom_model_env, get_google_api_key_env, get_llm_provider_env, + get_ollama_model_env, get_ollama_url_env, get_openai_api_key_env, ) @@ -93,9 +95,9 @@ def get_large_model(): elif selected_llm == LLMProvider.GOOGLE: return "gemini-2.0-flash" elif selected_llm == LLMProvider.OLLAMA: - return os.getenv("OLLAMA_MODEL") + return get_ollama_model_env() elif selected_llm == LLMProvider.CUSTOM: - return os.getenv("CUSTOM_MODEL") + return get_custom_model_env() else: raise ValueError(f"Invalid LLM model") @@ -107,9 +109,9 @@ def get_small_model(): elif selected_llm == LLMProvider.GOOGLE: return "gemini-2.0-flash" elif selected_llm == LLMProvider.OLLAMA: - return os.getenv("OLLAMA_MODEL") + return get_ollama_model_env() elif selected_llm == LLMProvider.CUSTOM: - return os.getenv("CUSTOM_MODEL") + return get_custom_model_env() else: raise ValueError(f"Invalid LLM model") @@ -121,8 +123,8 @@ def get_nano_model(): elif selected_llm == LLMProvider.GOOGLE: return "gemini-2.0-flash" elif selected_llm == LLMProvider.OLLAMA: - return os.getenv("OLLAMA_MODEL") + return get_ollama_model_env() elif selected_llm == LLMProvider.CUSTOM: - return os.getenv("CUSTOM_MODEL") + return get_custom_model_env() else: raise ValueError(f"Invalid LLM model") diff --git a/servers/fastapi/utils/model_availability.py b/servers/fastapi/utils/model_availability.py index 4b3f1c10..c1dce981 100644 --- a/servers/fastapi/utils/model_availability.py +++ b/servers/fastapi/utils/model_availability.py @@ -2,7 +2,17 @@ import os from constants.supported_ollama_models import SUPPORTED_OLLAMA_MODELS from enums.llm_provider import LLMProvider from utils.custom_llm_provider import list_available_custom_models -from utils.get_env import get_can_change_keys_env +from utils.get_env import ( + get_can_change_keys_env, + get_openai_api_key_env, + get_pixabay_api_key_env, + get_pexels_api_key_env, +) +from utils.get_env import get_google_api_key_env +from utils.get_env import get_ollama_model_env +from utils.get_env import get_custom_llm_api_key_env +from utils.get_env import get_custom_llm_url_env +from utils.get_env import get_custom_model_env from utils.llm_provider import ( get_llm_provider, is_custom_llm_selected, @@ -16,21 +26,22 @@ from utils.image_provider import ( is_dalle3_selected, ) + 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: - openai_api_key = os.getenv("OPENAI_API_KEY") + openai_api_key = get_openai_api_key_env() if not openai_api_key: raise Exception("OPENAI_API_KEY must be provided") elif get_llm_provider() == LLMProvider.GOOGLE: - google_api_key = os.getenv("GOOGLE_API_KEY") + google_api_key = get_google_api_key_env() if not google_api_key: raise Exception("GOOGLE_API_KEY must be provided") elif is_ollama_selected(): - ollama_model = os.getenv("OLLAMA_MODEL") + ollama_model = get_ollama_model_env() if not ollama_model: raise Exception("OLLAMA_MODEL must be provided") @@ -45,9 +56,9 @@ async def check_llm_and_image_provider_api_or_model_availability(): print("-" * 50) elif is_custom_llm_selected(): - custom_model = os.getenv("CUSTOM_MODEL") - custom_llm_url = os.getenv("CUSTOM_LLM_URL") - custom_llm_api_key = os.getenv("CUSTOM_LLM_API_KEY") + custom_model = get_custom_model_env() + custom_llm_url = get_custom_llm_url_env() + custom_llm_api_key = get_custom_llm_api_key_env() if not custom_model: raise Exception("CUSTOM_MODEL must be provided") if not custom_llm_url: @@ -64,21 +75,21 @@ async def check_llm_and_image_provider_api_or_model_availability(): 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") + pexels_api_key = get_pexels_api_key_env() 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") + pixabay_api_key = get_pixabay_api_key_env() if not pixabay_api_key: raise Exception("PIXABAY_API_KEY must be provided") elif is_gemini_flash_selected(): - google_api_key = os.getenv("GOOGLE_API_KEY") + google_api_key = get_google_api_key_env() 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") + openai_api_key = get_openai_api_key_env() if not openai_api_key: - raise Exception("OPENAI_API_KEY must be provided") \ No newline at end of file + raise Exception("OPENAI_API_KEY must be provided") From 6ae502fc9e409eec04ac83cd2ed383a8696679fd Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Sat, 19 Jul 2025 13:26:29 +0545 Subject: [PATCH 09/10] fix(nextjs): changes ollama and custom llm endpoints, fix(fastapi): fixes broken image search due to ImageAsset --- .../fastapi/api/v1/ppt/endpoints/images.py | 12 +- .../fastapi/api/v1/ppt/endpoints/ollama.py | 2 +- .../constants/supported_ollama_models.py | 58 ++--- .../services/image_generation_service.py | 21 +- servers/fastapi/utils/image_provider.py | 1 - servers/nextjs/app/settings/SettingPage.tsx | 209 +++++++++--------- servers/nextjs/app/storeInitializer.tsx | 8 +- servers/nextjs/components/Home.tsx | 107 +++++---- 8 files changed, 210 insertions(+), 208 deletions(-) diff --git a/servers/fastapi/api/v1/ppt/endpoints/images.py b/servers/fastapi/api/v1/ppt/endpoints/images.py index 64acbde8..5af10e88 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/images.py +++ b/servers/fastapi/api/v1/ppt/endpoints/images.py @@ -1,6 +1,8 @@ from fastapi import APIRouter from models.image_prompt import ImagePrompt +from models.sql.image_asset import ImageAsset +from services.database import get_sql_session from services.image_generation_service import ImageGenerationService from utils.asset_directory_utils import get_images_directory @@ -13,4 +15,12 @@ async def generate_image(prompt: str): image_prompt = ImagePrompt(prompt=prompt) image_generation_service = ImageGenerationService(images_directory) - return await image_generation_service.generate_image(image_prompt) + image = await image_generation_service.generate_image(image_prompt) + if not isinstance(image, ImageAsset): + return image + + with get_sql_session() as sql_session: + sql_session.add(image) + sql_session.commit() + + return image.path diff --git a/servers/fastapi/api/v1/ppt/endpoints/ollama.py b/servers/fastapi/api/v1/ppt/endpoints/ollama.py index 04b1a505..13e334a5 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/ollama.py +++ b/servers/fastapi/api/v1/ppt/endpoints/ollama.py @@ -22,7 +22,7 @@ async def get_available_models(): return await list_pulled_ollama_models() -@OLLAMA_ROUTER.get("/models/pull", response_model=OllamaModelStatus) +@OLLAMA_ROUTER.get("/model/pull", response_model=OllamaModelStatus) async def pull_model(model: str, background_tasks: BackgroundTasks): if model not in SUPPORTED_OLLAMA_MODELS: diff --git a/servers/fastapi/constants/supported_ollama_models.py b/servers/fastapi/constants/supported_ollama_models.py index 57a9a257..a46b5774 100644 --- a/servers/fastapi/constants/supported_ollama_models.py +++ b/servers/fastapi/constants/supported_ollama_models.py @@ -8,7 +8,7 @@ SUPPORTED_OLLAMA_MODELS = { description="❌ Graphs not supported.", size="4.7GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3:70b": OllamaModelMetadata( label="Llama 3:70b", @@ -16,7 +16,7 @@ SUPPORTED_OLLAMA_MODELS = { description="✅ Graphs supported.", size="40GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3.1:8b": OllamaModelMetadata( label="Llama 3.1:8b", @@ -24,7 +24,7 @@ SUPPORTED_OLLAMA_MODELS = { description="❌ Graphs not supported.", size="4.9GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3.1:70b": OllamaModelMetadata( label="Llama 3.1:70b", @@ -32,7 +32,7 @@ SUPPORTED_OLLAMA_MODELS = { description="✅ Graphs supported.", size="43GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3.1:405b": OllamaModelMetadata( label="Llama 3.1:405b", @@ -40,7 +40,7 @@ SUPPORTED_OLLAMA_MODELS = { description="✅ Graphs supported.", size="243GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3.2:1b": OllamaModelMetadata( label="Llama 3.2:1b", @@ -48,7 +48,7 @@ SUPPORTED_OLLAMA_MODELS = { description="❌ Graphs not supported.", size="1.3GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3.2:3b": OllamaModelMetadata( label="Llama 3.2:3b", @@ -56,7 +56,7 @@ SUPPORTED_OLLAMA_MODELS = { description="❌ Graphs not supported.", size="2GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama3.3:70b": OllamaModelMetadata( label="Llama 3.3:70b", @@ -64,7 +64,7 @@ SUPPORTED_OLLAMA_MODELS = { description="✅ Graphs supported.", size="43GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama4:16x17b": OllamaModelMetadata( label="Llama 4:16x17b", @@ -72,7 +72,7 @@ SUPPORTED_OLLAMA_MODELS = { description="✅ Graphs supported.", size="67GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), "llama4:128x17b": OllamaModelMetadata( label="Llama 4:128x17b", @@ -80,7 +80,7 @@ SUPPORTED_OLLAMA_MODELS = { description="✅ Graphs supported.", size="245GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/meta.png", + icon="/static/icons/meta.png", ), } @@ -91,7 +91,7 @@ SUPPORTED_GEMMA_MODELS = { description="❌ Graphs not supported.", size="815MB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/gemma.png", + icon="/static/icons/gemma.png", ), "gemma3:4b": OllamaModelMetadata( label="Gemma 3:4b", @@ -99,7 +99,7 @@ SUPPORTED_GEMMA_MODELS = { description="❌ Graphs not supported.", size="3.3GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/gemma.png", + icon="/static/icons/gemma.png", ), "gemma3:12b": OllamaModelMetadata( label="Gemma 3:12b", @@ -107,7 +107,7 @@ SUPPORTED_GEMMA_MODELS = { description="❌ Graphs not supported.", size="8.1GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/gemma.png", + icon="/static/icons/gemma.png", ), "gemma3:27b": OllamaModelMetadata( label="Gemma 3:27b", @@ -115,7 +115,7 @@ SUPPORTED_GEMMA_MODELS = { description="✅ Graphs supported.", size="17GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/gemma.png", + icon="/static/icons/gemma.png", ), } @@ -126,7 +126,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="❌ Graphs not supported.", size="1.1GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), "deepseek-r1:7b": OllamaModelMetadata( label="DeepSeek R1:7b", @@ -134,7 +134,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="❌ Graphs not supported.", size="4.7GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), "deepseek-r1:8b": OllamaModelMetadata( label="DeepSeek R1:8b", @@ -142,7 +142,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="❌ Graphs not supported.", size="5.2GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), "deepseek-r1:14b": OllamaModelMetadata( label="DeepSeek R1:14b", @@ -150,7 +150,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="❌ Graphs not supported.", size="9GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), "deepseek-r1:32b": OllamaModelMetadata( label="DeepSeek R1:32b", @@ -158,7 +158,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="✅ Graphs supported.", size="20GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), "deepseek-r1:70b": OllamaModelMetadata( label="DeepSeek R1:70b", @@ -166,7 +166,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="✅ Graphs supported.", size="43GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), "deepseek-r1:671b": OllamaModelMetadata( label="DeepSeek R1:671b", @@ -174,7 +174,7 @@ SUPPORTED_DEEPSEEK_MODELS = { description="✅ Graphs supported.", size="404GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/deepseek.png", + icon="/static/icons/deepseek.png", ), } @@ -185,7 +185,7 @@ SUPPORTED_QWEN_MODELS = { description="❌ Graphs not supported.", size="523MB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:1.7b": OllamaModelMetadata( label="Qwen 3:1.7b", @@ -193,7 +193,7 @@ SUPPORTED_QWEN_MODELS = { description="❌ Graphs not supported.", size="1.4GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:4b": OllamaModelMetadata( label="Qwen 3:4b", @@ -201,7 +201,7 @@ SUPPORTED_QWEN_MODELS = { description="❌ Graphs not supported.", size="2.6GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:8b": OllamaModelMetadata( label="Qwen 3:8b", @@ -209,7 +209,7 @@ SUPPORTED_QWEN_MODELS = { description="❌ Graphs not supported.", size="5.2GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:14b": OllamaModelMetadata( label="Qwen 3:14b", @@ -217,7 +217,7 @@ SUPPORTED_QWEN_MODELS = { description="❌ Graphs not supported.", size="9.3GB", supports_graph=False, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:30b": OllamaModelMetadata( label="Qwen 3:30b", @@ -225,7 +225,7 @@ SUPPORTED_QWEN_MODELS = { description="✅ Graphs supported.", size="19GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:32b": OllamaModelMetadata( label="Qwen 3:32b", @@ -233,7 +233,7 @@ SUPPORTED_QWEN_MODELS = { description="✅ Graphs supported.", size="20GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), "qwen3:235b": OllamaModelMetadata( label="Qwen 3:235b", @@ -241,7 +241,7 @@ SUPPORTED_QWEN_MODELS = { description="✅ Graphs supported.", size="142GB", supports_graph=True, - icon="/static/servers/fastapi/assets/icons/qwen.png", + icon="/static/icons/qwen.png", ), } diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 2bad97dd..4299ced1 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -9,18 +9,15 @@ 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.llm_provider import get_llm_client from utils.image_provider import ( is_pixels_selected, is_pixabay_selected, is_gemini_flash_selected, - is_dalle3_selected + is_dalle3_selected, ) + class ImageGenerationService: def __init__(self, output_directory: str): @@ -53,14 +50,18 @@ class ImageGenerationService: print("No image generation function found. Using placeholder image.") return "/static/images/placeholder.jpg" - image_prompt = prompt.get_image_prompt(with_theme=not self.is_stock_provider_selected()) + image_prompt = prompt.get_image_prompt( + with_theme=not self.is_stock_provider_selected() + ) print(f"Request - Generating Image for {image_prompt}") try: 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) + image_path = await self.image_gen_func( + image_prompt, self.output_directory + ) if image_path: if image_path.startswith("http"): return image_path @@ -118,11 +119,11 @@ 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={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=3" + f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=1" ) data = await response.json() image_url = data["hits"][0]["largeImageURL"] diff --git a/servers/fastapi/utils/image_provider.py b/servers/fastapi/utils/image_provider.py index 53167d1e..8a2f01ff 100644 --- a/servers/fastapi/utils/image_provider.py +++ b/servers/fastapi/utils/image_provider.py @@ -1,4 +1,3 @@ -import os from enums.image_provider import ImageProvider from utils.get_env import ( get_google_api_key_env, diff --git a/servers/nextjs/app/settings/SettingPage.tsx b/servers/nextjs/app/settings/SettingPage.tsx index 4eb7c3c3..c7c39f9d 100644 --- a/servers/nextjs/app/settings/SettingPage.tsx +++ b/servers/nextjs/app/settings/SettingPage.tsx @@ -178,13 +178,13 @@ const SettingsPage = () => { 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); + const response = await fetch("/api/v1/ppt/ollama/models/supported"); + const models = await response.json(); + setOllamaModels(models); // Check if currently selected model is still available - if (config.OLLAMA_MODEL && data.models.length > 0) { - const isModelAvailable = data.models.some( + if (config.OLLAMA_MODEL && models.length > 0) { + const isModelAvailable = models.some( (model: any) => model.value === config.OLLAMA_MODEL ); if (!isModelAvailable) { @@ -220,7 +220,7 @@ const SettingsPage = () => { const interval = setInterval(async () => { try { const response = await fetch( - `/api/v1/ppt/ollama/pull-model?name=${llmConfig.OLLAMA_MODEL}` + `/api/v1/ppt/ollama/model/pull?model=${llmConfig.OLLAMA_MODEL}` ); if (response.status === 200) { const data = await response.json(); @@ -259,7 +259,7 @@ const SettingsPage = () => { const fetchCustomModels = async () => { try { setCustomModelsLoading(true); - const response = await fetch("/api/v1/ppt/models/list/custom", { + const response = await fetch("/api/v1/ppt/custom_llm/models/available", { method: "POST", headers: { "Content-Type": "application/json", @@ -384,24 +384,22 @@ const SettingsPage = () => { @@ -481,8 +479,8 @@ const SettingsPage = () => { {llmConfig.OLLAMA_MODEL ? ollamaModels.find( - (m) => m.value === llmConfig.OLLAMA_MODEL - )?.label || llmConfig.OLLAMA_MODEL + (m) => m.value === llmConfig.OLLAMA_MODEL + )?.label || llmConfig.OLLAMA_MODEL : "Select a model"} {llmConfig.OLLAMA_MODEL && ( @@ -686,80 +684,80 @@ const SettingsPage = () => { {/* 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. -

-
- -
- - - - - +
+

+ 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} - - - ))} - - - - - + + + + + + + + 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 || @@ -771,11 +769,10 @@ const SettingsPage = () => { disabled={ customModelsLoading || !llmConfig.CUSTOM_LLM_URL } - className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 font-semibold ${ - customModelsLoading || !llmConfig.CUSTOM_LLM_URL + className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 font-semibold ${customModelsLoading || !llmConfig.CUSTOM_LLM_URL ? "bg-gray-100 border-gray-300 cursor-not-allowed text-gray-500" : "bg-white border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-2 focus:ring-blue-500/20" - }`} + }`} > {customModelsLoading ? (
@@ -806,11 +803,10 @@ const SettingsPage = () => { disabled={ customModelsLoading || !llmConfig.CUSTOM_LLM_URL } - className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 font-semibold ${ - customModelsLoading || !llmConfig.CUSTOM_LLM_URL + className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 font-semibold ${customModelsLoading || !llmConfig.CUSTOM_LLM_URL ? "bg-gray-100 border-gray-300 cursor-not-allowed text-gray-500" : "bg-white border-gray-600 text-gray-600 hover:bg-gray-50 focus:ring-2 focus:ring-gray-500/20" - }`} + }`} > {customModelsLoading ? (
@@ -868,7 +864,7 @@ const SettingsPage = () => { {llmConfig.IMAGE_PROVIDER ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] - ?.title || llmConfig.IMAGE_PROVIDER + ?.title || llmConfig.IMAGE_PROVIDER : "Select image provider"}
@@ -962,8 +958,8 @@ const SettingsPage = () => { provider.apiKeyField === "PEXELS_API_KEY" ? llmConfig.PEXELS_API_KEY || "" : provider.apiKeyField === "PIXABAY_API_KEY" - ? llmConfig.PIXABAY_API_KEY || "" - : "" + ? llmConfig.PIXABAY_API_KEY || "" + : "" } onChange={(e) => { if (provider.apiKeyField === "PEXELS_API_KEY") { @@ -998,25 +994,24 @@ const SettingsPage = () => { (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || (llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL) } - className={`mt-8 w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${ - isLoading || - (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || - (llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL) + className={`mt-8 w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${isLoading || + (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || + (llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL) ? "bg-gray-400 cursor-not-allowed" : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200" - } text-white`} + } text-white`} > {isLoading ? (
{(llmConfig.LLM === "ollama" && downloadingModel.downloaded) || - 0 > 0 + 0 > 0 ? `Downloading Model (${( - ((downloadingModel.downloaded || 0) / - (downloadingModel.size || 1)) * - 100 - ).toFixed(0)}%)` + ((downloadingModel.downloaded || 0) / + (downloadingModel.size || 1)) * + 100 + ).toFixed(0)}%)` : "Saving Configuration..."}
) : (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || diff --git a/servers/nextjs/app/storeInitializer.tsx b/servers/nextjs/app/storeInitializer.tsx index 95baf330..6077f7bf 100644 --- a/servers/nextjs/app/storeInitializer.tsx +++ b/servers/nextjs/app/storeInitializer.tsx @@ -84,9 +84,9 @@ export function StoreInitializer({ children }: { children: React.ReactNode }) { const checkIfSelectedOllamaModelIsPulled = async (ollamaModel: string) => { try { - const response = await fetch('/api/v1/ppt/ollama/list-pulled-models'); - const data = await response.json(); - const pulledModels = data.map((model: any) => model.name); + const response = await fetch('/api/v1/ppt/ollama/models/available'); + const models = await response.json(); + const pulledModels = models.map((model: any) => model.name); return pulledModels.includes(ollamaModel); } catch (error) { console.error('Error checking if selected Ollama model is pulled:', error); @@ -96,7 +96,7 @@ export function StoreInitializer({ children }: { children: React.ReactNode }) { const checkIfSelectedCustomModelIsAvailable = async (customModel: string) => { try { - const response = await fetch('/api/v1/ppt/models/list/custom', { + const response = await fetch('/api/v1/ppt/custom_llm/models/available', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/servers/nextjs/components/Home.tsx b/servers/nextjs/components/Home.tsx index 4017c50e..8df8cc13 100644 --- a/servers/nextjs/components/Home.tsx +++ b/servers/nextjs/components/Home.tsx @@ -214,7 +214,8 @@ export default function Home() { const router = useRouter(); const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false); const config = useSelector((state: RootState) => state.userConfig); - const [llmConfig, setLlmConfig] = useState({...config.llm_config, + const [llmConfig, setLlmConfig] = useState({ + ...config.llm_config, IMAGE_PROVIDER: "dall-e-3", }); const [ollamaModels, setOllamaModels] = useState< @@ -297,13 +298,13 @@ export default function Home() { 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 || []); + const response = await fetch("/api/v1/ppt/ollama/models/supported"); + const models = await response.json(); + setOllamaModels(models || []); // Check if currently selected model is still available - if (config.OLLAMA_MODEL && data.models && data.models.length > 0) { - const isModelAvailable = data.models.some( + if (config.OLLAMA_MODEL && models && models.length > 0) { + const isModelAvailable = models.some( (model: any) => model.value === config.OLLAMA_MODEL ); if (!isModelAvailable) { @@ -350,7 +351,7 @@ export default function Home() { const interval = setInterval(async () => { try { const response = await fetch( - `/api/v1/ppt/ollama/pull-model?name=${llmConfig.OLLAMA_MODEL}` + `/api/v1/ppt/ollama/model/pull?model=${llmConfig.OLLAMA_MODEL}` ); if (response.status === 200) { const data = await response.json(); @@ -389,7 +390,7 @@ export default function Home() { const fetchCustomModels = async () => { try { setCustomModelsLoading(true); - const response = await fetch("/api/v1/ppt/models/list/custom", { + const response = await fetch("/api/v1/ppt/custom_llm/models/available", { method: "POST", headers: { "Content-Type": "application/json", @@ -505,19 +506,17 @@ export default function Home() { -
- )} + }`} + > + {customModelsLoading ? ( +
+ + Checking for models... +
+ ) : ( + "Check for available models" + )} + +
+ )} {/* Show message if no models found */} {customModelsChecked && customModels.length === 0 && ( @@ -893,7 +891,7 @@ export default function Home() { {llmConfig.IMAGE_PROVIDER ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || - llmConfig.IMAGE_PROVIDER + llmConfig.IMAGE_PROVIDER : "Select image provider"}
@@ -986,8 +984,8 @@ export default function Home() { provider.apiKeyField === "PEXELS_API_KEY" ? llmConfig.PEXELS_API_KEY || "" : provider.apiKeyField === "PIXABAY_API_KEY" - ? llmConfig.PIXABAY_API_KEY || "" - : "" + ? llmConfig.PIXABAY_API_KEY || "" + : "" } onChange={(e) => { if (provider.apiKeyField === "PEXELS_API_KEY") { @@ -1022,11 +1020,11 @@ export default function Home() { {llmConfig.LLM === "ollama" ? llmConfig.OLLAMA_MODEL ?? "_____" : llmConfig.LLM === "custom" - ? llmConfig.CUSTOM_MODEL ?? "_____" - : PROVIDER_CONFIGS[llmConfig.LLM!].textModels[0].label}{" "} + ? 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] ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER].label : "_____"}{" "} for images @@ -1101,24 +1099,23 @@ export default function Home() { (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || (llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL) } - className={`mt-8 w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${ - isLoading || + className={`mt-8 w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${isLoading || (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || (llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL) - ? "bg-gray-400 cursor-not-allowed" - : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200" - } text-white`} + ? "bg-gray-400 cursor-not-allowed" + : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200" + } text-white`} > {isLoading ? (
{(llmConfig.LLM === "ollama" && downloadingModel.downloaded) || - 0 > 0 + 0 > 0 ? `Downloading Model (${( - ((downloadingModel.downloaded || 0) / - (downloadingModel.size || 1)) * - 100 - ).toFixed(0)}%)` + ((downloadingModel.downloaded || 0) / + (downloadingModel.size || 1)) * + 100 + ).toFixed(0)}%)` : "Saving Configuration..."}
) : (llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL) || From 1bdc5e642efd83725dee41b091239188d7dcfe38 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Sat, 19 Jul 2025 15:59:47 +0545 Subject: [PATCH 10/10] feat(Nextjs): Add EditableLayoutWrapper and enhance image/icon editing capabilities --- .../components/ChartEditor.tsx | 505 ---------------- .../components/EditableLayoutWrapper.tsx | 321 ++++++++++ .../components/IconsEditor.tsx | 208 ++++--- .../components/ImageEditor.tsx | 566 +++++++----------- .../components/SmartEditableWrapper.tsx | 37 +- .../components/slide_config.tsx | 500 ---------------- .../context/LayoutContext.tsx | 196 +++--- .../hooks/useGroupLayouts.tsx | 10 +- .../outline/components/LayoutSelection.tsx | 1 - .../presentation/components/SlideContent.tsx | 2 +- .../dashboard/components/PresentationCard.tsx | 2 +- servers/nextjs/package-lock.json | 37 ++ servers/nextjs/package.json | 1 + .../store/slices/presentationGeneration.ts | 142 +++++ 14 files changed, 927 insertions(+), 1601 deletions(-) delete mode 100644 servers/nextjs/app/(presentation-generator)/components/ChartEditor.tsx create mode 100644 servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx delete mode 100644 servers/nextjs/app/(presentation-generator)/components/slide_config.tsx diff --git a/servers/nextjs/app/(presentation-generator)/components/ChartEditor.tsx b/servers/nextjs/app/(presentation-generator)/components/ChartEditor.tsx deleted file mode 100644 index f672d01d..00000000 --- a/servers/nextjs/app/(presentation-generator)/components/ChartEditor.tsx +++ /dev/null @@ -1,505 +0,0 @@ -import React, { useState } from 'react'; -import { - Sheet, - SheetContent, - SheetTitle, - SheetHeader, -} from "@/components/ui/sheet"; -import { Button } from '@/components/ui/button'; -import { Plus, ChevronDown, Trash, BarChart3, PieChart as PieChartIcon, LineChart as LineChartIcon } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { StoreChartData } from '../utils/chartDataTransforms'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuItem, -} from "@/components/ui/dropdown-menu"; -import { renderChart } from './slide_config'; -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ChartSettings } from '@/store/slices/presentationGeneration'; - -interface ChartEditorProps { - isOpen: boolean; - onClose: () => void; - chartData: StoreChartData; - onChartDataChange: (newData: StoreChartData) => void; - chartSettings: ChartSettings; - setChartSettings: (newSettings: ChartSettings) => void; -} - -const ChartEditor = ({ isOpen, onClose, chartData, onChartDataChange, chartSettings, setChartSettings }: ChartEditorProps) => { - const [selectedCell, setSelectedCell] = useState<{ row: number; col: number } | null>(null); - const { currentColors } = useSelector((state: RootState) => state.theme); - - const handleCategoryChange = (index: number, value: string) => { - const newData = { - ...chartData, - data: { - ...chartData.data, - categories: [ - ...chartData.data.categories.slice(0, index), - value, - ...chartData.data.categories.slice(index + 1) - ] - } - }; - onChartDataChange(newData); - }; - - - const handleValueChange = (categoryIndex: number, seriesIndex: number, value: string) => { - const newData = { - ...chartData, - data: { - ...chartData.data, - series: chartData.data.series.map((series, idx) => { - if (idx === seriesIndex) { - return { - ...series, - data: [...series.data.slice(0, categoryIndex), Number(value), ...series.data.slice(categoryIndex + 1)] - }; - } - return series; - }) - } - }; - onChartDataChange(newData); - }; - - const addCategory = () => { - - const newData = { - ...chartData, - data: { - ...chartData.data, - categories: [...chartData.data.categories, ''], - series: chartData.data.series.map(series => ({ - ...series, - data: [...series.data, 0] - })) - } - }; - onChartDataChange(newData); - }; - - const addSeriesBefore = (index: number) => { - if (chartData.type === 'pie' && chartData.data.series.length >= 1) { - return; - } else { - if (chartData.data.series.length >= 4) { - return; - } - } - const newData = { - ...chartData, - data: { - ...chartData.data, - series: [ - ...chartData.data.series.slice(0, index), - { - name: `Series ${chartData.data.series.length + 1}`, - data: new Array(chartData.data.categories.length).fill(0) - }, - ...chartData.data.series.slice(index) - ] - } - }; - onChartDataChange(newData); - }; - - const addSeriesAfter = (index: number) => { - if (chartData.type === 'pie' && chartData.data.series.length >= 1) { - return; - } else { - if (chartData.data.series.length >= 4) { - return; - } - } - const newData = { - ...chartData, - data: { - ...chartData.data, - series: [ - ...chartData.data.series.slice(0, index + 1), - { - name: `Series ${chartData.data.series.length + 1}`, - data: new Array(chartData.data.categories.length).fill(0) - }, - ...chartData.data.series.slice(index + 1) - ] - } - }; - onChartDataChange(newData); - }; - - const removeCategory = (index: number) => { - const newData = { - ...chartData, - data: { - ...chartData.data, - categories: chartData.data.categories.filter((_, idx) => idx !== index), - series: chartData.data.series.map(series => ({ - ...series, - data: series.data.filter((_, idx) => idx !== index) - })) - } - }; - onChartDataChange(newData); - }; - - const removeSeries = (index: number) => { - const newData = { - ...chartData, - data: { - ...chartData.data, - series: chartData.data.series.filter((_, idx) => idx !== index) - } - }; - onChartDataChange(newData); - }; - - const getColumnLetter = (index: number) => { - return String.fromCharCode(65 + index); - }; - - const isColumnSelected = (colIndex: number) => { - return selectedCell?.col === colIndex; - }; - - const isRowSelected = (rowIndex: number) => { - return selectedCell?.row === rowIndex; - }; - - const isCellSelected = (rowIndex: number, colIndex: number) => { - return selectedCell?.row === rowIndex && selectedCell?.col === colIndex; - }; - const disableAddSeries = (chartType: string) => { - if (chartType === 'pie') { - return chartData.data.series.length >= 1; - } else { - return chartData.data.series.length >= 4; - } - } - - return ( - - e.preventDefault()}> - - Chart Editor - -
-
- {/* Spreadsheet Table */} -
-
- - - - - {/* First column for categories */} - - {/* Data columns for each series */} - {chartData && chartData.data.series && chartData.data.series.map((_, index) => ( - - ))} - - - {/* New row for series names */} - - - - {chartData.data.series.map((series, index) => ( - - ))} - - - - - - {chartData.data.categories.map((category, rowIndex) => ( - - {/* Row Numbers */} - - - {/* Category Cell */} - - - - {/* Series Data Cells */} - {/* series name */} - {chartData.data.series.map((series, seriesIndex) => ( - - ))} - - - - ))} - -
- -
- A -
-
-
- - {getColumnLetter(index + 1)} - - - - - - - addSeriesBefore(index)} disabled={disableAddSeries(chartData.type)}> - - Add Column before - - addSeriesAfter(index)} disabled={disableAddSeries(chartData.type)}> - - Add Column after - - removeSeries(index)}> - - Delete Column - - - -
-
- -
- { - const newSeries = chartData.data.series.map((s, i) => - i === index ? { ...s, name: e.target.value } : s - ); - onChartDataChange({ - ...chartData, - data: { - ...chartData.data, - series: newSeries - } - }); - }} - className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent" - /> -
- {rowIndex + 1} - setSelectedCell({ row: rowIndex, col: 0 })} - > - handleCategoryChange(rowIndex, e.target.value)} - className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent" - /> - setSelectedCell({ row: rowIndex, col: seriesIndex + 1 })} - > - handleValueChange(rowIndex, seriesIndex, e.target.value)} - className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent text-right" - /> - - -
- - {/* Add Row Button */} -
- -
-
-
-
- - {/* Add the chart preview section */} -
-

Preview

-
- {renderChart(chartData, false, currentColors, chartSettings)} -
- - {/* Add chart type selection */} -
-

Chart Type

-
- - - -
-
- {chartData.type !== 'line' && ( -
-
- - setChartSettings({ ...chartSettings, showDataLabel: checked })} - /> -
- - {chartSettings.showDataLabel && ( -
- - - - setChartSettings({ - ...chartSettings, dataLabel: { - ...chartSettings.dataLabel, - dataLabelPosition: 'Inside' - } - })} value="inside">Inside - setChartSettings({ - ...chartSettings, dataLabel: { - ...chartSettings.dataLabel, - dataLabelPosition: 'Outside' - } - })} value="outside">Outside - - {chartData.type === 'bar' && - - - - setChartSettings({ - ...chartSettings, dataLabel: { - ...chartSettings.dataLabel, - dataLabelAlignment: 'Base' - } - })} value="base">Base - setChartSettings({ - ...chartSettings, dataLabel: { - ...chartSettings.dataLabel, - dataLabelAlignment: 'Center' - } - })} value="center">Center - setChartSettings({ - ...chartSettings, dataLabel: { - ...chartSettings.dataLabel, - dataLabelAlignment: 'End' - } - })} value="end">End - - - } - -
- )} -
- )} -
- - setChartSettings({ ...chartSettings, showLegend: checked })} - /> -
- - {chartData.type !== 'pie' &&
- - setChartSettings({ ...chartSettings, showGrid: checked })} - /> -
} - - {chartData.type !== 'pie' &&
- - setChartSettings({ ...chartSettings, showAxisLabel: checked })} - /> -
} -
-
-
-
-
-
- ); -}; - -export default ChartEditor; \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx b/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx new file mode 100644 index 00000000..9e0117b2 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx @@ -0,0 +1,321 @@ +"use client"; + +import React, { ReactNode, useRef, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { updateSlideImage, updateSlideIcon } from '@/store/slices/presentationGeneration'; +import ImageEditor from './ImageEditor'; +import IconsEditor from './IconsEditor'; + +interface EditableLayoutWrapperProps { + children: ReactNode; + slideIndex: number; + slideData: any; + isEditMode?: boolean; +} + +interface EditableElement { + id: string; + type: 'image' | 'icon'; + src: string; + dataPath: string; + data: any; + element: HTMLImageElement; +} + +const EditableLayoutWrapper: React.FC = ({ + children, + slideIndex, + slideData, + isEditMode = true, +}) => { + const dispatch = useDispatch(); + const containerRef = useRef(null); + const [editableElements, setEditableElements] = useState([]); + const [activeEditor, setActiveEditor] = useState(null); + + /** + * Recursively searches for image/icon data in the slide data structure + */ + const findDataPath = (targetUrl: string, data: any, path: string = ''): { path: string; type: 'image' | 'icon'; data: any } | null => { + if (!data || typeof data !== 'object') return null; + + // Check current level for __image_url__ or __icon_url__ + if (data.__image_url__ && isMatchingUrl(data.__image_url__, targetUrl)) { + return { path, type: 'image', data }; + } + + if (data.__icon_url__ && isMatchingUrl(data.__icon_url__, targetUrl)) { + return { path, type: 'icon', data }; + } + + // Recursively check nested objects and arrays + for (const [key, value] of Object.entries(data)) { + const newPath = path ? `${path}.${key}` : key; + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const result = findDataPath(targetUrl, value[i], `${newPath}[${i}]`); + if (result) return result; + } + } else if (value && typeof value === 'object') { + const result = findDataPath(targetUrl, value, newPath); + if (result) return result; + } + } + + return null; + }; + + /** + * Checks if two URLs match using various comparison strategies + */ + const isMatchingUrl = (url1: string, url2: string): boolean => { + if (!url1 || !url2) return false; + + // Direct match + if (url1 === url2) return true; + + // Remove protocol and domain differences + const cleanUrl1 = url1.replace(/^https?:\/\/[^\/]+/, '').replace(/^\/+/, ''); + const cleanUrl2 = url2.replace(/^https?:\/\/[^\/]+/, '').replace(/^\/+/, ''); + + if (cleanUrl1 === cleanUrl2) return true; + + // Handle app_data paths and placeholder URLs + if (url1.includes('/app_data/') || url2.includes('/app_data/') || + url1.includes('placeholder') || url2.includes('placeholder')) { + const getFilename = (path: string) => path.split('/').pop() || ''; + const filename1 = getFilename(url1); + const filename2 = getFilename(url2); + if (filename1 === filename2 && filename1 !== '') return true; + } + + // Extract and compare filenames for other URLs + const getFilename = (path: string) => path.split('/').pop() || ''; + const filename1 = getFilename(url1); + const filename2 = getFilename(url2); + + if (filename1 === filename2 && filename1 !== '') { + return true; + } + + // Check if one URL is contained in another (for partial matches) + if (url1.includes(url2) || url2.includes(url1)) { + return true; + } + + return false; + }; + + /** + * Finds and processes images in the DOM, making them editable + */ + const findAndProcessImages = () => { + if (!containerRef.current || !isEditMode) return; + + const imgElements = containerRef.current.querySelectorAll('img:not([data-editable-processed])'); + const newEditableElements: EditableElement[] = []; + + imgElements.forEach((img, index) => { + const htmlImg = img as HTMLImageElement; + const src = htmlImg.src; + + if (src) { + const result = findDataPath(src, slideData); + + if (result) { + const { path: dataPath, type, data } = result; + + // Mark as processed to prevent re-processing + htmlImg.setAttribute('data-editable-processed', 'true'); + + const editableElement: EditableElement = { + id: `${type}-${dataPath}-${index}`, + type, + src, + dataPath, + data, + element: htmlImg + }; + + newEditableElements.push(editableElement); + + // Add click handler directly to the image + const clickHandler = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + setActiveEditor(editableElement); + }; + + htmlImg.addEventListener('click', clickHandler); + + // Add hover effects without changing layout + htmlImg.style.cursor = 'pointer'; + htmlImg.style.transition = 'filter 0.2s, transform 0.2s'; + + const mouseEnterHandler = () => { + htmlImg.style.filter = 'brightness(0.9)'; + + }; + + const mouseLeaveHandler = () => { + htmlImg.style.filter = 'brightness(1)'; + + }; + + htmlImg.addEventListener('mouseenter', mouseEnterHandler); + htmlImg.addEventListener('mouseleave', mouseLeaveHandler); + + // Store cleanup functions + (htmlImg as any)._editableCleanup = () => { + htmlImg.removeEventListener('click', clickHandler); + htmlImg.removeEventListener('mouseenter', mouseEnterHandler); + htmlImg.removeEventListener('mouseleave', mouseLeaveHandler); + htmlImg.style.cursor = ''; + htmlImg.style.transition = ''; + htmlImg.style.filter = ''; + htmlImg.style.transform = ''; + htmlImg.removeAttribute('data-editable-processed'); + }; + } + } + }); + + setEditableElements(prev => [...prev, ...newEditableElements]); + }; + + /** + * Cleanup function to remove event listeners and reset styles + */ + const cleanupElements = () => { + editableElements.forEach(({ element }) => { + if ((element as any)._editableCleanup) { + (element as any)._editableCleanup(); + } + }); + setEditableElements([]); + }; + + // Wait for LoadableComponent to render and then process images + useEffect(() => { + const timer = setTimeout(() => { + findAndProcessImages(); + }, 300); + + return () => { + clearTimeout(timer); + cleanupElements(); + }; + }, [slideData, children]); + + // Re-run when container content changes + useEffect(() => { + if (!containerRef.current) return; + + const observer = new MutationObserver((mutations) => { + const hasNewImages = mutations.some(mutation => + Array.from(mutation.addedNodes).some(node => + node.nodeType === Node.ELEMENT_NODE && + ( + (node as Element).tagName === 'IMG' || + (node as Element).querySelector('img:not([data-editable-processed])') + ) + ) + ); + + if (hasNewImages) { + setTimeout(findAndProcessImages, 100); + } + }); + + observer.observe(containerRef.current, { + childList: true, + subtree: true + }); + + return () => observer.disconnect(); + }, [slideData]); + + /** + * Handles closing the active editor + */ + const handleEditorClose = () => { + setActiveEditor(null); + }; + + /** + * Handles image change from ImageEditor + */ + const handleImageChange = (newImageUrl: string, prompt?: string) => { + if (activeEditor && activeEditor.element) { + // Update the DOM element immediately for visual feedback + activeEditor.element.src = newImageUrl; + + // Update Redux store + dispatch(updateSlideImage({ + slideIndex, + dataPath: activeEditor.dataPath, + imageUrl: newImageUrl, + prompt: prompt || activeEditor.data?.__image_prompt__ || '' + })); + + setActiveEditor(null); + } + }; + + /** + * Handles icon change from IconsEditor + */ + const handleIconChange = (newIconUrl: string, query?: string) => { + if (activeEditor && activeEditor.element) { + // Update the DOM element immediately for visual feedback + activeEditor.element.src = newIconUrl; + + // Update Redux store + dispatch(updateSlideIcon({ + slideIndex, + dataPath: activeEditor.dataPath, + iconUrl: newIconUrl, + query: query || activeEditor.data?.__icon_query__ || '' + })); + + setActiveEditor(null); + } + }; + + return ( +
+ {children} + + {/* Render ImageEditor when an image is being edited */} + {activeEditor && activeEditor.type === 'image' && ( + +
+ + )} + + {/* Render IconsEditor when an icon is being edited */} + {activeEditor && activeEditor.type === 'icon' && ( + +
+ + )} +
+ ); +}; + +export default EditableLayoutWrapper; \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/components/IconsEditor.tsx b/servers/nextjs/app/(presentation-generator)/components/IconsEditor.tsx index ca8d6e17..1cfb6568 100644 --- a/servers/nextjs/app/(presentation-generator)/components/IconsEditor.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/IconsEditor.tsx @@ -7,66 +7,52 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { Input } from "@/components/ui/input"; -import { PlusIcon, Search } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useDispatch, useSelector } from "react-redux"; -import { PresentationGenerationApi } from "../services/api/presentation-generation"; -import { RootState } from "@/store/store"; -import { usePathname, useSearchParams } from "next/navigation"; +import { Search } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; -import { updateSlideIcon } from "@/store/slices/presentationGeneration"; +import { PresentationGenerationApi } from "../services/api/presentation-generation"; import { getStaticFileUrl } from "../utils/others"; interface IconsEditorProps { icon: string; index: number; - backgroundColor: string; - hasBg: boolean; - slideIndex: number; - elementId: string; - isWhite?: boolean; className?: string; icon_prompt?: string[] | null; onClose?: () => void; + onIconChange?: (newIconUrl: string, query?: string) => void; } const IconsEditor = ({ icon: initialIcon, - index, - backgroundColor, - hasBg, - className, - slideIndex, - elementId, icon_prompt, onClose, -}: IconsEditorProps) => { - const dispatch = useDispatch(); + onIconChange, +}: IconsEditorProps) => { + // State management const [icon, setIcon] = useState(initialIcon); const [icons, setIcons] = useState([]); - const [isEditorOpen, setIsEditorOpen] = useState(false); const [searchQuery, setSearchQuery] = useState( icon_prompt?.[0] || "" ); const [loading, setLoading] = useState(true); + const searchParams = useSearchParams(); + // Update local state when initial icon changes useEffect(() => { setIcon(initialIcon); }, [initialIcon]); + // Search for icons when component opens useEffect(() => { - if (isEditorOpen) { - handleIconSearch(); - } - }, [isEditorOpen]); - - const handleIconClick = () => { - setIsEditorOpen(true); - }; + handleIconSearch(); + }, []); + /** + * Searches for icons based on the current query + */ const handleIconSearch = async () => { setLoading(true); const presentation_id = searchParams.get("id"); @@ -88,94 +74,100 @@ const IconsEditor = ({ } }; + /** + * Handles icon selection and calls the parent callback + */ const handleIconChange = (newIcon: string) => { - - setIcon(newIcon); - dispatch( - updateSlideIcon({ index: slideIndex, iconIdx: index, icon: newIcon }) - ); - setIsEditorOpen(false); + + if (onIconChange) { + onIconChange(newIcon, searchQuery || icon_prompt?.[0] || ''); + } }; return ( - onClose?.()}> - e.preventDefault()} - onClick={(e) => e.stopPropagation()} - > - - Choose Icon - -
-
{ - e.preventDefault(); - e.stopPropagation(); - handleIconSearch(); - }} - > -
- +
- setSearchQuery(e.target.value)} - onClick={(e) => e.stopPropagation()} - className="pl-10" - /> -
- - +
+ + setSearchQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + className="pl-10" + /> +
+ + - {/* Icons grid */} -
- {loading ? ( -
- {Array.from({ length: 40 }).map((_, index) => ( - - ))} -
- ) : icons.length > 0 ? ( -
- {icons.map((iconSrc, idx) => ( -
{ - e.stopPropagation(); - handleIconChange(iconSrc); - }} - className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2" - > - {`Icon -
- ))} -
- ) : ( -
- -

No icons found for your search.

-

Try refining your search query.

-
- )} + {/* Icons Grid */} +
+ {loading ? ( +
+ {Array.from({ length: 40 }).map((_, index) => ( + + ))} +
+ ) : icons.length > 0 ? ( +
+ {icons.map((iconSrc, idx) => ( +
{ + e.stopPropagation(); + handleIconChange(iconSrc); + }} + className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2 transition-colors" + > + {`Icon +
+ ))} +
+ ) : ( +
+ +

No icons found for your search.

+

Try refining your search query.

+
+ )} +
-
- - + + +
); }; diff --git a/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx b/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx index 14349032..cdf5a3c7 100644 --- a/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx @@ -13,32 +13,25 @@ import { Wand2, Upload, Move, - } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { PresentationGenerationApi } from "../services/api/presentation-generation"; import { RootState } from "@/store/store"; import { useSearchParams } from "next/navigation"; import { Skeleton } from "@/components/ui/skeleton"; -import { - updateSlideImage, - updateSlideProperties, -} from "@/store/slices/presentationGeneration"; -import { getStaticFileUrl, ThemeImagePrompt } from "../utils/others"; - - +import { ThemeImagePrompt } from "../utils/others"; interface ImageEditorProps { initialImage: string | null; imageIdx?: number; - slideIndex: number; - className?: string; promptContent?: string; properties?: null | any; onClose?: () => void; + onImageChange?: (newImageUrl: string, prompt?: string) => void; + } const ImageEditor = ({ @@ -48,12 +41,13 @@ const ImageEditor = ({ promptContent, properties, onClose, + onImageChange, + }: ImageEditorProps) => { - const dispatch = useDispatch(); - const { currentTheme } = useSelector((state: RootState) => state.theme); - const searchParams = useSearchParams(); + + // State management const [image, setImage] = useState(initialImage); const [previewImages, setPreviewImages] = useState([initialImage]); const [prompt, setPrompt] = useState(""); @@ -62,6 +56,8 @@ const ImageEditor = ({ const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadedImageUrl, setUploadedImageUrl] = useState(null); + + // Focus point and object fit for image editing const [isFocusPointMode, setIsFocusPointMode] = useState(false); const [focusPoint, setFocusPoint] = useState( (properties && @@ -77,11 +73,14 @@ const ImageEditor = ({ properties[imageIdx].initialObjectFit) || "cover" ); + + // Refs const imageRef = useRef(null); const imageContainerRef = useRef(null); const toolbarRef = useRef(null); const popoverContentRef = useRef(null); + // Update local state when initial image changes useEffect(() => { setImage(initialImage); setPreviewImages([initialImage]); @@ -97,9 +96,7 @@ const ImageEditor = ({ !toolbarRef.current.contains(event.target as Node) && !popoverContentRef.current ) { - if (isFocusPointMode) { - // saveFocusPoint(); // Save focus point before closing saveImageProperties(objectFit, focusPoint); } setIsFocusPointMode(false); @@ -110,21 +107,22 @@ const ImageEditor = ({ return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isFocusPointMode, focusPoint]); - - + }, [isFocusPointMode, focusPoint, objectFit]); + /** + * Handles image selection and calls the parent callback + */ const handleImageChange = (newImage: string) => { setImage(newImage); - dispatch( - updateSlideImage({ - index: slideIndex, - imageIdx: imageIdx, - image: newImage, - }) - ); + + if (onImageChange) { + onImageChange(newImage, promptContent); + } }; + /** + * Handles focus point adjustment when clicking on the image + */ const handleFocusPointClick = (e: React.MouseEvent) => { if (!isFocusPointMode || !imageRef.current) return; @@ -147,14 +145,19 @@ const ImageEditor = ({ } }; + /** + * Toggles focus point adjustment mode + */ const toggleFocusPointMode = () => { if (isFocusPointMode) { - // If turning off focus point mode, save the current focus point - // saveFocusPoint(); + saveImageProperties(objectFit, focusPoint); } setIsFocusPointMode(!isFocusPointMode); }; + /** + * Handles object fit change + */ const handleFitChange = (fit: "cover" | "contain" | "fill") => { setObjectFit(fit); @@ -162,10 +165,12 @@ const ImageEditor = ({ imageRef.current.style.objectFit = fit; } - // Save the fit change to your state saveImageProperties(fit, focusPoint); }; + /** + * Saves image properties (focus point and object fit) + */ const saveImageProperties = ( fit: "cover" | "contain" | "fill", focusPoint: { x: number; y: number } @@ -174,16 +179,12 @@ const ImageEditor = ({ initialObjectFit: fit, initialFocusPoint: focusPoint, }; - - dispatch( - updateSlideProperties({ - index: slideIndex, - itemIdx: imageIdx, - properties: propertiesData, - }) - ); + // TODO: Save to Redux store if needed }; + /** + * Generates new images using AI + */ const handleGenerateImage = async () => { try { setIsGenerating(true); @@ -208,26 +209,24 @@ const ImageEditor = ({ } }; + /** + * Handles file upload + */ const handleFileUpload = async ( event: React.ChangeEvent ) => { - const presentation_id = searchParams.get("id"); const file = event.target.files?.[0]; if (!file) return; - // Check file size (e.g., 5MB limit) + // Validate file size (5MB limit) if (file.size > 5 * 1024 * 1024) { - const error_message = "File size should be less than 5MB"; - - setUploadError(error_message); + setUploadError("File size should be less than 5MB"); return; } - // Check file type + // Validate file type if (!file.type.startsWith("image/")) { - const error_message = "Please upload an image file"; - - setUploadError(error_message); + setUploadError("Please upload an image file"); return; } @@ -249,356 +248,191 @@ const ImageEditor = ({ throw new Error(result.error || 'Upload failed'); } - // Update state with the returned path setUploadedImageUrl(result.filePath); } catch (err) { - const error_message = "Failed to upload image. Please try again."; - - setUploadError(error_message); + setUploadError("Failed to upload image. Please try again."); console.error("Upload error:", err); } finally { setIsUploading(false); } }; - - return ( - onClose?.()}> - e.preventDefault()} - onClick={(e) => e.stopPropagation()} - > - - Update Image - +
-
- - - - Edit - - - AI Generate - - - Upload - - - -
- {/* Current Image Preview */} -
-

Current Image

-
- {image ? ( - Current image { - e.stopPropagation(); - handleFocusPointClick(e); - }} - onError={(e) => { - console.error('Image failed to load:', image); - e.currentTarget.src = '/placeholder-image.png'; - }} - /> - ) : ( -
-
- -

No image selected

-
-
- )} + onClose?.()}> + e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > + + Update Image + - {/* Focus Point Indicator */} - {isFocusPointMode && image && ( -
- )} -
- {/* Debug info */} - {image && ( -
-

Image Path: {image}

-

Resolved URL: {image}

-

Focus Point: {focusPoint.x.toFixed(1)}%, {focusPoint.y.toFixed(1)}%

-

Object Fit: {objectFit}

-
- )} -
- - {/* Editing Controls */} +
+ + + + AI Generate + + + Upload + + + {/* Generate Tab */} +
- {/* Focus Point Controls */} -
-
-

Focus Point

- -
- {isFocusPointMode && ( -

- Click on the image above to set the focus point -

- )} +
+

Current Prompt

+

{promptContent}

- {/* Object Fit Controls */} -
-

Image Fit

-
- - - -
-
-

Cover: Fill container, may crop image

-

Contain: Fit entire image, may show empty space

-

Fill: Stretch to fill container exactly

-
+
+

Image Description

+