diff --git a/Dockerfile b/Dockerfile index 4fbd4b8d..cc303b52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,24 +2,15 @@ FROM python:3.11-slim-bookworm # Install Node.js and npm RUN apt-get update && apt-get install -y \ - nodejs \ - npm \ + nginx \ curl \ - redis-server \ - default-libmysqlclient-dev \ - build-essential \ - pkg-config \ - libreoffice \ - fontconfig \ - imagemagick + redis-server + # Install Node.js 20 using NodeSource repository +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs - RUN sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml - -# Create fonts directory and set permissions -RUN mkdir -p /usr/share/fonts/truetype && \ - chmod 755 /usr/share/fonts/truetype # Create a working directory WORKDIR /app diff --git a/Dockerfile.dev b/Dockerfile.dev index 3065d48a..3c84ac4b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,23 +2,15 @@ FROM python:3.11-slim-bookworm # Install Node.js and npm RUN apt-get update && apt-get install -y \ - nodejs \ - npm \ nginx \ curl \ - redis-server \ - default-libmysqlclient-dev \ - build-essential \ - pkg-config \ - libreoffice \ - fontconfig \ - imagemagick + redis-server -RUN sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml -# Create fonts directory and set permissions -RUN mkdir -p /usr/share/fonts/truetype && \ - chmod 755 /usr/share/fonts/truetype + # Install Node.js 20 using NodeSource repository +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs + # Change working directory WORKDIR /app diff --git a/fonts/Fraunces/Fraunces-Regular.otf b/fonts/Fraunces/Fraunces-Regular.otf deleted file mode 100644 index 0a7a3d6a..00000000 Binary files a/fonts/Fraunces/Fraunces-Regular.otf and /dev/null differ diff --git a/fonts/Inria Serif/InriaSerif-Regular.ttf b/fonts/Inria Serif/InriaSerif-Regular.ttf deleted file mode 100644 index c61a18b1..00000000 Binary files a/fonts/Inria Serif/InriaSerif-Regular.ttf and /dev/null differ diff --git a/fonts/Montserrat/Montserrat-Regular.ttf b/fonts/Montserrat/Montserrat-Regular.ttf deleted file mode 100644 index 48ba65ed..00000000 Binary files a/fonts/Montserrat/Montserrat-Regular.ttf and /dev/null differ diff --git a/fonts/Satoshi/Satoshi-Regular.otf b/fonts/Satoshi/Satoshi-Regular.otf deleted file mode 100644 index ddaadc05..00000000 Binary files a/fonts/Satoshi/Satoshi-Regular.otf and /dev/null differ diff --git a/fonts/inter/Inter.otf b/fonts/inter/Inter.otf deleted file mode 100644 index 1fc75450..00000000 Binary files a/fonts/inter/Inter.otf and /dev/null differ diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py index 184a55eb..86f55379 100644 --- a/servers/fastapi/api/lifespan.py +++ b/servers/fastapi/api/lifespan.py @@ -2,11 +2,12 @@ from contextlib import asynccontextmanager import os from fastapi import FastAPI -from sqlmodel import SQLModel -from services import SQL_ENGINE +from services.database import create_db_and_tables from utils.get_env import get_app_data_directory_env -from utils.model_availability import check_llm_and_image_provider_api_or_model_availability +from utils.model_availability import ( + check_llm_and_image_provider_api_or_model_availability, +) @asynccontextmanager @@ -14,9 +15,9 @@ async def app_lifespan(_: FastAPI): """ 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 create_db_and_tables() await check_llm_and_image_provider_api_or_model_availability() yield diff --git a/servers/fastapi/api/v1/ppt/endpoints/images.py b/servers/fastapi/api/v1/ppt/endpoints/images.py index 79d1a38d..55079409 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/images.py +++ b/servers/fastapi/api/v1/ppt/endpoints/images.py @@ -1,10 +1,11 @@ from typing import List -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from models.image_prompt import ImagePrompt from models.sql.image_asset import ImageAsset -from services.database import get_sql_session +from services.database import get_async_session from services.image_generation_service import ImageGenerationService from utils.asset_directory_utils import get_images_directory @@ -12,7 +13,9 @@ IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"]) @IMAGES_ROUTER.get("/generate") -async def generate_image(prompt: str): +async def generate_image( + prompt: str, sql_session: AsyncSession = Depends(get_async_session) +): images_directory = get_images_directory() image_prompt = ImagePrompt(prompt=prompt) image_generation_service = ImageGenerationService(images_directory) @@ -21,21 +24,18 @@ async def generate_image(prompt: str): if not isinstance(image, ImageAsset): return image - with get_sql_session() as sql_session: - sql_session.add(image) - sql_session.commit() - image_path = image.path + sql_session.add(image) + await sql_session.commit() - return image_path + return image.path @IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset]) -async def get_generated_images(): +async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)): try: - with get_sql_session() as sql_session: - images = sql_session.exec( - select(ImageAsset).order_by(ImageAsset.created_at.desc()) - ).all() + images = await sql_session.scalars( + select(ImageAsset).order_by(ImageAsset.created_at.desc()) + ) return images except Exception as e: return {"error": f"Failed to retrieve generated images: {str(e)}"} diff --git a/servers/fastapi/api/v1/ppt/endpoints/outlines.py b/servers/fastapi/api/v1/ppt/endpoints/outlines.py index 4228975c..fa1e45bd 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/outlines.py +++ b/servers/fastapi/api/v1/ppt/endpoints/outlines.py @@ -1,21 +1,23 @@ import asyncio import json -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession from models.presentation_outline_model import PresentationOutlineModel from models.sql.presentation import PresentationModel from models.sse_response import SSECompleteResponse, SSEResponse, SSEStatusResponse -from services.database import get_sql_session +from services.database import get_async_session from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"]) @OUTLINES_ROUTER.get("/stream") -async def stream_outlines(presentation_id: str): - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, presentation_id) +async def stream_outlines( + presentation_id: str, sql_session: AsyncSession = Depends(get_async_session) +): + presentation = await sql_session.get(PresentationModel, presentation_id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") @@ -54,10 +56,8 @@ async def stream_outlines(presentation_id: str): ] presentation.notes = presentation_content.notes - with get_sql_session() as sql_session: - sql_session.add(presentation) - sql_session.commit() - sql_session.refresh(presentation) + sql_session.add(presentation) + await sql_session.commit() yield SSECompleteResponse( key="presentation", value=presentation.model_dump(mode="json") diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index 9c3a0dae..f7fa7865 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -3,11 +3,10 @@ import json import os import random from typing import Annotated, List, Literal, Optional -import uuid -from annotated_types import Len -from fastapi import APIRouter, Body, File, HTTPException, UploadFile +from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile from fastapi.responses import StreamingResponse -from sqlalchemy import delete +from sqlalchemy import String, cast, delete +from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from constants.documents import UPLOAD_ACCEPTED_FILE_TYPES from models.presentation_and_path import PresentationPathAndEditPath @@ -29,7 +28,7 @@ from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline from models.sql.slide import SlideModel from models.sse_response import SSECompleteResponse, SSEResponse from services import TEMP_FILE_SERVICE -from services.database import get_sql_session +from services.database import get_async_session from services.documents_loader import DocumentsLoader from models.sql.presentation import PresentationModel from services.pptx_presentation_creator import PptxPresentationCreator @@ -49,57 +48,58 @@ PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"]) @PRESENTATION_ROUTER.get("", response_model=PresentationWithSlides) -def get_presentation(id: str): - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, id) - if not presentation: - raise HTTPException(404, "Presentation not found") - slides = sql_session.exec( - select(SlideModel) - .where(SlideModel.presentation == id) - .order_by(SlideModel.index) - ) - return PresentationWithSlides( - **presentation.model_dump(), - slides=slides, - ) +async def get_presentation( + id: str, sql_session: AsyncSession = Depends(get_async_session) +): + presentation = await sql_session.get(PresentationModel, id) + if not presentation: + raise HTTPException(404, "Presentation not found") + slides = await sql_session.scalars( + select(SlideModel) + .where(SlideModel.presentation == id) + .order_by(SlideModel.index) + ) + return PresentationWithSlides( + **presentation.model_dump(), + slides=slides, + ) @PRESENTATION_ROUTER.delete("", status_code=204) -def delete_presentation(id: str): - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, id) - if not presentation: - raise HTTPException(404, "Presentation not found") - slides = sql_session.exec( - select(SlideModel).where(SlideModel.presentation == id) - ).all() - for slide in slides: - sql_session.delete(slide) - sql_session.delete(presentation) - sql_session.commit() +async def delete_presentation( + id: str, sql_session: AsyncSession = Depends(get_async_session) +): + presentation = await sql_session.get(PresentationModel, id) + if not presentation: + raise HTTPException(404, "Presentation not found") + + await sql_session.execute(delete(SlideModel).where(SlideModel.presentation == id)) + await sql_session.delete(presentation) + await sql_session.commit() @PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides]) -def get_all_presentations(): - with get_sql_session() as sql_session: - presentations_with_slides = [] - presentations = sql_session.exec(select(PresentationModel)) - for presentation in presentations: - slides = sql_session.exec( - select(SlideModel) - .where(SlideModel.presentation == presentation.id) - .where(SlideModel.index == 0) - ).all() - if not slides: - continue - presentations_with_slides.append( - PresentationWithSlides( - **presentation.model_dump(), - slides=slides, - ) - ) - return presentations_with_slides +async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)): + presentations_with_slides = [] + presentations = await sql_session.scalars(select(PresentationModel)) + + async def inner(presentation: PresentationModel, sql_session: AsyncSession): + first_slide = await sql_session.scalar( + select(SlideModel) + .where(SlideModel.presentation == presentation.id) + .where(SlideModel.index == 0) + ) + if not first_slide: + return None + return PresentationWithSlides( + **presentation.model_dump(), + slides=[first_slide], + ) + + tasks = [inner(p, sql_session) for p in presentations] + results = await asyncio.gather(*tasks) + presentations_with_slides = [r for r in results if r is not None] + return presentations_with_slides @PRESENTATION_ROUTER.post("/create", response_model=PresentationModel) @@ -108,8 +108,9 @@ async def create_presentation( n_slides: Annotated[int, Body()], language: Annotated[str, Body()], file_paths: Annotated[Optional[List[str]], Body()] = None, + sql_session: AsyncSession = Depends(get_async_session), ): - presentation_id = str(uuid.uuid4()) + presentation_id = get_random_uuid() summary = None if file_paths: @@ -127,10 +128,8 @@ async def create_presentation( summary=summary, ) - with get_sql_session() as sql_session: - sql_session.add(presentation) - sql_session.commit() - sql_session.refresh(presentation) + sql_session.add(presentation) + await sql_session.commit() return presentation @@ -141,12 +140,14 @@ async def prepare_presentation( outlines: Annotated[List[SlideOutlineModel], Body()], layout: Annotated[PresentationLayoutModel, Body()], title: Annotated[Optional[str], Body()] = None, + sql_session: AsyncSession = Depends(get_async_session), ): if not outlines: raise HTTPException(status_code=400, detail="Outlines are required") - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, presentation_id) + presentation = await sql_session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") total_slide_layouts = len(layout.slides) total_outlines = len(outlines) @@ -170,34 +171,33 @@ async def prepare_presentation( if presentation_structure.slides[index] >= total_slide_layouts: presentation_structure.slides[index] = random_slide_index - with get_sql_session() as sql_session: - sql_session.add(presentation) - presentation.outlines = [each.model_dump() for each in outlines] - presentation.title = title or presentation.title - presentation.set_layout(layout) - presentation.set_structure(presentation_structure) - sql_session.commit() - sql_session.refresh(presentation) + sql_session.add(presentation) + presentation.outlines = [each.model_dump() for each in outlines] + presentation.title = title or presentation.title + presentation.set_layout(layout) + presentation.set_structure(presentation_structure) + await sql_session.commit() return presentation @PRESENTATION_ROUTER.get("/stream", response_model=PresentationWithSlides) -async def stream_presentation(presentation_id: str): - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, presentation_id) - if not presentation: - raise HTTPException(status_code=404, detail="Presentation not found") - if not presentation.structure: - raise HTTPException( - status_code=400, - detail="Presentation not prepared for stream", - ) - if not presentation.outlines: - raise HTTPException( - status_code=400, - detail="Outlines can not be empty", - ) +async def stream_presentation( + presentation_id: str, sql_session: AsyncSession = Depends(get_async_session) +): + presentation = await sql_session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + if not presentation.structure: + raise HTTPException( + status_code=400, + detail="Presentation not prepared for stream", + ) + if not presentation.outlines: + raise HTTPException( + status_code=400, + detail="Outlines can not be empty", + ) image_generation_service = ImageGenerationService(get_images_directory()) icon_finder_service = IconFinderService() @@ -218,7 +218,7 @@ async def stream_presentation(presentation_id: str): for i, slide_layout_index in enumerate(structure.slides): slide_layout = layout.slides[slide_layout_index] slide_content = await get_slide_content_from_type_and_outline( - slide_layout, outline.slides[i] + slide_layout, outline.slides[i], presentation.language ) slide = SlideModel( presentation=presentation_id, @@ -254,14 +254,10 @@ async def stream_presentation(presentation_id: str): for assets_list in generated_assets_lists: generated_assets.extend(assets_list) - with get_sql_session() as sql_session: - sql_session.add(presentation) - sql_session.add_all(slides) - sql_session.add_all(generated_assets) - sql_session.commit() - sql_session.refresh(presentation) - for each_slide in slides: - sql_session.refresh(each_slide) + sql_session.add(presentation) + sql_session.add_all(slides) + sql_session.add_all(generated_assets) + await sql_session.commit() response = PresentationWithSlides( **presentation.model_dump(), @@ -277,25 +273,22 @@ async def stream_presentation(presentation_id: str): @PRESENTATION_ROUTER.put("/update", response_model=PresentationWithSlides) -def update_presentation( +async def update_presentation( presentation_with_slides: Annotated[PresentationWithSlides, Body()], + sql_session: AsyncSession = Depends(get_async_session), ): updated_presentation = presentation_with_slides.to_presentation_model() updated_slides = presentation_with_slides.slides - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, updated_presentation.id) - if not presentation: - raise HTTPException(status_code=404, detail="Presentation not found") - presentation.sqlmodel_update(updated_presentation) + presentation = await sql_session.get(PresentationModel, updated_presentation.id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + presentation.sqlmodel_update(updated_presentation) - sql_session.exec( - delete(SlideModel).where(SlideModel.presentation == updated_presentation.id) - ) - sql_session.add_all(updated_slides) - sql_session.commit() - sql_session.refresh(presentation) - for slide in updated_slides: - sql_session.refresh(slide) + await sql_session.execute( + delete(SlideModel).where(SlideModel.presentation == updated_presentation.id) + ) + sql_session.add_all(updated_slides) + await sql_session.commit() return PresentationWithSlides( **presentation.model_dump(), @@ -304,7 +297,9 @@ def update_presentation( @PRESENTATION_ROUTER.post("/export/pptx", response_model=str) -async def create_pptx(pptx_model: Annotated[PptxPresentationModel, Body()]): +async def create_pptx( + pptx_model: Annotated[PptxPresentationModel, Body()], +): temp_dir = TEMP_FILE_SERVICE.create_temp_dir() pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) @@ -327,6 +322,7 @@ async def generate_presentation_api( layout: Annotated[str, Body()] = "general", files: Annotated[Optional[List[UploadFile]], File()] = None, export_as: Annotated[Literal["pptx", "pdf"], Body()] = "pptx", + sql_session: AsyncSession = Depends(get_async_session), ): validate_files(files, True, True, 50, UPLOAD_ACCEPTED_FILE_TYPES) @@ -369,12 +365,12 @@ async def generate_presentation_api( print(f"Generated {total_outlines} outlines for the presentation") # 4. Parse Layouts - layout = await get_layout_by_name(layout) - total_slide_layouts = len(layout.slides) + layout_model = await get_layout_by_name(layout) + total_slide_layouts = len(layout_model.slides) # 5. Generate Structure - if layout.ordered: - presentation_structure = layout.to_presentation_structure() + if layout_model.ordered: + presentation_structure = layout_model.to_presentation_structure() else: presentation_structure: PresentationStructureModel = ( await generate_presentation_structure( @@ -383,7 +379,7 @@ async def generate_presentation_api( slides=outlines, notes=presentation_content.notes, ), - presentation_layout=layout, + presentation_layout=layout_model, ) ) @@ -406,7 +402,7 @@ async def generate_presentation_api( summary=summary, outlines=[each.model_dump() for each in outlines], notes=presentation_content.notes, - layout=layout.model_dump(), + layout=layout_model.model_dump(), structure=presentation_structure.model_dump(), ) @@ -418,14 +414,14 @@ async def generate_presentation_api( slides: List[SlideModel] = [] slide_contents: List[dict] = [] for i, slide_layout_index in enumerate(presentation_structure.slides): - slide_layout = layout.slides[slide_layout_index] + slide_layout = layout_model.slides[slide_layout_index] print(f"Generating content for slide {i} with layout {slide_layout.id}") slide_content = await get_slide_content_from_type_and_outline( - slide_layout, outlines[i] + slide_layout, outlines[i], language ) slide = SlideModel( presentation=presentation_id, - layout_group=layout.name, + layout_group=layout_model.name, layout=slide_layout.id, index=i, content=slide_content, @@ -444,11 +440,10 @@ async def generate_presentation_api( generated_assets.extend(assets_list) # 8. Save PresentationModel and Slides - with get_sql_session() as sql_session: - sql_session.add(presentation) - sql_session.add_all(slides) - sql_session.add_all(generated_assets) - sql_session.commit() + sql_session.add(presentation) + sql_session.add_all(slides) + sql_session.add_all(generated_assets) + await sql_session.commit() # 9. Export presentation_and_path = await export_presentation( @@ -464,14 +459,14 @@ async def generate_presentation_api( @PRESENTATION_ROUTER.post("/from-template", response_model=PresentationPathAndEditPath) async def from_template( data: Annotated[GetPresentationUsingTemplateRequest, Body()], + sql_session: AsyncSession = Depends(get_async_session), ): - with get_sql_session() as sql_session: - presentation = sql_session.get(PresentationModel, data.presentation_id) - if not presentation: - raise HTTPException(status_code=404, detail="Presentation not found") - slides = sql_session.exec( - select(SlideModel).where(SlideModel.presentation == data.presentation_id) - ).all() + presentation = await sql_session.get(PresentationModel, data.presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + slides = await sql_session.scalars( + select(SlideModel).where(SlideModel.presentation == data.presentation_id) + ) new_presentation = presentation.get_new_presentation() new_slides = [] @@ -485,11 +480,9 @@ async def from_template( each_slide.get_new_slide(new_presentation.id, updated_content) ) - with get_sql_session() as sql_session: - sql_session.add(new_presentation) - sql_session.add_all(new_slides) - sql_session.commit() - sql_session.refresh(new_presentation) + sql_session.add(new_presentation) + sql_session.add_all(new_slides) + await sql_session.commit() presentation_and_path = await export_presentation( new_presentation.id, new_presentation.title, data.export_as diff --git a/servers/fastapi/api/v1/ppt/endpoints/slide.py b/servers/fastapi/api/v1/ppt/endpoints/slide.py index 111d7e3e..3a254453 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/slide.py +++ b/servers/fastapi/api/v1/ppt/endpoints/slide.py @@ -1,9 +1,10 @@ from typing import Annotated, Optional -from fastapi import APIRouter, Body, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession from models.sql.presentation import PresentationModel from models.sql.slide import SlideModel -from services.database import get_sql_session +from services.database import get_async_session from services.icon_finder_service import IconFinderService from services.image_generation_service import ImageGenerationService from utils.asset_directory_utils import get_images_directory @@ -18,15 +19,17 @@ SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"]) @SLIDE_ROUTER.post("/edit") -async def edit_slide(id: Annotated[str, Body()], prompt: Annotated[str, Body()]): - - with get_sql_session() as sql_session: - slide = sql_session.get(SlideModel, id) - if not slide: - raise HTTPException(status_code=404, detail="Slide not found") - presentation = sql_session.get(PresentationModel, slide.presentation) - if not presentation: - raise HTTPException(status_code=404, detail="Presentation not found") +async def edit_slide( + id: Annotated[str, Body()], + prompt: Annotated[str, Body()], + sql_session: AsyncSession = Depends(get_async_session), +): + slide = await sql_session.get(SlideModel, id) + if not slide: + raise HTTPException(status_code=404, detail="Slide not found") + presentation = await sql_session.get(PresentationModel, slide.presentation) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") presentation_layout = presentation.get_layout() @@ -51,13 +54,11 @@ async def edit_slide(id: Annotated[str, Body()], prompt: Annotated[str, Body()]) # Always assign a new unique id to the slide slide.id = get_random_uuid() - with get_sql_session() as sql_session: - sql_session.add(slide) - slide.content = edited_slide_content - slide.layout = slide_layout.id - sql_session.add_all(new_assets) - sql_session.commit() - sql_session.refresh(slide) + sql_session.add(slide) + slide.content = edited_slide_content + slide.layout = slide_layout.id + sql_session.add_all(new_assets) + await sql_session.commit() return slide @@ -67,11 +68,11 @@ async def edit_slide_html( id: Annotated[str, Body()], prompt: Annotated[str, Body()], html: Annotated[Optional[str], Body()] = None, + sql_session: AsyncSession = Depends(get_async_session), ): - with get_sql_session() as sql_session: - slide = sql_session.get(SlideModel, id) - if not slide: - raise HTTPException(status_code=404, detail="Slide not found") + slide = await sql_session.get(SlideModel, id) + if not slide: + raise HTTPException(status_code=404, detail="Slide not found") html_to_edit = html or slide.html_content if not html_to_edit: @@ -83,10 +84,8 @@ async def edit_slide_html( # This is to ensure that the nextjs can track slide updates slide.id = get_random_uuid() - with get_sql_session() as sql_session: - sql_session.add(slide) - slide.html_content = edited_slide_html - sql_session.commit() - sql_session.refresh(slide) + sql_session.add(slide) + slide.html_content = edited_slide_html + await sql_session.commit() return slide diff --git a/servers/fastapi/chroma/chroma.sqlite3 b/servers/fastapi/chroma/chroma.sqlite3 index cfcbd713..b39add05 100644 Binary files a/servers/fastapi/chroma/chroma.sqlite3 and b/servers/fastapi/chroma/chroma.sqlite3 differ diff --git a/servers/fastapi/models/presentation_with_slides.py b/servers/fastapi/models/presentation_with_slides.py index bda5eb5f..d8b4e57e 100644 --- a/servers/fastapi/models/presentation_with_slides.py +++ b/servers/fastapi/models/presentation_with_slides.py @@ -21,7 +21,7 @@ class PresentationWithSlides(BaseModel): summary: Optional[str] created_at: datetime updated_at: datetime - layout: PresentationLayoutModel + layout: Optional[PresentationLayoutModel] structure: Optional[PresentationStructureModel] slides: List[SlideModel] diff --git a/servers/fastapi/models/sql/presentation.py b/servers/fastapi/models/sql/presentation.py index 7a57d5f6..3fa5f779 100644 --- a/servers/fastapi/models/sql/presentation.py +++ b/servers/fastapi/models/sql/presentation.py @@ -1,6 +1,5 @@ from datetime import datetime from typing import List, Optional -import uuid from sqlalchemy import JSON, Column, DateTime from sqlmodel import SQLModel, Field diff --git a/servers/fastapi/models/sql/slide.py b/servers/fastapi/models/sql/slide.py index 1a058467..268bf1da 100644 --- a/servers/fastapi/models/sql/slide.py +++ b/servers/fastapi/models/sql/slide.py @@ -1,5 +1,4 @@ from typing import Optional -import uuid from sqlmodel import SQLModel, Field, Column, JSON from utils.randomizers import get_random_uuid diff --git a/servers/fastapi/requirements.txt b/servers/fastapi/requirements.txt index 22811bec..685964dd 100644 --- a/servers/fastapi/requirements.txt +++ b/servers/fastapi/requirements.txt @@ -1,10 +1,13 @@ aiohappyeyeballs==2.6.1 aiohttp==3.12.14 +aiomysql==0.2.0 aiosignal==1.4.0 +aiosqlite==0.21.0 annotated-types==0.7.0 anthropic==0.60.0 anyio==4.9.0 async-timeout==5.0.1 +asyncpg==0.30.0 attrs==25.3.0 backoff==2.2.1 bcrypt==4.3.0 diff --git a/servers/fastapi/services/__init__.py b/servers/fastapi/services/__init__.py index 56843e2b..2c4366c5 100644 --- a/servers/fastapi/services/__init__.py +++ b/servers/fastapi/services/__init__.py @@ -1,8 +1,6 @@ from services.redis_service import RedisService from services.temp_file_service import TempFileService -from services.database import sql_engine TEMP_FILE_SERVICE = TempFileService() -SQL_ENGINE = sql_engine REDIS_SERVICE = RedisService() diff --git a/servers/fastapi/services/database.py b/servers/fastapi/services/database.py index b08605b2..6b458f73 100644 --- a/servers/fastapi/services/database.py +++ b/servers/fastapi/services/database.py @@ -1,25 +1,42 @@ -from contextlib import contextmanager +from collections.abc import AsyncGenerator import os -from sqlalchemy import create_engine -from sqlmodel import Session +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + create_async_engine, + async_sessionmaker, + AsyncSession, +) +from sqlmodel import SQLModel from utils.get_env import get_app_data_directory_env, get_database_url_env -database_url = get_database_url_env() or "sqlite:///" + os.path.join( +raw_database_url = get_database_url_env() or "sqlite:///" + os.path.join( get_app_data_directory_env() or "/tmp/presenton", "fastapi.db" ) + +if raw_database_url.startswith("sqlite://"): + database_url = raw_database_url.replace("sqlite://", "sqlite+aiosqlite://", 1) +elif raw_database_url.startswith("postgresql://"): + database_url = raw_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) +elif raw_database_url.startswith("mysql://"): + database_url = raw_database_url.replace("mysql://", "mysql+aiomysql://", 1) +else: + database_url = raw_database_url + connect_args = {} if "sqlite" in database_url: connect_args["check_same_thread"] = False -sql_engine = create_engine(database_url, connect_args=connect_args) +sql_engine: AsyncEngine = create_async_engine(database_url, connect_args=connect_args) +async_session_maker = async_sessionmaker(sql_engine, expire_on_commit=False) -@contextmanager -def get_sql_session(): - session = Session(sql_engine) - try: +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: yield session - finally: - session.close() + + +async def create_db_and_tables(): + async with sql_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 0d6e1358..667425ac 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -1,6 +1,5 @@ import asyncio import os -import uuid import aiohttp from google import genai from google.genai.types import GenerateContentConfig @@ -16,6 +15,7 @@ from utils.image_provider import ( is_gemini_flash_selected, is_dalle3_selected, ) +from utils.randomizers import get_random_uuid class ImageGenerationService: @@ -104,7 +104,7 @@ class ImageGenerationService: if part.text is not None: print(part.text) elif part.inline_data is not None: - image_path = os.path.join(output_directory, f"{str(uuid.uuid4())}.jpg") + image_path = os.path.join(output_directory, f"{get_random_uuid()}.jpg") with open(image_path, "wb") as f: f.write(part.inline_data.data) diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index 3f682e09..44fd08ed 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -1,6 +1,5 @@ import os from typing import List, Optional -import uuid from lxml import etree from pptx import Presentation @@ -41,6 +40,7 @@ from utils.image_utils import ( round_image_corners, set_image_opacity, ) +from utils.randomizers import get_random_uuid BLANK_SLIDE_LAYOUT = 6 @@ -74,9 +74,9 @@ class PptxPresentationCreator: image_path = each_shape.picture.path if image_path.startswith("http"): if "app_data/" in image_path: - relative_path = image_path.split("/app_data/")[1] + relative_path = image_path.split("app_data/")[1] each_shape.picture.path = os.path.join( - "app_data", relative_path + "/app_data", relative_path ) each_shape.picture.is_network = False continue @@ -89,9 +89,9 @@ class PptxPresentationCreator: image_path = each_shape.picture.path if image_path.startswith("http"): if "app_data" in image_path: - relative_path = image_path.split("/app_data/")[1] + relative_path = image_path.split("app_data/")[1] each_shape.picture.path = os.path.join( - "app_data", relative_path + "/app_data", relative_path ) each_shape.picture.is_network = False continue @@ -210,7 +210,7 @@ class PptxPresentationCreator: image = invert_image(image) if picture_model.opacity: image = set_image_opacity(image, picture_model.opacity) - image_path = os.path.join(self._temp_dir, f"{str(uuid.uuid4())}.png") + image_path = os.path.join(self._temp_dir, f"{get_random_uuid()}.png") image.save(image_path) margined_position = self.get_margined_position( diff --git a/servers/fastapi/services/temp_file_service.py b/servers/fastapi/services/temp_file_service.py index 6fdf07a6..544f9246 100644 --- a/servers/fastapi/services/temp_file_service.py +++ b/servers/fastapi/services/temp_file_service.py @@ -1,8 +1,8 @@ import os -import uuid from typing import Optional, Union from utils.get_env import get_temp_directory_env +from utils.randomizers import get_random_uuid class TempFileService: @@ -14,7 +14,7 @@ class TempFileService: os.makedirs(self.base_dir, exist_ok=True) def create_dir_in_dir(self, base_dir: str, dir_name: Optional[str] = None) -> str: - temp_dir = os.path.join(base_dir, dir_name if dir_name else str(uuid.uuid4())) + temp_dir = os.path.join(base_dir, dir_name if dir_name else get_random_uuid()) os.makedirs(temp_dir, exist_ok=True) return temp_dir diff --git a/servers/fastapi/utils/llm_calls/edit_slide.py b/servers/fastapi/utils/llm_calls/edit_slide.py index 20c87c53..9069a459 100644 --- a/servers/fastapi/utils/llm_calls/edit_slide.py +++ b/servers/fastapi/utils/llm_calls/edit_slide.py @@ -1,6 +1,5 @@ import asyncio import json -from typing import Optional from models.presentation_layout import SlideLayoutModel from models.sql.slide import SlideModel @@ -21,24 +20,32 @@ system_prompt = """ - The goal is to change Slide data based on the provided prompt. - Do not change **Image prompts** and **Icon queries** if not asked for in prompt. - Generate **Image prompts** and **Icon queries** if asked to generate or change in prompt. + - Make sure to follow language guidelines. **Go through all notes and steps and make sure they are followed, including mentioned constraints** """ -def get_user_prompt(prompt: str, slide_data: dict, language: Optional[str] = None): +def get_user_prompt(prompt: str, slide_data: dict, language: str): return f""" - - Prompt: {prompt} - - Output Language: {language} - - Image Prompts and Icon Queries Language: English - - Slide data: {slide_data} + ## Icon Query And Image Prompt Language + English + + ## Slide Content Language + {language} + + ## Prompt + {prompt} + + ## Slide data + {slide_data} """ def get_prompt_to_edit_slide_content( prompt: str, slide_data: dict, - language: Optional[str] = None, + language: str, ): return [ { @@ -56,7 +63,7 @@ async def get_edited_slide_content( prompt: str, slide_layout: SlideLayoutModel, slide: SlideModel, - language: Optional[str] = None, + language: str, ): model = get_large_model() response_schema = remove_fields_from_schema( diff --git a/servers/fastapi/utils/llm_calls/generate_slide_content.py b/servers/fastapi/utils/llm_calls/generate_slide_content.py index 0d487bbb..e4fe86b0 100644 --- a/servers/fastapi/utils/llm_calls/generate_slide_content.py +++ b/servers/fastapi/utils/llm_calls/generate_slide_content.py @@ -24,12 +24,19 @@ system_prompt = """ - Provide prompt to generate image on "__image_prompt__" property. - Provide query to search icon on "__icon_query__" property. - Do not use markdown formatting in slide body. - - **Strictly follow the max and min character limit for every property in the slide.** + - Make sure to follow language guidelines. + **Strictly follow the max and min character limit for every property in the slide.** """ -def get_user_prompt(title: str, outline: str): +def get_user_prompt(title: str, outline: str, language: str): return f""" + ## Icon Query And Image Prompt Language + English + + ## Slide Content Language + {language} + ## Slide Title {title} @@ -38,7 +45,7 @@ def get_user_prompt(title: str, outline: str): """ -def get_prompt_to_generate_slide_content(title: str, outline: str): +def get_prompt_to_generate_slide_content(title: str, outline: str, language: str): return [ { @@ -47,13 +54,13 @@ def get_prompt_to_generate_slide_content(title: str, outline: str): }, { "role": "user", - "content": get_user_prompt(title, outline), + "content": get_user_prompt(title, outline, language), }, ] async def get_slide_content_from_type_and_outline( - slide_layout: SlideLayoutModel, outline: SlideOutlineModel + slide_layout: SlideLayoutModel, outline: SlideOutlineModel, language: str ): model = get_large_model() @@ -68,6 +75,7 @@ async def get_slide_content_from_type_and_outline( messages=get_prompt_to_generate_slide_content( outline.title, outline.body, + language, ), response_format={ "type": "json_schema", @@ -83,7 +91,7 @@ async def get_slide_content_from_type_and_outline( response = await asyncio.to_thread( client.models.generate_content, model=model, - contents=[get_user_prompt(outline.title, outline.body)], + contents=[get_user_prompt(outline.title, outline.body, language)], config=GenerateContentConfig( system_instruction=system_prompt, response_mime_type="application/json", diff --git a/servers/fastapi/utils/set_env.py b/servers/fastapi/utils/set_env.py index fbfaf221..836c8caa 100644 --- a/servers/fastapi/utils/set_env.py +++ b/servers/fastapi/utils/set_env.py @@ -18,7 +18,7 @@ def set_ollama_url_env(value): def set_custom_llm_url_env(value): - os.environ["CUSTOM_URL"] = value + os.environ["CUSTOM_LLM_URL"] = value def set_openai_api_key_env(value): @@ -44,9 +44,10 @@ def set_custom_model_env(value): def set_pexels_api_key_env(value): os.environ["PEXELS_API_KEY"] = value + def set_image_provider_env(value): os.environ["IMAGE_PROVIDER"] = value def set_pixabay_api_key_env(value): - os.environ["PIXABAY_API_KEY"] = value \ No newline at end of file + os.environ["PIXABAY_API_KEY"] = value diff --git a/servers/nextjs/app/(presentation-generator)/documents-preview/components/MarkdownRenderer.tsx b/servers/nextjs/app/(presentation-generator)/documents-preview/components/MarkdownRenderer.tsx index eb4998c3..814dcb34 100644 --- a/servers/nextjs/app/(presentation-generator)/documents-preview/components/MarkdownRenderer.tsx +++ b/servers/nextjs/app/(presentation-generator)/documents-preview/components/MarkdownRenderer.tsx @@ -1,14 +1,30 @@ "use client"; -import React from "react"; +import React, { useState, useEffect } from "react"; import { marked } from "marked"; + interface MarkdownRendererProps { content: string; } const MarkdownRenderer: React.FC = ({ content }) => { - const markdownContent = marked.parse(content); + const [markdownContent, setMarkdownContent] = useState(""); + + useEffect(() => { + const parseMarkdown = async () => { + try { + const parsed = await marked.parse(content); + setMarkdownContent(parsed); + } catch (error) { + console.error("Error parsing markdown:", error); + setMarkdownContent(""); + } + }; + + parseMarkdown(); + }, [content]); + return (