Merge branch 'feat/custom_schema_and_layout' of https://github.com/presenton/presenton into feat/custom_schema_and_layout

merge
This commit is contained in:
Suraj Jha 2025-07-19 17:59:24 +05:45
commit 130d6b7141
60 changed files with 4742 additions and 3758 deletions

View file

@ -5,12 +5,18 @@ from fastapi import FastAPI
from sqlmodel import SQLModel
from services import SQL_ENGINE
from utils.model_availability import check_llm_model_availability
from utils.get_env import get_app_data_directory_env
from utils.model_availability import check_llm_and_image_provider_api_or_model_availability
@asynccontextmanager
async def app_lifespan(_: FastAPI):
os.makedirs(os.getenv("APP_DATA_DIRECTORY"), exist_ok=True)
"""
Lifespan context manager for FastAPI application.
Initializes the application data directory and checks LLM model availability.
"""
os.makedirs(get_app_data_directory_env(), 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

View file

@ -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

View file

@ -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)

View file

@ -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("<br>", "\n")
with open(file_path, "w") as text_file:

View file

@ -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

View file

@ -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("/model/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,
)

View file

@ -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)

View file

@ -1,14 +1,14 @@
from models.ollama_model_metadata import OllamaModelMetadata
SUPPORTED_LLAMA_MODELS = {
SUPPORTED_OLLAMA_MODELS = {
"llama3:8b": OllamaModelMetadata(
label="Llama 3:8b",
value="llama3:8b",
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_LLAMA_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_LLAMA_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_LLAMA_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_LLAMA_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_LLAMA_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_LLAMA_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_LLAMA_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_LLAMA_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_LLAMA_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,12 +241,12 @@ SUPPORTED_QWEN_MODELS = {
description="✅ Graphs supported.",
size="142GB",
supports_graph=True,
icon="/static/servers/fastapi/assets/icons/qwen.png",
icon="/static/icons/qwen.png",
),
}
SUPPORTED_OLLAMA_MODELS = {
**SUPPORTED_LLAMA_MODELS,
**SUPPORTED_OLLAMA_MODELS,
**SUPPORTED_GEMMA_MODELS,
**SUPPORTED_DEEPSEEK_MODELS,
**SUPPORTED_QWEN_MODELS,

View file

@ -0,0 +1,7 @@
from enum import Enum
class ImageProvider(Enum):
PEXELS = "pexels"
PIXABAY = "pixabay"
GEMINI_FLASH = "gemini_flash"
DALLE3 = "dall-e-3"

View file

@ -144,6 +144,7 @@ class PptxConnectorModel(PptxShapeModel):
class PptxSlideModel(BaseModel):
background: Optional[PptxFillModel] = None
shapes: List[
PptxTextBoxModel
| PptxAutoShapeBoxModel

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -8,10 +8,13 @@ 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.llm_provider import (
get_llm_client,
is_google_selected,
is_openai_selected,
from utils.get_env import get_pixabay_api_key_env
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,
)
@ -19,32 +22,46 @@ 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_gemini_flash_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 +119,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={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=1"
)
data = await response.json()
image_url = data["hits"][0]["largeImageURL"]
return image_url

View file

@ -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)

View file

@ -0,0 +1,115 @@
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 = 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:
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

View file

@ -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)

View file

@ -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_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)
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_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)
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_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)
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_gemini_flash_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_gemini_flash_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_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)
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_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)
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())

View file

@ -55,3 +55,27 @@ 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")
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")

View file

@ -0,0 +1,47 @@
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:
return ImageProvider.PEXELS == get_selected_image_provider()
def is_pixabay_selected() -> bool:
return ImageProvider.PIXABAY == get_selected_image_provider()
def is_gemini_flash_selected() -> bool:
return ImageProvider.GEMINI_FLASH == 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(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 get_pexels_api_key_env()
elif selected_image_provider == ImageProvider.PIXABAY:
return get_pixabay_api_key_env()
elif selected_image_provider == ImageProvider.GEMINI_FLASH:
return get_google_api_key_env()
elif selected_image_provider == ImageProvider.DALLE3:
return get_openai_api_key_env()
else:
raise ValueError(f"Invalid image provider: {selected_image_provider}")

View file

@ -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):

View file

@ -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")

View file

@ -2,30 +2,46 @@ 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,
is_ollama_selected,
)
from utils.ollama import pull_ollama_model
from utils.image_provider import (
is_pixels_selected,
is_pixabay_selected,
is_gemini_flash_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:
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")
@ -40,9 +56,9 @@ async def check_llm_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:
@ -58,3 +74,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 = 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 = 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 = 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 = get_openai_api_key_env()
if not openai_api_key:
raise Exception("OPENAI_API_KEY must be provided")

View file

@ -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

View file

@ -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)

View file

@ -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 (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent side="bottom" className="h-[80vh] overflow-y-auto" onOpenAutoFocus={(e) => e.preventDefault()}>
<SheetHeader className='mb-4'>
<SheetTitle>Chart Editor</SheetTitle>
</SheetHeader>
<div className="grid grid-cols-2 items-start gap-8 h-full">
<div className="space-y-4">
{/* Spreadsheet Table */}
<div className="rounded-md border bg-white">
<div className=" overflow-hidden">
<table className="w-full border-collapse ">
<thead className='w-full'>
<tr>
<th className={`w-12 border-b border-r p-2 sticky top-0 z-10 transition-colors duration-200
${selectedCell ? 'bg-[#f3f3f3]' : 'bg-[#f8f9fa]'}`}>
</th>
{/* First column for categories */}
<th className={`border-b border-r p-2 sticky top-0 z-10 transition-colors duration-200
${isColumnSelected(0) ? 'bg-[#e8f0fe]' : 'bg-[#f8f9fa]'}`}>
<div className="flex items-center justify-between">
<span className="text-[13px] text-gray-600">A</span>
</div>
</th>
{/* Data columns for each series */}
{chartData && chartData.data.series && chartData.data.series.map((_, index) => (
<th key={index}
className={`border-b border-r p-2 sticky top-0 z-10 transition-colors duration-200
${isColumnSelected(index + 1) ? 'bg-[#e8f0fe]' : 'bg-[#f8f9fa]'}`}>
<div className="flex items-center justify-between">
<span className="text-[13px] text-gray-600">
{getColumnLetter(index + 1)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] space-y-2">
<DropdownMenuItem className='cursor-pointer hover:bg-gray-100' onClick={() => addSeriesBefore(index)} disabled={disableAddSeries(chartData.type)}>
<Plus className="mr-2 h-4 w-4" />
Add Column before
</DropdownMenuItem>
<DropdownMenuItem className='cursor-pointer hover:bg-gray-100' onClick={() => addSeriesAfter(index)} disabled={disableAddSeries(chartData.type)}>
<Plus className="mr-2 h-4 w-4" />
Add Column after
</DropdownMenuItem>
<DropdownMenuItem className='cursor-pointer hover:bg-gray-100' onClick={() => removeSeries(index)}>
<Trash className="mr-2 h-4 w-4 text-red-500" />
Delete Column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</th>
))}
<th className="w-10 bg-[#f8f9fa] border-b p-2 sticky top-0 z-10">
<Button
onClick={() => addSeriesAfter(chartData.data.series.length - 1)}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
disabled={disableAddSeries(chartData.type)}
>
<Plus className="h-4 w-4" />
</Button>
</th>
</tr>
{/* New row for series names */}
<tr>
<td className="border-r p-2 bg-[#f8f9fa]"></td>
<td className="border-r p-2 bg-[#f8f9fa]"></td>
{chartData.data.series.map((series, index) => (
<td key={index} className="border p-1 bg-[#f8f9fa]">
<Input
value={series.name}
onChange={(e) => {
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"
/>
</td>
))}
<td className="w-10 bg-[#f8f9fa]"></td>
</tr>
</thead>
<tbody className='block h-full max-h-[500px] custom_scrollbar overflow-y-auto'>
{chartData.data.categories.map((category, rowIndex) => (
<tr key={rowIndex} className="group">
{/* Row Numbers */}
<td className={`border-r p-2 text-[13px] text-gray-600 w-12 text-center transition-colors duration-200
${isRowSelected(rowIndex) ? 'bg-[#e8f0fe]' : 'bg-[#f8f9fa]'}`}>
{rowIndex + 1}
</td>
{/* Category Cell */}
<td
className={`border p-1 relative transition-all duration-200
${isCellSelected(rowIndex, 0)
? 'bg-[#e8f0fe] outline outline-2 outline-blue-500 z-10'
: 'hover:bg-[#f1f3f4]'}`}
onClick={() => setSelectedCell({ row: rowIndex, col: 0 })}
>
<Input
value={category}
onChange={(e) => handleCategoryChange(rowIndex, e.target.value)}
className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent"
/>
</td>
{/* Series Data Cells */}
{/* series name */}
{chartData.data.series.map((series, seriesIndex) => (
<td
key={seriesIndex}
className={`border p-1 relative transition-all duration-200
${isCellSelected(rowIndex, seriesIndex + 1)
? 'bg-[#e8f0fe] outline outline-2 outline-blue-500 z-10'
: 'hover:bg-[#f1f3f4]'}`}
onClick={() => setSelectedCell({ row: rowIndex, col: seriesIndex + 1 })}
>
<Input
type="number"
value={series.data[rowIndex]}
onChange={(e) => handleValueChange(rowIndex, seriesIndex, e.target.value)}
className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent text-right"
/>
</td>
))}
<td className="w-10 p-1">
<Button
variant="ghost"
size="sm"
onClick={() => removeCategory(rowIndex)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-600 hover:bg-red-50"
>
<Trash className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{/* Add Row Button */}
<div className="p-2 border-t">
<Button
onClick={addCategory}
variant="ghost"
size="sm"
className="w-full h-7 text-[13px] hover:bg-[#f8f9fa]"
>
<Plus className="h-4 w-4 mr-2" />
Add row
</Button>
</div>
</div>
</div>
</div>
{/* Add the chart preview section */}
<div className="border rounded-lg p-4 bg-white">
<h3 className="text-lg font-semibold mb-4">Preview</h3>
<div className="w-full" style={{ backgroundColor: currentColors.slideBg }}>
{renderChart(chartData, false, currentColors, chartSettings)}
</div>
{/* Add chart type selection */}
<div className="mt-4 border-t pt-4 custom_scrollbar">
<h4 className="text-sm font-medium mb-2">Chart Type</h4>
<div className="flex gap-2">
<Button
variant={chartData.type === 'bar' ? 'default' : 'outline'}
size="sm"
onClick={() => {
const newData = { ...chartData, type: 'bar' as 'bar' };
onChartDataChange(newData);
}}
className="flex items-center gap-2"
>
<BarChart3 className="h-4 w-4" />
Bar
</Button>
<Button
variant={chartData.type === 'line' ? 'default' : 'outline'}
size="sm"
onClick={() => {
const newData = { ...chartData, type: 'line' as 'line' };
onChartDataChange(newData);
}}
className="flex items-center gap-2"
>
<LineChartIcon className="h-4 w-4" />
Line
</Button>
<Button
variant={chartData.type === 'pie' ? 'default' : 'outline'}
size="sm"
onClick={() => {
const newData = { ...chartData, type: 'pie' as 'pie' };
onChartDataChange(newData);
}}
className="flex items-center gap-2"
>
<PieChartIcon className="h-4 w-4" />
Pie
</Button>
</div>
<div className="border-t mt-6 pt-4 mb-6 flex flex-col items-start gap-4">
{chartData.type !== 'line' && (
<div className="space-y-4">
<div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
<Label htmlFor="data-label" className="font-medium">Data Label</Label>
<Switch
id="data-label"
checked={chartSettings.showDataLabel}
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showDataLabel: checked })}
/>
</div>
{chartSettings.showDataLabel && (
<div className="space-y-4 p-4 max-w-[350px] bg-gray-50 rounded-lg">
<Label className="font-medium block mb-2">Data Label Position</Label>
<Tabs className="w-full" defaultValue={chartSettings.dataLabel.dataLabelPosition.toLowerCase()}>
<TabsList className="w-full grid grid-cols-2 mb-4">
<TabsTrigger onClick={() => setChartSettings({
...chartSettings, dataLabel: {
...chartSettings.dataLabel,
dataLabelPosition: 'Inside'
}
})} value="inside">Inside</TabsTrigger>
<TabsTrigger onClick={() => setChartSettings({
...chartSettings, dataLabel: {
...chartSettings.dataLabel,
dataLabelPosition: 'Outside'
}
})} value="outside">Outside</TabsTrigger>
</TabsList>
{chartData.type === 'bar' && <TabsContent value="inside">
<Label className="font-medium block mb-2">Data Label Alignment</Label>
<Tabs className="w-full" defaultValue={chartSettings.dataLabel.dataLabelAlignment.toLowerCase()}>
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger onClick={() => setChartSettings({
...chartSettings, dataLabel: {
...chartSettings.dataLabel,
dataLabelAlignment: 'Base'
}
})} value="base">Base</TabsTrigger>
<TabsTrigger onClick={() => setChartSettings({
...chartSettings, dataLabel: {
...chartSettings.dataLabel,
dataLabelAlignment: 'Center'
}
})} value="center">Center</TabsTrigger>
<TabsTrigger onClick={() => setChartSettings({
...chartSettings, dataLabel: {
...chartSettings.dataLabel,
dataLabelAlignment: 'End'
}
})} value="end">End</TabsTrigger>
</TabsList>
</Tabs>
</TabsContent>}
</Tabs>
</div>
)}
</div>
)}
<div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
<Label htmlFor="legend" className="font-medium">Legend</Label>
<Switch
id="legend"
checked={chartSettings.showLegend}
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showLegend: checked })}
/>
</div>
{chartData.type !== 'pie' && <div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
<Label htmlFor="grid" className="font-medium">Grid Lines</Label>
<Switch
id="grid"
checked={chartSettings.showGrid}
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showGrid: checked })}
/>
</div>}
{chartData.type !== 'pie' && <div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
<Label htmlFor="axis-labels" className="font-medium">Axis Labels</Label>
<Switch
id="axis-labels"
checked={chartSettings.showAxisLabel}
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showAxisLabel: checked })}
/>
</div>}
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
};
export default ChartEditor;

View file

@ -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<EditableLayoutWrapperProps> = ({
children,
slideIndex,
slideData,
isEditMode = true,
}) => {
const dispatch = useDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
const [activeEditor, setActiveEditor] = useState<EditableElement | null>(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 (
<div ref={containerRef} className="editable-layout-wrapper">
{children}
{/* Render ImageEditor when an image is being edited */}
{activeEditor && activeEditor.type === 'image' && (
<ImageEditor
initialImage={activeEditor.src}
slideIndex={slideIndex}
promptContent={activeEditor.data?.__image_prompt__ || ''}
imageIdx={0}
properties={null}
onClose={handleEditorClose}
onImageChange={handleImageChange}
>
<div />
</ImageEditor>
)}
{/* Render IconsEditor when an icon is being edited */}
{activeEditor && activeEditor.type === 'icon' && (
<IconsEditor
icon={activeEditor.src}
index={0}
icon_prompt={activeEditor.data?.__icon_query__ ? [activeEditor.data.__icon_query__] : []}
onClose={handleEditorClose}
onIconChange={handleIconChange}
>
<div />
</IconsEditor>
)}
</div>
);
};
export default EditableLayoutWrapper;

View file

@ -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<string[]>([]);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>(
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 (
<Sheet open={true} onOpenChange={() => onClose?.()}>
<SheetContent
side="right"
className="w-[400px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<SheetHeader>
<SheetTitle>Choose Icon</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleIconSearch();
}}
>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<div className="icons-editor-container">
<Input
placeholder="Search icons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="pl-10"
/>
</div>
<Button
type="submit"
variant="outline"
className="w-full text-semibold text-[#51459e]"
onClick={(e) => e.stopPropagation()}
<Sheet open={true} onOpenChange={() => onClose?.()}>
<SheetContent
side="right"
className="w-[400px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<SheetHeader>
<SheetTitle>Choose Icon</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
{/* Search Form */}
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleIconSearch();
}}
>
Search
</Button>
</form>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<Input
placeholder="Search icons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="pl-10"
/>
</div>
<Button
type="submit"
variant="outline"
className="w-full text-semibold text-[#51459e]"
onClick={(e) => e.stopPropagation()}
>
Search
</Button>
</form>
{/* Icons grid */}
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
{loading ? (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 40 }).map((_, index) => (
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
))}
</div>
) : icons.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{icons.map((iconSrc, idx) => (
<div
key={idx}
onClick={(e) => {
e.stopPropagation();
handleIconChange(iconSrc);
}}
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2"
>
<img
src={getStaticFileUrl(iconSrc)}
alt={`Icon ${idx + 1}`}
className="w-full h-full object-contain "
/>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
<Search className="w-12 h-12 text-gray-400" />
<p className="text-sm">No icons found for your search.</p>
<p className="text-xs">Try refining your search query.</p>
</div>
)}
{/* Icons Grid */}
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
{loading ? (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 40 }).map((_, index) => (
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
))}
</div>
) : icons.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{icons.map((iconSrc, idx) => (
<div
key={idx}
onClick={(e) => {
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"
>
<img
src={getStaticFileUrl(iconSrc)}
alt={`Icon ${idx + 1}`}
className="w-full h-full object-contain"
/>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
<Search className="w-12 h-12 text-gray-400" />
<p className="text-sm">No icons found for your search.</p>
<p className="text-xs">Try refining your search query.</p>
</div>
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
</SheetContent>
</Sheet>
</div>
);
};

View file

@ -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<string>("");
@ -62,6 +56,8 @@ const ImageEditor = ({
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(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<HTMLImageElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const popoverContentRef = useRef<HTMLDivElement>(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<HTMLInputElement>
) => {
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 (
<Sheet open={true} onOpenChange={() => onClose?.()}>
<SheetContent
side="right"
className="w-[600px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<SheetHeader>
<SheetTitle>Update Image</SheetTitle>
</SheetHeader>
<div className="image-editor-container">
<div className="mt-6">
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-3 mx-auto ">
<TabsTrigger className="font-medium" value="edit">
Edit
</TabsTrigger>
<TabsTrigger className="font-medium" value="generate">
AI Generate
</TabsTrigger>
<TabsTrigger className="font-medium" value="upload">
Upload
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-4 space-y-4">
<div className="space-y-4">
{/* Current Image Preview */}
<div className="space-y-2">
<h3 className="text-base font-medium">Current Image</h3>
<div
ref={imageContainerRef}
className="relative aspect-[4/3] w-full overflow-hidden rounded-lg border bg-gray-100"
>
{image ? (
<img
ref={imageRef}
src={image}
alt="Current image"
className="w-full h-full object-cover cursor-pointer"
style={{
objectFit: objectFit,
objectPosition: `${focusPoint.x}% ${focusPoint.y}%`,
}}
onClick={(e) => {
e.stopPropagation();
handleFocusPointClick(e);
}}
onError={(e) => {
console.error('Image failed to load:', image);
e.currentTarget.src = '/placeholder-image.png';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<Upload className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm">No image selected</p>
</div>
</div>
)}
<Sheet open={true} onOpenChange={() => onClose?.()}>
<SheetContent
side="right"
className="w-[600px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<SheetHeader>
<SheetTitle>Update Image</SheetTitle>
</SheetHeader>
{/* Focus Point Indicator */}
{isFocusPointMode && image && (
<div
className="absolute w-4 h-4 bg-blue-500 border-2 border-white rounded-full transform -translate-x-1/2 -translate-y-1/2 pointer-events-none shadow-lg"
style={{
left: `${focusPoint.x}%`,
top: `${focusPoint.y}%`,
}}
/>
)}
</div>
{/* Debug info */}
{image && (
<div className="text-xs text-gray-500 space-y-1">
<p><strong>Image Path:</strong> {image}</p>
<p><strong>Resolved URL:</strong> {image}</p>
<p><strong>Focus Point:</strong> {focusPoint.x.toFixed(1)}%, {focusPoint.y.toFixed(1)}%</p>
<p><strong>Object Fit:</strong> {objectFit}</p>
</div>
)}
</div>
{/* Editing Controls */}
<div className="mt-6">
<Tabs defaultValue="generate" className="w-full">
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-2 mx-auto">
<TabsTrigger className="font-medium" value="generate">
AI Generate
</TabsTrigger>
<TabsTrigger className="font-medium" value="upload">
Upload
</TabsTrigger>
</TabsList>
{/* Generate Tab */}
<TabsContent value="generate" className="mt-4 space-y-4">
<div className="space-y-4">
{/* Focus Point Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Focus Point</h4>
<Button
variant={isFocusPointMode ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
toggleFocusPointMode();
}}
disabled={!image}
>
<Move className="w-4 h-4 mr-2" />
{isFocusPointMode ? "Done" : "Adjust"}
</Button>
</div>
{isFocusPointMode && (
<p className="text-xs text-gray-500">
Click on the image above to set the focus point
</p>
)}
<div>
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
<p className="text-sm text-gray-500">{promptContent}</p>
</div>
{/* Object Fit Controls */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Image Fit</h4>
<div className="flex gap-2">
<Button
variant={objectFit === "cover" ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFitChange("cover");
}}
className="flex-1"
disabled={!image}
>
Cover
</Button>
<Button
variant={objectFit === "contain" ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFitChange("contain");
}}
className="flex-1"
disabled={!image}
>
Contain
</Button>
<Button
variant={objectFit === "fill" ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFitChange("fill");
}}
className="flex-1"
disabled={!image}
>
Fill
</Button>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p><strong>Cover:</strong> Fill container, may crop image</p>
<p><strong>Contain:</strong> Fit entire image, may show empty space</p>
<p><strong>Fill:</strong> Stretch to fill container exactly</p>
</div>
<div>
<h3 className="text-base font-medium mb-2">Image Description</h3>
<Textarea
placeholder="Describe the image you want to generate..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px]"
/>
</div>
{/* Quick Actions */}
<div className="pt-2 border-t">
<h4 className="text-sm font-medium mb-2">Quick Actions</h4>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setFocusPoint({ x: 50, y: 50 });
setObjectFit("cover");
saveImageProperties("cover", { x: 50, y: 50 });
if (imageRef.current) {
imageRef.current.style.objectFit = "cover";
imageRef.current.style.objectPosition = "50% 50%";
}
}}
className="flex-1"
disabled={!image}
>
Reset to Default
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="generate" className="mt-4 space-y-4">
<div></div>
<div className="space-y-4">
<div className="">
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
<Button
onClick={handleGenerateImage}
className="w-full"
disabled={!prompt || isGenerating}
>
<Wand2 className="w-4 h-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Image"}
</Button>
<p className="text-sm text-gray-500">{promptContent}</p>
</div>
<div>
<h3 className="text-base font-medium mb-2">
Image Description
</h3>
<Textarea
placeholder="Describe the image you want to generate..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px]"
/>
</div>
<Button
onClick={handleGenerateImage}
className="w-full"
disabled={!prompt || isGenerating}
>
<Wand2 className="w-4 h-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Image"}
</Button>
{error && <p className="text-red-500 text-sm">{error}</p>}
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="grid grid-cols-2 gap-4">
{isGenerating || previewImages.length === 0
? Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-[4/3] w-full rounded-lg"
/>
))
: previewImages.map((image, index) => (
<div
key={index}
onClick={() => handleImageChange(image as string)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer"
>
<img
src={
image
? getStaticFileUrl(image)
: ""
}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
<div className="grid grid-cols-2 gap-4">
{isGenerating || previewImages.length === 0
? Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-[4/3] w-full rounded-lg"
/>
</div>
))}
))
: previewImages.map((image, index) => (
<div
key={index}
onClick={() => handleImageChange(image as string)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
>
{image && (
<img
src={image}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
)}
</div>
))}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="upload" className="mt-4 space-y-4">
<div className="space-y-4">
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
isUploading
? "border-gray-400 bg-gray-50"
: "border-gray-300"
)}
>
<input
type="file"
id="file-upload"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={isUploading}
/>
<label
htmlFor="file-upload"
</TabsContent>
{/* Upload Tab */}
<TabsContent value="upload" className="mt-4 space-y-4">
<div className="space-y-4">
<div
className={cn(
"flex flex-col items-center",
isUploading ? "cursor-wait" : "cursor-pointer"
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
isUploading
? "border-gray-400 bg-gray-50"
: "border-gray-300 hover:border-blue-400"
)}
>
{isUploading ? (
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
) : (
<Upload className="w-8 h-8 text-gray-500 mb-2" />
)}
<span className="text-sm text-gray-600">
{isUploading
? "Uploading your image..."
: "Click to upload an image"}
</span>
<span className="text-xs text-gray-500 mt-1">
Maximum file size: 5MB
</span>
</label>
</div>
{uploadError && (
<p className="text-red-500 text-sm text-center">
{uploadError}
</p>
)}
{(uploadedImageUrl || isUploading) && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">
Uploaded Image Preview
</h3>
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
<input
type="file"
id="file-upload"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className={cn(
"flex flex-col items-center",
isUploading ? "cursor-wait" : "cursor-pointer"
)}
>
{isUploading ? (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<div className="flex flex-col items-center">
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-sm text-gray-500">
Processing...
</span>
</div>
</div>
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
) : (
uploadedImageUrl && (
<div
onClick={() =>
handleImageChange(uploadedImageUrl)
}
className="cursor-pointer group w-full h-full"
>
<img
src={uploadedImageUrl}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
Click to use this image
<Upload className="w-8 h-8 text-gray-500 mb-2" />
)}
<span className="text-sm text-gray-600">
{isUploading
? "Uploading your image..."
: "Click to upload an image"}
</span>
<span className="text-xs text-gray-500 mt-1">
Maximum file size: 5MB
</span>
</label>
</div>
{uploadError && (
<p className="text-red-500 text-sm text-center">
{uploadError}
</p>
)}
{(uploadedImageUrl || isUploading) && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">
Uploaded Image Preview
</h3>
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
{isUploading ? (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<div className="flex flex-col items-center">
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-sm text-gray-500">
Processing...
</span>
</div>
</div>
)
)}
) : (
uploadedImageUrl && (
<div
onClick={() =>
handleImageChange(uploadedImageUrl)
}
className="cursor-pointer group w-full h-full"
>
<img
src={uploadedImageUrl}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
Click to use this image
</span>
</div>
</div>
)
)}
</div>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
)}
</div>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
</div>
);
};

View file

@ -2,6 +2,8 @@
import React, { createContext, useContext, useRef, useEffect, ReactNode, useState } from 'react';
import ReactDOM from 'react-dom';
import { useDispatch } from 'react-redux';
import { updateSlideImage, updateSlideIcon } from '../../../store/slices/presentationGeneration';
import ImageEditor from './ImageEditor';
import IconsEditor from './IconsEditor';
@ -53,6 +55,8 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
rect: DOMRect;
} | null>(null);
const dispatch = useDispatch();
useEffect(() => {
if (!isEditMode || !containerRef.current || !slideData) return;
@ -63,7 +67,7 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
console.log('🔍 Starting smart detection with slideData:', slideData);
// Detect Images and Icons only (text is now handled by SmartText components)
// Detect Images and Icons only (text is now handled by TiptapTextReplacer)
const detectEditableElementsFromData = (data: any, path: string = '') => {
if (!data || typeof data !== 'object') return;
@ -80,7 +84,16 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
slideIndex,
initialImage: data.__image_url__,
promptContent: data.__image_prompt__ || '',
imageIdx: elements.filter(e => e.type === 'image').length
imageIdx: elements.filter(e => e.type === 'image').length,
onImageChange: (newImageUrl: string, prompt?: string) => {
console.log(`🖼️ Image changed at ${path}:`, newImageUrl);
dispatch(updateSlideImage({
slideIndex,
dataPath: path,
imageUrl: newImageUrl,
prompt: prompt
}));
}
}
});
console.log(`✅ Matched image to DOM element:`, imgElement);
@ -103,12 +116,22 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
index: elements.filter(e => e.type === 'icon').length,
backgroundColor: '#3B82F6',
hasBg: false,
icon_prompt: data.__icon_query__ ? [data.__icon_query__] : []
icon_prompt: data.__icon_query__ ? [data.__icon_query__] : [],
onIconChange: (newIconUrl: string, query?: string) => {
console.log(`🎯 Icon changed at ${path}:`, newIconUrl);
dispatch(updateSlideIcon({
slideIndex,
dataPath: path,
iconUrl: newIconUrl,
query: query
}));
}
}
});
console.log(`✅ Matched icon to DOM element:`, imgElement);
}
}
// Recursively scan nested objects and arrays
Object.keys(data).forEach(key => {
const value = data[key];
@ -168,7 +191,7 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
return () => {
clearTimeout(timer);
};
}, [slideIndex, slideId, slideData, isEditMode]); // Removed editableElements from dependency array
}, [slideIndex, slideId, slideData, isEditMode, dispatch]);
// Set up event listeners when editableElements change
useEffect(() => {

View file

@ -40,9 +40,9 @@ const TiptapText: React.FC<TiptapTextProps> = ({
},
},
onBlur: ({ editor }) => {
const text = editor.getText();
const markdown = editor?.storage.markdown.getMarkdown();
if (onContentChange) {
onContentChange(text);
onContentChange(markdown);
}
},
editable: !disabled,
@ -61,10 +61,10 @@ const TiptapText: React.FC<TiptapTextProps> = ({
}
return (
<div className="relative w-full">
<div className="relative z-50 w-full">
{!disabled && (
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
<button
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("bold") ? "bg-blue-100 text-blue-600" : ""

View file

@ -1,5 +1,4 @@
"use client";
import { renderToStaticMarkup } from 'react-dom/server';
import React, { useRef, useEffect, useState, ReactNode } from 'react';
import ReactDOM from 'react-dom/client';
@ -11,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;
}
@ -19,14 +19,12 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
children,
slideData,
layout,
slideIndex,
onContentChange = () => { },
isEditMode = true
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedElements, setProcessedElements] = useState(new Set<HTMLElement>());
useEffect(() => {
if (!isEditMode || !containerRef.current) return;
@ -46,6 +44,9 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
return;
}
// Skip if element is inside an ignored element tree
if (isInIgnoredElementTree(htmlElement)) return;
// Get direct text content (not from child elements)
const directTextContent = getDirectTextContent(htmlElement);
const trimmedText = directTextContent.trim();
@ -59,7 +60,6 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
// 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);
@ -110,7 +110,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
content={trimmedText}
onContentChange={(content: string) => {
if (dataPath && onContentChange) {
onContentChange(content, dataPath);
onContentChange(content, dataPath, slideIndex);
}
}}
placeholder="Enter text..."
@ -120,6 +120,56 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
});
};
// Function to check if element is inside an ignored element tree
const isInIgnoredElementTree = (element: HTMLElement): boolean => {
// List of element types that should be ignored entirely with all their children
const ignoredElementTypes = [
'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'TD', 'TH', // Table elements
'SVG', 'G', 'PATH', 'CIRCLE', 'RECT', 'LINE', // SVG elements
'CANVAS', // Canvas element
'VIDEO', 'AUDIO', // Media elements
'IFRAME', 'EMBED', 'OBJECT', // Embedded content
'SELECT', 'OPTION', 'OPTGROUP', // Select dropdown elements
'SCRIPT', 'STYLE', 'NOSCRIPT', // Script/style elements
];
// List of class patterns that indicate ignored element trees
const ignoredClassPatterns = [
'chart', 'graph', 'visualization', // Chart/graph components
'menu', 'dropdown', 'tooltip', // UI components
'editor', 'wysiwyg', // Editor components
'calendar', 'datepicker', // Date picker components
'slider', 'carousel', // Interactive components
];
// Check if current element or any parent is in ignored list
let currentElement: HTMLElement | null = element;
while (currentElement) {
// Check element type
if (ignoredElementTypes.includes(currentElement.tagName)) {
return true;
}
// Check class patterns
const className = currentElement.className.length > 0 ? currentElement.className.toLowerCase() : '';
if (ignoredClassPatterns.some(pattern => className.includes(pattern))) {
return true;
}
// Check for specific attributes that indicate non-text content
if (currentElement.hasAttribute('contenteditable') ||
currentElement.hasAttribute('data-chart') ||
currentElement.hasAttribute('data-visualization') ||
currentElement.hasAttribute('data-interactive')) {
return true;
}
currentElement = currentElement.parentElement;
}
return false;
};
// Helper function to get only direct text content (not from children)
const getDirectTextContent = (element: HTMLElement): string => {
let text = '';
@ -155,7 +205,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
return true;
}
// Skip elements that contain interactive content
// Skip elements that contain interactive content (simplified since we now use isInIgnoredElementTree)
if (element.querySelector('img, svg, button, input, textarea, select, a[href]')) {
return true;
}
@ -163,7 +213,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
// Skip container elements (elements that primarily serve as layout containers)
const containerClasses = ['grid', 'flex', 'space-', 'gap-', 'container', 'wrapper'];
const hasContainerClass = containerClasses.some(cls =>
element.className.includes(cls)
element.className.length > 0 ? element.className.includes(cls) : false
);
if (hasContainerClass) return true;
@ -208,7 +258,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
return () => {
clearTimeout(timer);
};
}, [slideData, isEditMode]);
}, [slideData, isEditMode, slideIndex]);
return (
<div ref={containerRef} className="tiptap-text-replacer">

View file

@ -1,500 +0,0 @@
import { Slide } from "../types/slide";
import Type1Layout from "./slide_layouts/Type1Layout";
import Type2Layout from "./slide_layouts/Type2Layout";
import Type4Layout from "./slide_layouts/Type4Layout";
import Type5Layout from "./slide_layouts/Type5Layout";
import Type6Layout from "./slide_layouts/Type6Layout";
import Type7Layout from "./slide_layouts/Type7Layout";
import Type8Layout from "./slide_layouts/Type8Layout";
import Type9Layout from "./slide_layouts/Type9Layout";
import { Chart, ChartSettings } from "@/store/slices/presentationGeneration";
import { Pie, PieChart, Cell, CartesianGrid, Label } from "recharts";
import {
LineChart,
Bar,
Legend,
BarChart,
Tooltip,
YAxis,
Line,
XAxis,
} from "recharts";
import { ResponsiveContainer } from "recharts";
import { ThemeColors } from "../store/themeSlice";
import { isDarkColor } from "../utils/others";
import {
formatTooltipValue,
formatYAxisTick,
transformedData,
} from "../utils/chart";
export const renderSlideContent = (slide: Slide, language: string) => {
switch (slide.type) {
case 1:
return (
<Type1Layout
slideIndex={slide.index}
title={slide.content.title}
slideId={slide.id}
description={
typeof slide.content.body === "string"
? slide.content.body
: slide.content.body[0]?.description || ""
}
images={slide.images || []}
image_prompts={slide.content.image_prompts || []}
properties={slide.properties}
/>
);
case 2:
return (
<Type2Layout
title={slide.content.title}
slideId={slide.id}
slideIndex={slide.index}
body={Array.isArray(slide.content.body) ? slide.content.body : []}
language={language || "English"}
design_index={slide.design_index || 2}
/>
);
case 4:
return (
<Type4Layout
title={slide.content.title}
slideId={slide.id}
slideIndex={slide.index}
images={slide.images || []}
body={Array.isArray(slide.content.body) ? slide.content.body : []}
image_prompts={slide.content.image_prompts || []}
properties={slide.properties}
/>
);
case 5:
const isFullSizeGraph =
slide.content.graph?.data.categories.length > 4 &&
slide.content.graph.type !== "pie";
return (
<Type5Layout
title={slide.content.title}
slideId={slide.id}
slideIndex={slide.index}
description={(slide.content.body as string) || ""}
isFullSizeGraph={isFullSizeGraph}
graphData={slide.content.graph}
/>
);
case 6:
return (
<Type6Layout
title={slide.content.title}
slideId={slide.id}
slideIndex={slide.index}
description={slide.content.description || ""}
body={Array.isArray(slide.content.body) ? slide.content.body : []}
language={language || "English"}
/>
);
case 7:
return (
<Type7Layout
title={slide.content.title}
slideId={slide.id}
slideIndex={slide.index}
body={Array.isArray(slide.content.body) ? slide.content.body : []}
icons={slide.icons || []}
icon_queries={slide.content.icon_queries || []}
/>
);
case 8:
return (
<Type8Layout
title={slide.content.title}
slideId={slide.id}
body={Array.isArray(slide.content.body) ? slide.content.body : []}
slideIndex={slide.index}
description={slide.content.description || ""}
icons={slide.icons || []}
icon_queries={slide.content.icon_queries || []}
/>
);
case 9:
return (
<Type9Layout
slideIndex={slide.index}
slideId={slide.id}
title={slide.content.title}
// @ts-ignore
body={slide.content.body}
language={language || "English"}
graphData={slide.content.graph}
/>
);
default:
return null;
}
};
// CHART RENDERING
export const renderChart = (
localChartData: Chart,
isMini: boolean = false,
theme: ThemeColors,
chartSettings?: ChartSettings
) => {
const chartColors = theme.chartColors || [];
const renderCustomizedLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
index,
}: any) => {
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
const isDark = isDarkColor(theme.chartColors[index % chartColors.length]);
return (
<text
x={x}
y={y}
fill={isDark ? "#ffffff" : "#000000"}
style={{ cursor: "pointer" }}
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
// New function for outside labels
const renderOutsideLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
index,
name,
}: any) => {
const RADIAN = Math.PI / 180;
// Position the label further outside the pie
const radius = outerRadius * 1.2;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill={theme.slideTitle}
style={{ cursor: "pointer" }}
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
if (!localChartData) return null;
switch (localChartData.type) {
case "line":
return (
<ResponsiveContainer
id="line-chart-container"
width="100%"
height={isMini ? 100 : 300}
>
<LineChart
className="w-full"
data={transformedData(localChartData)}
style={{ cursor: "pointer" }}
margin={{ bottom: !isMini ? 30 : 0, right: 30, left: 10, top: 20 }}
>
{chartSettings?.showGrid && (
<CartesianGrid
vertical={false}
stroke={theme.slideDescription}
opacity={0.2}
/>
)}
{!isMini && chartSettings?.showAxisLabel && (
<XAxis
dataKey="name"
tickSize={10}
angle={-10}
height={!isMini ? 30 : 0}
interval={0}
dy={!isMini ? 10 : 0}
dx={!isMini ? -15 : 0}
tick={{
fill: theme.slideTitle,
fontSize: 14,
alignmentBaseline: "middle",
}}
/>
)}
{!isMini && chartSettings?.showAxisLabel && (
<YAxis
tick={{ fill: theme.slideTitle }}
tickFormatter={formatYAxisTick}
padding={{ top: 15 }}
>
<Label
value={localChartData.unit || ""}
position="top"
style={{
textTransform: "capitalize",
textAnchor: "start",
fontSize: "16px",
fill: theme.slideTitle,
fontWeight: "bold",
}}
/>
</YAxis>
)}
<Tooltip
cursor={{ fill: "transparent" }}
contentStyle={{
backgroundColor: theme.slideBox,
color: theme.slideTitle,
border: "none",
}}
itemStyle={{
color: theme.slideTitle,
}}
/>
{!isMini && chartSettings?.showLegend && (
<Legend verticalAlign="top" align="center" />
)}
{localChartData.data.series.map((serie, index) => (
<Line
isAnimationActive={false}
key={serie.name || `Series ${index + 1}`}
type="monotone"
strokeWidth={2}
dataKey={serie.name || `Series ${index + 1}`}
stroke={chartColors[index % chartColors.length]}
style={{ cursor: "pointer" }}
// label={(chartSettings?.showDataLabel && localChartData.data.series.length === 1) ? {
// position: chartSettings?.dataLabel.dataLabelPosition === "Outside" ? "top" : "center",
// formatter: (value: number) => formatYAxisTick(value),
// fill: chartSettings?.dataLabel.dataLabelPosition === "Outside" ? theme.slideTitle : '#ffffff',
// fontWeight: 'bold',
// fontSize: '12px',
// fontFamily: theme.fontFamily
// } : undefined}
/>
))}
</LineChart>
</ResponsiveContainer>
);
case "pie":
return (
<ResponsiveContainer
id="pie-chart-container"
width="100%"
height={isMini ? 100 : 300}
>
<PieChart>
<Pie
isAnimationActive={false}
data={transformedData(localChartData)}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
style={{ cursor: "pointer" }}
label={
chartSettings?.showDataLabel
? chartSettings?.dataLabel.dataLabelPosition === "Inside"
? renderCustomizedLabel
: renderOutsideLabel
: false
}
fill={theme.slideTitle}
paddingAngle={2}
labelLine={false}
outerRadius={
chartSettings?.dataLabel.dataLabelPosition === "Outside"
? "80%"
: "90%"
}
>
{transformedData(localChartData).map((entry: any, index: any) => (
<Cell
key={`cell-${index}`}
fill={chartColors[index % chartColors.length]}
focusable={false}
stroke="none"
style={{
border: "none",
outline: "none",
cursor: "pointer",
}}
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
formatTooltipValue(localChartData, value as number)
}
contentStyle={{
backgroundColor: theme.slideBox,
color: theme.slideTitle,
border: "none",
borderRadius: "6px",
}}
itemStyle={{
color: theme.slideTitle,
}}
/>
{!isMini && chartSettings?.showLegend && (
<Legend verticalAlign="top" align="center" />
)}
</PieChart>
</ResponsiveContainer>
);
case "bar":
default:
return (
<ResponsiveContainer
id="bar-chart-container"
width="100%"
height={isMini ? 100 : 330}
>
<BarChart
data={transformedData(localChartData)}
margin={{ bottom: !isMini ? 30 : 0, top: 20 }}
>
{chartSettings?.showGrid && (
<CartesianGrid
vertical={false}
stroke={theme.slideDescription}
opacity={0.2}
/>
)}
{!isMini && chartSettings?.showAxisLabel && (
<XAxis
stroke={theme.slideTitle}
className=""
dataKey="name"
tickSize={10}
angle={-10}
height={!isMini ? 40 : 0}
interval={0}
dy={!isMini ? 20 : 0}
dx={!isMini ? -10 : 0}
tick={{
fill: theme.slideTitle,
fontSize: 14,
alignmentBaseline: "middle",
}}
/>
)}
{!isMini && chartSettings?.showAxisLabel && (
<YAxis
stroke={theme.slideTitle}
tick={{ fill: theme.slideTitle }}
tickFormatter={formatYAxisTick}
padding={{ top: 20 }}
>
<Label
value={localChartData.unit || ""}
position="top"
style={{
textTransform: "capitalize",
textAnchor: "start",
fontSize: "16px",
fill: theme.slideTitle,
fontWeight: "bold",
width: "fit",
margin: "0 auto",
}}
/>
</YAxis>
)}
<Tooltip
cursor={{ fill: "transparent" }}
contentStyle={{
backgroundColor: theme.slideBox,
color: theme.slideTitle,
border: "none",
}}
itemStyle={{
color: theme.slideTitle,
}}
/>
{!isMini && chartSettings?.showLegend && (
<Legend verticalAlign="top" align="center" />
)}
{localChartData &&
localChartData.data &&
localChartData.data.series &&
localChartData.data.series.map((serie, index) => (
<Bar
isAnimationActive={false}
key={serie.name || `Series ${index + 1}`}
dataKey={serie.name || `Series ${index + 1}`}
fill={chartColors[index % chartColors.length]}
barSize={50}
style={{ cursor: "pointer" }}
radius={[5, 8, 0, 0]}
label={
chartSettings?.showDataLabel
? {
position:
chartSettings?.dataLabel.dataLabelPosition ===
"Outside"
? "top"
: chartSettings?.dataLabel.dataLabelAlignment ===
"Base"
? "insideBottom"
: chartSettings?.dataLabel.dataLabelAlignment ===
"Center"
? "center"
: "insideTop",
formatter: (value: number) => formatYAxisTick(value),
fill:
chartSettings?.dataLabel.dataLabelPosition ===
"Outside"
? theme.slideTitle
: "#ffffff",
fontWeight: "bold",
fontSize: "14px",
fontFamily: theme.fontFamily,
}
: undefined
}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
};

View file

@ -70,84 +70,92 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
const fileMap = new Map<string, { fileName: string; groupName: string }>();
const groupedLayouts = new Map<string, LayoutInfo[]>();
// Start preloading process
setIsPreloading(true);
for (const groupData of groupedLayoutsData) {
try {
for (const groupData of groupedLayoutsData) {
// Initialize group
if (!layoutsByGroup.has(groupData.groupName)) {
layoutsByGroup.set(groupData.groupName, new Set());
}
// group settings or default settings
const settings = groupData.settings || {
description: `${groupData.groupName} presentation layouts`,
ordered: false,
isDefault: false
};
groupSettingsMap.set(groupData.groupName, settings);
const groupLayouts: LayoutInfo[] = [];
for (const fileName of groupData.files) {
try {
const file = fileName.replace('.tsx', '').replace('.ts', '');
const module = await import(`@/presentation-layouts/${groupData.groupName}/${file}`);
if (!module.default) {
toast({
title: `${file} has no default export`,
description: 'Please ensure the layout file exports a default component',
});
console.warn(`${file} has no default export`);
continue;
}
if (!module.Schema) {
toast({
title: `${file} has no Schema export`,
description: 'Please ensure the layout file exports a Schema',
});
console.warn(`${file} has no Schema export`);
continue;
}
const originalLayoutId = module.layoutId || file.toLowerCase().replace(/layout$/, '');
const uniqueKey = `${groupData.groupName}:${originalLayoutId}`;
const layoutName = module.layoutName || file.replace(/([A-Z])/g, ' $1').trim();
const layoutDescription = module.layoutDescription || `${layoutName} layout for presentations`;
const jsonSchema = z.toJSONSchema(module.Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
const layout: LayoutInfo = {
id: originalLayoutId,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
groupName: groupData.groupName,
};
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupData.groupName)!.add(originalLayoutId);
fileMap.set(uniqueKey, { fileName, groupName: groupData.groupName });
groupLayouts.push(layout);
layouts.push(layout);
} catch (error) {
console.error(`💥 Error extracting schema for ${fileName} from ${groupData.groupName}:`, error);
// Initialize group
if (!layoutsByGroup.has(groupData.groupName)) {
layoutsByGroup.set(groupData.groupName, new Set());
}
}
// Cache grouped layouts
groupedLayouts.set(groupData.groupName, groupLayouts);
// group settings or default settings
const settings = groupData.settings || {
description: `${groupData.groupName} presentation layouts`,
ordered: false,
isDefault: false
};
groupSettingsMap.set(groupData.groupName, settings);
const groupLayouts: LayoutInfo[] = [];
for (const fileName of groupData.files) {
try {
const file = fileName.replace('.tsx', '').replace('.ts', '');
const module = await import(`@/presentation-layouts/${groupData.groupName}/${file}`);
if (!module.default) {
toast({
title: `${file} has no default export`,
description: 'Please ensure the layout file exports a default component',
});
console.warn(`${file} has no default export`);
continue;
}
if (!module.Schema) {
toast({
title: `${file} has no Schema export`,
description: 'Please ensure the layout file exports a Schema',
});
console.warn(`${file} has no Schema export`);
continue;
}
// Cache the layout component immediately after import
const cacheKey = createCacheKey(groupData.groupName, fileName);
if (!layoutCache.has(cacheKey)) {
layoutCache.set(cacheKey, module.default);
}
const originalLayoutId = module.layoutId || file.toLowerCase().replace(/layout$/, '');
const uniqueKey = `${groupData.groupName}:${originalLayoutId}`;
const layoutName = module.layoutName || file.replace(/([A-Z])/g, ' $1').trim();
const layoutDescription = module.layoutDescription || `${layoutName} layout for presentations`;
const jsonSchema = z.toJSONSchema(module.Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
const layout: LayoutInfo = {
id: uniqueKey,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
groupName: groupData.groupName,
};
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupData.groupName)!.add(uniqueKey);
fileMap.set(uniqueKey, { fileName, groupName: groupData.groupName });
groupLayouts.push(layout);
layouts.push(layout);
} catch (error) {
console.error(`💥 Error extracting schema for ${fileName} from ${groupData.groupName}:`, error);
}
}
// Cache grouped layouts
groupedLayouts.set(groupData.groupName, groupLayouts);
}
} finally {
setIsPreloading(false);
}
return {
@ -185,8 +193,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
const data = await buildData(groupedLayoutsData);
setLayoutData(data);
// Preload layouts after loading schema
await preloadLayouts(data.fileMap);
// The preloading is now handled within buildData
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load layouts';
setError(errorMessage);
@ -196,33 +203,6 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
}
};
const preloadLayouts = async (fileMap: Map<string, { fileName: string; groupName: string }>) => {
setIsPreloading(true);
try {
const layoutPromises = Array.from(fileMap.entries()).map(async ([layoutId, { fileName, groupName }]) => {
const cacheKey = createCacheKey(groupName, fileName);
if (!layoutCache.has(cacheKey)) {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '');
const Layout = dynamic(
() => import(`@/presentation-layouts/${groupName}/${layoutName}`),
{
loading: () => <div className="w-full aspect-[16/9] bg-gray-100 animate-pulse rounded-lg" />,
ssr: false,
}
) as React.ComponentType<{ data: any }>;
layoutCache.set(cacheKey, Layout);
}
});
await Promise.all(layoutPromises);
} catch (error) {
console.error('Error preloading layouts:', error);
} finally {
setIsPreloading(false);
}
};
const getLayout = (layoutId: string): React.ComponentType<{ data: any }> | null => {
if (!layoutData) return null;
@ -230,9 +210,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
// Search through all fileMap entries to find the layout
for (const [key, info] of Array.from(layoutData.fileMap.entries())) {
// Extract original layout ID from unique key (format: "groupName:layoutId")
const originalId = key.split(':')[1];
if (originalId === layoutId) {
if (key === layoutId) {
fileInfo = info;
break;
}
@ -269,8 +247,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
// Search through all entries to find the layout (since we don't know the group)
for (const [key, layout] of Array.from(layoutData.layoutsById.entries())) {
const originalId = key.split(':')[1];
if (originalId === layoutId) {
if (key === layoutId) {
return layout;
}
}
@ -279,8 +256,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
const getLayoutByIdAndGroup = (layoutId: string, groupName: string): LayoutInfo | null => {
if (!layoutData) return null;
const uniqueKey = `${groupName}:${layoutId}`;
return layoutData.layoutsById.get(uniqueKey) || null;
return layoutData.layoutsById.get(layoutId) || null;
};
const getLayoutsByGroup = (groupName: string): LayoutInfo[] => {

View file

@ -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 EditableLayoutWrapper from '../components/EditableLayoutWrapper';
import TiptapTextReplacer from '../components/TiptapTextReplacer';
import { updateSlideContent } from '../../../store/slices/presentationGeneration';
export const useGroupLayouts = () => {
const dispatch = useDispatch();
const {
getLayoutByIdAndGroup,
getLayoutsByGroup,
@ -30,7 +33,7 @@ export const useGroupLayouts = () => {
};
}, [getLayoutsByGroup]);
// Render slide content with group validation and automatic Tiptap text editing
// Render slide content with group validation, automatic Tiptap text editing, and editable images/icons
const renderSlideContent = useMemo(() => {
return (slide: any, isEditMode: boolean = true) => {
const Layout = getGroupLayout(slide.layout, slide.layout_group);
@ -46,29 +49,37 @@ export const useGroupLayouts = () => {
if (isEditMode) {
return (
<SmartEditableProvider
<EditableLayoutWrapper
slideIndex={slide.index}
slideId={slide.id || `slide-${slide.index}`}
slideData={slide.content}
isEditMode={isEditMode}
>
<TiptapTextReplacer
slideData={slide.content}
slideIndex={slide.index}
isEditMode={isEditMode}
layout={Layout}
onContentChange={(content: string, dataPath: string) => {
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
}));
}
}}
>
<Layout data={slide.content} />
</TiptapTextReplacer>
</SmartEditableProvider>
</EditableLayoutWrapper>
);
}
return <Layout data={slide.content} />;
};
}, [getGroupLayout]);
}, [getGroupLayout, dispatch]);
return {
getGroupLayout,

View file

@ -37,7 +37,6 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
const Groups: LayoutGroup[] = groups.map(groupName => {
const layouts = getLayoutsByGroup(groupName);
const settings = getGroupSetting(groupName);
return {
id: groupName,
name: groupName,

View file

@ -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({

View file

@ -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<PresentationPageProps> = ({ 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<PresentationPageProps> = ({ 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<PresentationPageProps> = ({ presentation_id })
role="alert"
>
<AlertCircle className="w-16 h-16 mb-4 text-red-500" />
<strong className="font-bold text-4xl mb-2">Oops!</strong>
<p className="block text-2xl py-2">
We encountered an issue loading your presentation.
<h2 className="text-xl font-semibold mb-2">
Something went wrong
</h2>
<p className="text-center mb-4">
We couldn't load your presentation. Please try again.
</p>
<p className="text-lg py-2">
Please check your internet connection or try again later.
</p>
<Button
className="mt-4 bg-red-500 text-white hover:bg-red-600 focus:ring-4 focus:ring-red-300"
onClick={() => window.location.reload()}
>
Retry
<Button onClick={() => window.location.reload()}>
Refresh Page
</Button>
</div>
</div>
);
}
return (
<div className="h-screen flex overflow-hidden flex-col">
{/* Auto save loading indicator */}
{autoSaveLoading && (
<div className="fixed right-6 top-[5.2rem] z-50 bg-white bg-opacity-50 flex items-center justify-center">
<Loader2 className="animate-spin text-primary" />
</div>
)}
<div className="fixed right-6 top-[5.2rem] z-50">
{isSaving && (
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
)}
</div>
<Header presentation_id={presentation_id} currentSlide={currentSlide} />
<Help />

View file

@ -139,7 +139,7 @@ const SlideContent = ({
{isStreaming && (
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
)}
<div className={` w-full group mb-6`}>
<div data-layout={slide.layout} data-group={slide.layout_group} className={` w-full group mb-6`}>
{/* render slides */}
{loading ? <div className="flex flex-col bg-white aspect-video items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin" />

View file

@ -1,3 +1,4 @@
export { usePresentationStreaming } from './usePresentationStreaming';
export { usePresentationData } from './usePresentationData';
export { usePresentationNavigation } from './usePresentationNavigation';
export { usePresentationNavigation } from './usePresentationNavigation';
export { useAutoSave } from './useAutoSave';

View file

@ -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<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>('');
const [isSaving, setIsSaving] = useState<boolean>(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,
};
};

View file

@ -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);

View file

@ -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";

View file

@ -1,21 +0,0 @@
import { useCallback, useRef } from "react";
export function useDebounce<T extends (...args: any[]) => void>(
callback: T,
delay: number
) {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay]
);
}

View file

@ -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,

View file

@ -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<Element>[]) {
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<ElementHandle<Element>> {
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<ElementHandle<Element>> {
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<Element>): Promise<SlideAttributesResult> {
// 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<Element>): Promise<ElementAttributes> {
const attributes = await element.evaluate((el) => {
@ -80,15 +203,28 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
return ctx.fillStyle;
}
// Helper function to check if element has only text nodes as direct children
function hasOnlyTextNodes(el: Element): boolean {
const children = el.childNodes;
for (let i = 0; i < children.length; i++) {
const child = children[i];
// If any child is an element node (not a text node), return false
if (child.nodeType === Node.ELEMENT_NODE) {
return false;
}
}
return true;
}
const computedStyles = window.getComputedStyle(el);
// Parse position and dimensions
const rect = el.getBoundingClientRect();
const position = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
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,
};
// Parse background
@ -113,6 +249,8 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
offset: undefined as [number, number] | undefined,
color: undefined as string | undefined,
opacity: undefined as number | undefined,
radius: undefined as number | undefined,
angle: undefined as number | undefined,
};
if (boxShadow && boxShadow !== 'none') {
@ -120,10 +258,13 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
if (shadowParts.length >= 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<Element>): Promise<El
const fontSize = parseFloat(computedStyles.fontSize);
const fontWeight = parseInt(computedStyles.fontWeight);
const fontColor = colorToHex(computedStyles.color);
const fontFamily = computedStyles.fontFamily;
const fontStyle = computedStyles.fontStyle;
// Extract only the first font from font-family (e.g., "Hack, sans-serif" -> "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<Element>): Promise<El
const marginBottom = parseFloat(computedStyles.marginBottom);
const marginLeft = parseFloat(computedStyles.marginLeft);
const marginRight = parseFloat(computedStyles.marginRight);
const margin = {
const marginObj = {
top: isNaN(marginTop) ? undefined : marginTop,
bottom: isNaN(marginBottom) ? undefined : marginBottom,
left: isNaN(marginLeft) ? undefined : marginLeft,
right: isNaN(marginRight) ? undefined : marginRight,
};
// Set margin as undefined if all fields are 0
const margin = (marginObj.top === 0 && marginObj.bottom === 0 && marginObj.left === 0 && marginObj.right === 0)
? undefined
: marginObj;
// Parse padding
const paddingTop = parseFloat(computedStyles.paddingTop);
const paddingBottom = parseFloat(computedStyles.paddingBottom);
const paddingLeft = parseFloat(computedStyles.paddingLeft);
const paddingRight = parseFloat(computedStyles.paddingRight);
const padding = {
const paddingObj = {
top: isNaN(paddingTop) ? undefined : paddingTop,
bottom: isNaN(paddingBottom) ? undefined : paddingBottom,
left: isNaN(paddingLeft) ? undefined : paddingLeft,
right: isNaN(paddingRight) ? undefined : paddingRight,
};
// Set padding as undefined if all fields are 0
const padding = (paddingObj.top === 0 && paddingObj.bottom === 0 && paddingObj.left === 0 && paddingObj.right === 0)
? undefined
: paddingObj;
// Only include innerText if the element has only text nodes as direct children
const innerText = hasOnlyTextNodes(el) ? (el.textContent || undefined) : undefined;
// Parse z-index
const zIndex = parseInt(computedStyles.zIndex);
const zIndexValue = isNaN(zIndex) ? 0 : zIndex;
// Parse additional attributes
const textAlign = computedStyles.textAlign as 'left' | 'center' | 'right' | 'justify';
const borderRadius = computedStyles.borderRadius;
const objectFit = computedStyles.objectFit as 'contain' | 'cover' | 'fill' | undefined;
const imageSrc = (el as HTMLImageElement).src;
// Parse border radius
let borderRadiusValue: number | number[] | undefined;
if (borderRadius && borderRadius !== '0px') {
const radiusParts = borderRadius.split(' ').map(part => 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<Element>): Promise<El
position,
margin,
padding,
zIndex: zIndexValue,
textAlign: textAlign !== 'left' ? textAlign : undefined,
borderRadius: borderRadiusValue,
imageSrc: imageSrc || undefined,
objectFit,
clip: false, // Default value
overlay: undefined,
shape,
connectorType: undefined,
textWrap,
};
});
return attributes;
}
async function getAllChildElementsAttributes(element: ElementHandle<Element>): Promise<ElementAttributes[]> {
// 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);
}

View file

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

View file

@ -100,7 +100,7 @@ export const PresentationCard = ({
>
<div className="absolute bg-transparent z-40 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
{renderSlideContent(slide)}
{renderSlideContent(slide, false)}
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -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',

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,7 @@
"puppeteer": "^24.13.0",
"react": "^18",
"react-dom": "^18",
"react-element-to-jsx-string": "^15.0.0",
"react-redux": "^9.1.2",
"recharts": "^2.15.4",
"sonner": "^2.0.6",
@ -115,6 +116,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@base2/pretty-print-object": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz",
"integrity": "sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==",
"license": "BSD-2-Clause"
},
"node_modules/@cypress/request": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.8.tgz",
@ -4594,6 +4601,15 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -6055,6 +6071,27 @@
"react": "^18.3.1"
}
},
"node_modules/react-element-to-jsx-string": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz",
"integrity": "sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==",
"license": "MIT",
"dependencies": {
"@base2/pretty-print-object": "1.0.1",
"is-plain-object": "5.0.0",
"react-is": "18.1.0"
},
"peerDependencies": {
"react": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0",
"react-dom": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0"
}
},
"node_modules/react-element-to-jsx-string/node_modules/react-is": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz",
"integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",

View file

@ -51,6 +51,7 @@
"puppeteer": "^24.13.0",
"react": "^18",
"react-dom": "^18",
"react-element-to-jsx-string": "^15.0.0",
"react-redux": "^9.1.2",
"recharts": "^2.15.4",
"sonner": "^2.0.6",

View file

@ -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<boolean>) => {
state.isSlidesRendered = action.payload;
},
// Error
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
@ -97,14 +73,6 @@ const presentationGenerationSlice = createSlice({
state.error = null;
state.isLoading = false;
},
// Set documents
setDocs: (state, action: PayloadAction<string[]>) => {
state.documents = action.payload;
},
// Set images
setImgs: (state, action: PayloadAction<string[]>) => {
state.images = action.payload;
},
// Set outlines
setOutlines: (state, action: PayloadAction<SlideOutline[]>) => {
state.outlines = action.payload;
@ -166,252 +134,201 @@ 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",
},
};
state.presentationData.slides[
action.payload.index
].content.graph.style = {
...defaultSettings,
...action.payload.chartSettings,
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;
}
};
// Update the slide content
if (dataPath && slide.content) {
setNestedValue(slide.content, dataPath, content);
}
}
},
addSlideBodyItem: (
// Update slide image at specific data path
updateSlideImage: (
state,
action: PayloadAction<{
index: number;
item: { heading: string; description: string };
slideIndex: number;
dataPath: string;
imageUrl: string;
prompt?: 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 = {};
if (
state.presentationData &&
state.presentationData.slides &&
state.presentationData.slides[action.payload.slideIndex]
) {
const slide = state.presentationData.slides[action.payload.slideIndex];
const { dataPath, imageUrl, prompt } = action.payload;
// Helper function to set nested property value for images
const setNestedImageValue = (obj: any, path: string, url: string, promptText?: 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))) {
if (!current[key]) {
current[key] = {};
}
current = current[key];
} else {
const index = Number(key);
if (!current[index]) {
current[index] = {};
}
current = current[index];
}
}
// Set the image properties
const finalKey = keys[keys.length - 1];
if (isNaN(Number(finalKey))) {
current[finalKey] = {
__image_url__: url,
__image_prompt__: promptText || ''
};
} else {
current[Number(finalKey)] = {
__image_url__: url,
__image_prompt__: promptText || ''
};
}
};
// Update the slide image
if (dataPath && slide.content) {
setNestedImageValue(slide.content, dataPath, imageUrl, prompt);
}
// Also update the images array if it exists
if (slide.images && Array.isArray(slide.images)) {
const imageIndex = parseInt(dataPath.split('[')[1]?.split(']')[0]) || 0;
if (slide.images[imageIndex] !== undefined) {
slide.images[imageIndex] = imageUrl;
}
}
// 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: (
// Update slide icon at specific data path
updateSlideIcon: (
state,
action: PayloadAction<{
slideIndex: number;
itemIdx: number;
title: string;
dataPath: string;
iconUrl: string;
query?: 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
);
if (
state.presentationData &&
state.presentationData.slides &&
state.presentationData.slides[action.payload.slideIndex]
) {
const slide = state.presentationData.slides[action.payload.slideIndex];
const { dataPath, iconUrl, query } = action.payload;
// Helper function to set nested property value for icons
const setNestedIconValue = (obj: any, path: string, url: string, queryText?: 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))) {
if (!current[key]) {
current[key] = {};
}
current = current[key];
} else {
const index = Number(key);
if (!current[index]) {
current[index] = {};
}
current = current[index];
}
}
// Set the icon properties
const finalKey = keys[keys.length - 1];
if (isNaN(Number(finalKey))) {
current[finalKey] = {
__icon_url__: url,
__icon_query__: queryText || ''
};
} else {
current[Number(finalKey)] = {
__icon_url__: url,
__icon_query__: queryText || ''
};
}
};
// Update the slide icon
if (dataPath && slide.content) {
setNestedIconValue(slide.content, dataPath, iconUrl, query);
}
// Also update the icons array if it exists
if (slide.icons && Array.isArray(slide.icons)) {
const iconIndex = parseInt(dataPath.split('[')[1]?.split(']')[0]) || 0;
if (slide.icons[iconIndex] !== undefined) {
slide.icons[iconIndex] = iconUrl;
}
}
}
},
},
@ -421,39 +338,19 @@ 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,
deletePresentationSlide,
updateSlideContent,
updateSlideImage,
updateSlideIcon,
deletePresentationSlide,
addSlideBodyItem,
addSlideImage,
deleteSlideImage,
deleteSlideBodyItem,
updateSlideProperties,
// infographics
addInfographics,
deleteInfographics,
updateInfographicsTitle,
updateInfographicsDescription,
updateInfographicsChart,
} = presentationGenerationSlice.actions;
export default presentationGenerationSlice.reducer;

View file

@ -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;
}

View file

@ -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

View file

@ -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];
};

View file

@ -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'
};
}

View file

@ -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;
}
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 "gemini_flash":
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();
};

View file

@ -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(