feat: Enhance presentation generation features and improve asset handling
- Added MAX_NUMBER_OF_SLIDES constant to limit slide generation. - Updated GeneratePresentationRequest model to allow optional slide count and language detection. - Implemented language resolution in edit_slide and generate_presentation_outlines utilities. - Enhanced user prompts to include optional parameters for slide count and table of contents. - Introduced outline utilities for managing table of contents and slide outlines. - Improved image and icon processing in slides to utilize outline image URLs. - Updated frontend components to resolve backend asset URLs for icons and images. - Refactored upload components to handle optional slide count and language selection. - Enhanced API utility functions to resolve backend asset URLs correctly.
This commit is contained in:
parent
de7c0930ed
commit
cc8f0bb862
19 changed files with 770 additions and 283 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import traceback
|
||||
import uuid
|
||||
import dirtyjson
|
||||
|
|
@ -19,8 +18,11 @@ from models.sse_response import (
|
|||
from services.temp_file_service import TEMP_FILE_SERVICE
|
||||
from services.database import get_async_session
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.outline_utils import (
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
|
||||
from utils.ppt_utils import get_presentation_title_from_outlines
|
||||
|
||||
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
|
||||
|
||||
|
|
@ -54,12 +56,14 @@ async def stream_outlines(
|
|||
|
||||
presentation_outlines_text = ""
|
||||
|
||||
n_slides_to_generate = presentation.n_slides
|
||||
if presentation.include_table_of_contents:
|
||||
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
|
||||
n_slides_to_generate -= math.ceil(
|
||||
(presentation.n_slides - needed_toc_count) / 10
|
||||
if presentation.n_slides > 0:
|
||||
n_slides_to_generate = get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=presentation.n_slides,
|
||||
toc=presentation.include_table_of_contents,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
else:
|
||||
n_slides_to_generate = None
|
||||
|
||||
async for chunk in generate_ppt_outline(
|
||||
presentation.content,
|
||||
|
|
@ -71,6 +75,7 @@ async def stream_outlines(
|
|||
presentation.instructions,
|
||||
presentation.include_title_slide,
|
||||
presentation.web_search,
|
||||
presentation.include_table_of_contents,
|
||||
):
|
||||
# Give control to the event loop
|
||||
await asyncio.sleep(0)
|
||||
|
|
@ -99,12 +104,30 @@ async def stream_outlines(
|
|||
|
||||
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
|
||||
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
yield SSEErrorResponse(
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
)
|
||||
).to_string()
|
||||
return
|
||||
|
||||
if n_slides_to_generate is not None:
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
|
||||
if presentation.n_slides <= 0:
|
||||
presentation.n_slides = len(presentation_outlines.slides)
|
||||
|
||||
presentation.outlines = presentation_outlines.model_dump()
|
||||
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
|
||||
presentation.title = get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
await sql_session.commit()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import asyncio
|
||||
from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import traceback
|
||||
|
|
@ -12,7 +11,7 @@ from fastapi.responses import StreamingResponse
|
|||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
from constants.presentation import DEFAULT_TEMPLATES
|
||||
from constants.presentation import DEFAULT_TEMPLATES, MAX_NUMBER_OF_SLIDES
|
||||
from enums.webhook_event import WebhookEvent
|
||||
from models.api_error_model import APIErrorModel
|
||||
from models.generate_presentation_request import GeneratePresentationRequest
|
||||
|
|
@ -58,9 +57,15 @@ from utils.llm_calls.generate_slide_content import (
|
|||
get_slide_content_from_type_and_outline,
|
||||
)
|
||||
from utils.ppt_utils import (
|
||||
get_presentation_title_from_outlines,
|
||||
select_toc_or_list_slide_layout_index,
|
||||
)
|
||||
from utils.outline_utils import (
|
||||
get_images_for_slides_from_outline,
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_no_of_toc_required_for_n_outlines,
|
||||
get_presentation_outline_model_with_toc,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.process_slides import (
|
||||
process_slide_add_placeholder_assets,
|
||||
process_slide_and_fetch_assets,
|
||||
|
|
@ -71,6 +76,20 @@ import uuid
|
|||
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
|
||||
|
||||
|
||||
def _insert_toc_layouts(
|
||||
structure: PresentationStructureModel,
|
||||
n_toc_slides: int,
|
||||
include_title_slide: bool,
|
||||
toc_slide_layout_index: int,
|
||||
):
|
||||
if n_toc_slides <= 0 or toc_slide_layout_index == -1:
|
||||
return
|
||||
|
||||
insertion_index = 1 if include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
structure.slides.insert(insertion_index + i, toc_slide_layout_index)
|
||||
|
||||
|
||||
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
|
||||
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
presentations_with_slides = []
|
||||
|
|
@ -129,8 +148,8 @@ async def delete_presentation(
|
|||
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
|
||||
async def create_presentation(
|
||||
content: Annotated[str, Body()],
|
||||
n_slides: Annotated[int, Body()],
|
||||
language: Annotated[str, Body()],
|
||||
n_slides: Annotated[Optional[int], Body()] = None,
|
||||
language: Annotated[Optional[str], Body()] = None,
|
||||
file_paths: Annotated[Optional[List[str]], Body()] = None,
|
||||
tone: Annotated[Tone, Body()] = Tone.DEFAULT,
|
||||
verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD,
|
||||
|
|
@ -141,19 +160,34 @@ async def create_presentation(
|
|||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
||||
if include_table_of_contents and n_slides < 3:
|
||||
if n_slides is not None and n_slides < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides must be greater than 0",
|
||||
)
|
||||
|
||||
if n_slides is not None and n_slides > MAX_NUMBER_OF_SLIDES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
|
||||
)
|
||||
|
||||
if include_table_of_contents and n_slides is not None and n_slides < 3:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides cannot be less than 3 if table of contents is included",
|
||||
)
|
||||
|
||||
presentation_id = uuid.uuid4()
|
||||
language_to_store = (language or "").strip()
|
||||
# DB schema stores an int; 0 is used as internal marker for auto slide count.
|
||||
n_slides_to_store = n_slides if n_slides is not None else 0
|
||||
|
||||
presentation = PresentationModel(
|
||||
id=presentation_id,
|
||||
content=content,
|
||||
n_slides=n_slides,
|
||||
language=language,
|
||||
n_slides=n_slides_to_store,
|
||||
language=language_to_store,
|
||||
file_paths=file_paths,
|
||||
tone=tone.value,
|
||||
verbosity=verbosity.value,
|
||||
|
|
@ -210,40 +244,24 @@ async def prepare_presentation(
|
|||
presentation_structure.slides[index] = random_slide_index
|
||||
|
||||
if presentation.include_table_of_contents:
|
||||
n_toc_slides = presentation.n_slides - total_outlines
|
||||
n_toc_slides = get_no_of_toc_required_for_n_outlines(
|
||||
n_outlines=total_outlines,
|
||||
title_slide=presentation.include_title_slide,
|
||||
target_total_slides=(presentation.n_slides if presentation.n_slides > 0 else None),
|
||||
)
|
||||
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout)
|
||||
if toc_slide_layout_index != -1:
|
||||
outline_index = 1 if presentation.include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
outlines_to = outline_index + 10
|
||||
if total_outlines == outlines_to:
|
||||
outlines_to -= 1
|
||||
|
||||
presentation_structure.slides.insert(
|
||||
i + 1 if presentation.include_title_slide else i,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
toc_outline = "Table of Contents\n\n"
|
||||
|
||||
for outline in presentation_outline_model.slides[
|
||||
outline_index:outlines_to
|
||||
]:
|
||||
page_number = (
|
||||
outline_index - i + n_toc_slides + 1
|
||||
if presentation.include_title_slide
|
||||
else outline_index - i + n_toc_slides
|
||||
)
|
||||
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
|
||||
outline_index += 1
|
||||
|
||||
outline_index += 1
|
||||
|
||||
presentation_outline_model.slides.insert(
|
||||
i + 1 if presentation.include_title_slide else i,
|
||||
SlideOutlineModel(
|
||||
content=toc_outline,
|
||||
),
|
||||
)
|
||||
_insert_toc_layouts(
|
||||
presentation_structure,
|
||||
n_toc_slides,
|
||||
presentation.include_title_slide,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
if toc_slide_layout_index != -1 and n_toc_slides > 0:
|
||||
presentation_outline_model = get_presentation_outline_model_with_toc(
|
||||
outline=presentation_outline_model,
|
||||
n_toc_slides=n_toc_slides,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
presentation.outlines = presentation_outline_model.model_dump(mode="json")
|
||||
|
|
@ -279,6 +297,7 @@ async def stream_presentation(
|
|||
structure = presentation.get_structure()
|
||||
layout = presentation.get_layout()
|
||||
outline = presentation.get_presentation_outline()
|
||||
image_urls_for_slides = get_images_for_slides_from_outline(outline.slides)
|
||||
|
||||
# These tasks will be gathered and awaited after all slides are generated
|
||||
async_assets_generation_tasks = []
|
||||
|
|
@ -319,7 +338,17 @@ async def stream_presentation(
|
|||
|
||||
# This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation
|
||||
async_assets_generation_tasks.append(
|
||||
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
|
||||
asyncio.create_task(
|
||||
process_slide_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide,
|
||||
outline_image_urls=(
|
||||
image_urls_for_slides[i]
|
||||
if i < len(image_urls_for_slides)
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
yield SSEResponse(
|
||||
|
|
@ -375,7 +404,7 @@ async def update_presentation(
|
|||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
presentation_update_dict = {}
|
||||
if n_slides:
|
||||
if n_slides is not None:
|
||||
presentation_update_dict["n_slides"] = n_slides
|
||||
if title:
|
||||
presentation_update_dict["title"] = title
|
||||
|
|
@ -465,13 +494,28 @@ async def check_if_api_request_is_valid(
|
|||
detail="Either content or slides markdown or files is required to generate presentation",
|
||||
)
|
||||
|
||||
# Making sure number of slides is greater than 0
|
||||
if request.n_slides <= 0:
|
||||
if request.n_slides is not None and request.n_slides <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides must be greater than 0",
|
||||
)
|
||||
|
||||
if request.n_slides is not None and request.n_slides > MAX_NUMBER_OF_SLIDES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
|
||||
)
|
||||
|
||||
if (
|
||||
request.include_table_of_contents
|
||||
and request.n_slides is not None
|
||||
and request.n_slides < 3
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides cannot be less than 3 if table of contents is included",
|
||||
)
|
||||
|
||||
# Checking if template is valid
|
||||
if request.template not in DEFAULT_TEMPLATES:
|
||||
request.template = request.template.lower()
|
||||
|
|
@ -502,6 +546,7 @@ async def generate_presentation_handler(
|
|||
):
|
||||
try:
|
||||
using_slides_markdown = False
|
||||
language_to_use = (request.language or "").strip() or None
|
||||
|
||||
if request.slides_markdown:
|
||||
using_slides_markdown = True
|
||||
|
|
@ -529,30 +574,27 @@ async def generate_presentation_handler(
|
|||
|
||||
# Finding number of slides to generate by considering table of contents
|
||||
n_slides_to_generate = request.n_slides
|
||||
if request.include_table_of_contents:
|
||||
needed_toc_count = math.ceil(
|
||||
(
|
||||
(request.n_slides - 1)
|
||||
if request.include_title_slide
|
||||
else request.n_slides
|
||||
if request.include_table_of_contents and request.n_slides is not None:
|
||||
n_slides_to_generate = (
|
||||
get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=request.n_slides,
|
||||
toc=True,
|
||||
title_slide=request.include_title_slide,
|
||||
)
|
||||
/ 10
|
||||
)
|
||||
n_slides_to_generate -= math.ceil(
|
||||
(request.n_slides - needed_toc_count) / 10
|
||||
)
|
||||
|
||||
presentation_outlines_text = ""
|
||||
async for chunk in generate_ppt_outline(
|
||||
request.content,
|
||||
n_slides_to_generate,
|
||||
request.language,
|
||||
language_to_use,
|
||||
additional_context,
|
||||
request.tone.value,
|
||||
request.verbosity.value,
|
||||
request.instructions,
|
||||
request.include_title_slide,
|
||||
request.web_search,
|
||||
request.include_table_of_contents,
|
||||
):
|
||||
|
||||
if isinstance(chunk, HTTPException):
|
||||
|
|
@ -573,7 +615,20 @@ async def generate_presentation_handler(
|
|||
presentation_outlines = PresentationOutlineModel(
|
||||
**presentation_outlines_json
|
||||
)
|
||||
total_outlines = n_slides_to_generate
|
||||
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
),
|
||||
)
|
||||
|
||||
total_outlines = len(presentation_outlines.slides)
|
||||
|
||||
else:
|
||||
# Setting outlines to slides markdown
|
||||
|
|
@ -621,50 +676,42 @@ async def generate_presentation_handler(
|
|||
if presentation_structure.slides[index] >= total_slide_layouts:
|
||||
presentation_structure.slides[index] = random_slide_index
|
||||
|
||||
# Injecting table of contents to the presentation structure and outlines
|
||||
if request.include_table_of_contents and not using_slides_markdown:
|
||||
n_toc_slides = request.n_slides - total_outlines
|
||||
should_include_toc = (
|
||||
request.include_table_of_contents and not using_slides_markdown
|
||||
)
|
||||
if should_include_toc:
|
||||
n_toc_slides = get_no_of_toc_required_for_n_outlines(
|
||||
n_outlines=total_outlines,
|
||||
title_slide=request.include_title_slide,
|
||||
target_total_slides=request.n_slides,
|
||||
)
|
||||
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model)
|
||||
if toc_slide_layout_index != -1:
|
||||
outline_index = 1 if request.include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
outlines_to = outline_index + 10
|
||||
if total_outlines == outlines_to:
|
||||
outlines_to -= 1
|
||||
_insert_toc_layouts(
|
||||
presentation_structure,
|
||||
n_toc_slides,
|
||||
request.include_title_slide,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
if toc_slide_layout_index != -1 and n_toc_slides > 0:
|
||||
presentation_outlines = get_presentation_outline_model_with_toc(
|
||||
outline=presentation_outlines,
|
||||
n_toc_slides=n_toc_slides,
|
||||
title_slide=request.include_title_slide,
|
||||
)
|
||||
|
||||
presentation_structure.slides.insert(
|
||||
i + 1 if request.include_title_slide else i,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
toc_outline = "Table of Contents\n\n"
|
||||
|
||||
for outline in presentation_outlines.slides[
|
||||
outline_index:outlines_to
|
||||
]:
|
||||
page_number = (
|
||||
outline_index - i + n_toc_slides + 1
|
||||
if request.include_title_slide
|
||||
else outline_index - i + n_toc_slides
|
||||
)
|
||||
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
|
||||
outline_index += 1
|
||||
|
||||
outline_index += 1
|
||||
|
||||
presentation_outlines.slides.insert(
|
||||
i + 1 if request.include_title_slide else i,
|
||||
SlideOutlineModel(
|
||||
content=toc_outline,
|
||||
),
|
||||
)
|
||||
final_n_slides = request.n_slides
|
||||
if final_n_slides is None:
|
||||
final_n_slides = len(presentation_outlines.slides)
|
||||
|
||||
# Create PresentationModel
|
||||
presentation = PresentationModel(
|
||||
id=presentation_id,
|
||||
content=request.content,
|
||||
n_slides=request.n_slides,
|
||||
language=request.language,
|
||||
title=get_presentation_title_from_outlines(presentation_outlines),
|
||||
n_slides=final_n_slides,
|
||||
language=language_to_use or "",
|
||||
title=get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
),
|
||||
outlines=presentation_outlines.model_dump(),
|
||||
layout=layout_model.model_dump(),
|
||||
structure=presentation_structure.model_dump(),
|
||||
|
|
@ -701,7 +748,7 @@ async def generate_presentation_handler(
|
|||
get_slide_content_from_type_and_outline(
|
||||
slide_layouts[i],
|
||||
presentation_outlines.slides[i],
|
||||
request.language,
|
||||
language_to_use,
|
||||
request.tone.value,
|
||||
request.verbosity.value,
|
||||
request.instructions,
|
||||
|
|
@ -726,10 +773,23 @@ async def generate_presentation_handler(
|
|||
slides.append(slide)
|
||||
batch_slides.append(slide)
|
||||
|
||||
if using_slides_markdown:
|
||||
image_urls_for_batch = get_images_for_slides_from_outline(
|
||||
presentation_outlines.slides[start:end]
|
||||
)
|
||||
else:
|
||||
image_urls_for_batch = [[] for _ in batch_slides]
|
||||
|
||||
# Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls
|
||||
asset_tasks = [
|
||||
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
|
||||
for slide in batch_slides
|
||||
asyncio.create_task(
|
||||
process_slide_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide,
|
||||
outline_image_urls=image_urls_for_batch[offset],
|
||||
)
|
||||
)
|
||||
for offset, slide in enumerate(batch_slides)
|
||||
]
|
||||
async_assets_generation_tasks.extend(asset_tasks)
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
|
||||
MAX_NUMBER_OF_SLIDES = 50
|
||||
|
|
|
|||
|
|
@ -18,9 +18,13 @@ class GeneratePresentationRequest(BaseModel):
|
|||
default=Verbosity.STANDARD, description="How verbose the presentation should be"
|
||||
)
|
||||
web_search: bool = Field(default=False, description="Whether to enable web search")
|
||||
n_slides: int = Field(default=8, description="Number of slides to generate")
|
||||
language: str = Field(
|
||||
default="English", description="Language for the presentation"
|
||||
n_slides: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of slides to generate. If omitted, model auto-detects slide count.",
|
||||
)
|
||||
language: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Language for the presentation. If omitted, model auto-detects language.",
|
||||
)
|
||||
template: str = Field(
|
||||
default="general", description="Template to use for the presentation"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ from utils.llm_provider import get_model
|
|||
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def get_system_prompt(
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
|
|
@ -40,6 +51,7 @@ def get_system_prompt(
|
|||
|
||||
|
||||
def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
||||
display_language = _resolve_prompt_language(language)
|
||||
return f"""
|
||||
## Icon Query And Image Prompt Language
|
||||
English
|
||||
|
|
@ -48,7 +60,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
|||
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
## Slide Content Language
|
||||
{language}
|
||||
{display_language}
|
||||
|
||||
## Prompt
|
||||
{prompt}
|
||||
|
|
@ -61,7 +73,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
|||
def get_messages(
|
||||
prompt: str,
|
||||
slide_data: dict,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
|
|
@ -79,7 +91,7 @@ def get_messages(
|
|||
async def get_edited_slide_content(
|
||||
prompt: str,
|
||||
slide: SlideModel,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
slide_layout: SlideLayoutModel,
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from datetime import datetime
|
|||
from typing import Optional
|
||||
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from models.llm_tools import SearchWebTool
|
||||
from services.llm_client import LLMClient
|
||||
from utils.get_dynamic_models import get_presentation_outline_model_with_n_slides
|
||||
|
|
@ -20,6 +21,7 @@ def get_system_prompt(
|
|||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
verbosity_instruction = (
|
||||
"Slide content should be abound 20 words but detailed enough to generate a good slide."
|
||||
|
|
@ -37,101 +39,126 @@ def get_system_prompt(
|
|||
else "Do not include presenter name in any slides."
|
||||
)
|
||||
|
||||
slide_structure_instruction = (
|
||||
"Each slide should have `structure` field and it"
|
||||
" - Must briefly describe the components in the slide layout:"
|
||||
" - Must start with one of given EXAMPLE STRUCTURES and add/update components as welll as change layouts as per the content."
|
||||
" - Must match with the provided content in structure."
|
||||
" - It shouldn't be very creative. You must pick of the given structures and make slight changes(not more than 5 words) to fit all content."
|
||||
toc_instruction = (
|
||||
"Include a table of contents slide in the outline sequence."
|
||||
if include_table_of_contents
|
||||
else ""
|
||||
)
|
||||
toc_block = f"{toc_instruction}\n" if toc_instruction else ""
|
||||
|
||||
slide_outline_structure = (
|
||||
"Each slide content:\n"
|
||||
" - Must have a ## title.\n"
|
||||
" - Must have content in exactly the format to be displayed in slide.\n"
|
||||
" - Where content should be structured in Markdown format exactly as how it should be shown in slide layout.\n"
|
||||
" - Must have content either in multiple bullet points or table or both.\n"
|
||||
" - Must be in Markdown format.\n"
|
||||
" - Don't use **bold** and __italic__ text."
|
||||
" - First slide title must be the same as the presentation title."
|
||||
)
|
||||
|
||||
user_instruction_block = (
|
||||
f"# User Instruction:\n{instructions}\n"
|
||||
if instructions
|
||||
else ""
|
||||
)
|
||||
tone_block = f"# Tone:\n{tone}\n" if tone else ""
|
||||
|
||||
system = (
|
||||
"Generate presentation title and outlines for slides.\n"
|
||||
f"{user_instruction_block}"
|
||||
f"{tone_block}"
|
||||
"Generate presentation title and content for slides.\n"
|
||||
"Generate flow based on user **content** and use **context** just for reference.\n"
|
||||
"Presentation title should be plain text, not markdown. It should be a concise title for the presentation.\n"
|
||||
"Each slide outline should contain the content for that slide.\n"
|
||||
"First slide should be intro and second should be table of contents, then start with regular content slides.\n"
|
||||
"Do not overstuff content within same slide. Consider using a slide for a single heading and not more than 2 sub-headings. If more than that is required put in in another slide as Topic X - 2/3\n"
|
||||
"Each slide content should contain the content for that slide.\n"
|
||||
f"{verbosity_instruction}\n"
|
||||
"Minimize repetitive content and make sure to use different words and phrases for different slides.\n"
|
||||
"Include numerical data or tables if required or asked by the user.\n"
|
||||
"Strictly follow given language and generate content is the prescribed language despite of content or other instructions."
|
||||
f"Each slide should object should have `structure` and `content` fields.\n"
|
||||
"If 'auto-detect' is used, figure it out from the content/context.\n"
|
||||
f"{title_slide_instruction}\n"
|
||||
f"{slide_structure_instruction}\n"
|
||||
f"{toc_block}"
|
||||
f"{slide_outline_structure}\n"
|
||||
"Slide outline must not contain any presentation branding/styling information.\n"
|
||||
"Slide content must not contain any presentation branding/styling information.\n"
|
||||
"Title slide must only contain title, presenter name, date and overview.\n"
|
||||
"Only include URLs if they appear in the provided content/context.\n"
|
||||
"Make sure data used is strictly from the provided content/context.\n"
|
||||
"Make sure data is consistent across all slides.\n"
|
||||
"**Pick different types of slide structures where appropriate to maintain diversity**.\n"
|
||||
"If language is arabic then generate content is Modern Standard English (MSA).\n"
|
||||
"If instructed to generate a template then leave spaces with '____' in the content. Do not add arbitrary content, just add fillers."
|
||||
"**Never give out chinese text/content.**\n"
|
||||
"**Search web to get latest information about the topic**\n"
|
||||
"User instruction should always be followed and should supercede any other instruction, except for slide numbers."
|
||||
"Make sure data is consistent across all slides."
|
||||
)
|
||||
|
||||
return system
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def _resolve_prompt_n_slides(n_slides: Optional[int]) -> str:
|
||||
if n_slides is None:
|
||||
return "auto-detect"
|
||||
return str(n_slides)
|
||||
|
||||
|
||||
def get_user_prompt(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
language: str,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str],
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return f"""
|
||||
**Input:**
|
||||
- User provided content: {content or "Create presentation"}
|
||||
- Output Language: {language}
|
||||
- Number of Slides: {n_slides}
|
||||
- Current Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
- Additional Information: {additional_context or ""}
|
||||
"""
|
||||
display_language = _resolve_prompt_language(language)
|
||||
display_slides = _resolve_prompt_n_slides(n_slides)
|
||||
toc_text = f"Include Table Of Contents: {str(include_table_of_contents).lower()}\n"
|
||||
return (
|
||||
f"Content: {content or ''}\n"
|
||||
f"Number of Slides: {display_slides}\n"
|
||||
f"Language: {display_language}\n"
|
||||
f"Tone: {tone or ''}\n"
|
||||
f"Today's Date: {datetime.now().strftime('%Y-%m-%d')}\n"
|
||||
f"Include Title Slide: {include_title_slide}\n"
|
||||
f"{toc_text if include_table_of_contents else ''}"
|
||||
f"Instructions: {instructions or ''}\n"
|
||||
f"Context: {additional_context or ''}"
|
||||
)
|
||||
|
||||
|
||||
def get_messages(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
language: str,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str],
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=get_system_prompt(
|
||||
tone, verbosity, instructions, include_title_slide
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=get_user_prompt(content, n_slides, language, additional_context),
|
||||
content=get_user_prompt(
|
||||
content,
|
||||
n_slides,
|
||||
language,
|
||||
additional_context,
|
||||
tone,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def generate_ppt_outline(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str] = None,
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
|
|
@ -139,9 +166,14 @@ async def generate_ppt_outline(
|
|||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
web_search: bool = False,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
model = get_model()
|
||||
response_model = get_presentation_outline_model_with_n_slides(n_slides)
|
||||
response_model = (
|
||||
get_presentation_outline_model_with_n_slides(n_slides)
|
||||
if n_slides is not None
|
||||
else PresentationOutlineModel
|
||||
)
|
||||
|
||||
client = LLMClient()
|
||||
|
||||
|
|
@ -157,6 +189,7 @@ async def generate_ppt_outline(
|
|||
verbosity,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
response_model.model_json_schema(),
|
||||
strict=True,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import datetime
|
||||
import json
|
||||
from typing import Optional
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import SlideLayoutModel
|
||||
|
|
@ -9,88 +10,136 @@ from utils.llm_provider import get_model
|
|||
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
|
||||
|
||||
|
||||
SLIDE_CONTENT_SYSTEM_PROMPT = """
|
||||
You will be given slide content and response schema.
|
||||
You need to generate structured content json based on the schema.
|
||||
|
||||
# Steps
|
||||
1. Analyze the content.
|
||||
2. Analyze the response schema.
|
||||
3. Generate structured content json based on the schema.
|
||||
4. Generate speaker note if required.
|
||||
5. Provide structured content json as output.
|
||||
|
||||
# General Rules
|
||||
- Make sure to follow language guidelines.
|
||||
- Speaker note should be normal text, not markdown.
|
||||
- Never ever go over the max character limit.
|
||||
- Do not add emoji in the content.
|
||||
- Don't provide $schema field in content json.
|
||||
{markdown_emphasis_rules}
|
||||
|
||||
{user_instructions}
|
||||
|
||||
{tone_instructions}
|
||||
|
||||
{verbosity_instructions}
|
||||
|
||||
{output_fields_instructions}
|
||||
"""
|
||||
|
||||
|
||||
SLIDE_CONTENT_USER_PROMPT = """
|
||||
# Current Date and Time:
|
||||
{current_date_time}
|
||||
|
||||
# Icon Query And Image Prompt Language:
|
||||
English
|
||||
|
||||
# Slide Language:
|
||||
{language}
|
||||
|
||||
# SLIDE CONTENT: START
|
||||
{content}
|
||||
# SLIDE CONTENT: END
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def _get_schema_markdown(response_schema: Optional[dict]) -> str:
|
||||
if not response_schema:
|
||||
return "- Follow the provided response schema strictly."
|
||||
try:
|
||||
schema_text = json.dumps(response_schema, ensure_ascii=False)
|
||||
except Exception:
|
||||
return "- Follow the provided response schema strictly."
|
||||
return f"- Follow this response schema exactly: {schema_text}"
|
||||
|
||||
|
||||
def get_system_prompt(
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
response_schema: Optional[dict] = None,
|
||||
):
|
||||
return f"""
|
||||
Generate structured slide based on provided outline, follow mentioned steps and notes and provide structured output.
|
||||
markdown_emphasis_rules = (
|
||||
"- Strictly use markdown to emphasize important points, by bolding or "
|
||||
"italicizing the part of text."
|
||||
)
|
||||
|
||||
{"# User Instructions:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
user_instructions = f"# User Instructions:\n{instructions}" if instructions else ""
|
||||
tone_instructions = (
|
||||
f"# Tone Instructions:\nMake slide as {tone} as possible." if tone else ""
|
||||
)
|
||||
|
||||
{"# Tone:" if tone else ""}
|
||||
{tone or ""}
|
||||
verbosity_instructions = ""
|
||||
if verbosity:
|
||||
verbosity_instructions = "# Verbosity Instructions:\n"
|
||||
if verbosity == "concise":
|
||||
verbosity_instructions += "Make slide as concise as possible."
|
||||
elif verbosity == "standard":
|
||||
verbosity_instructions += "Make slide as standard as possible."
|
||||
elif verbosity == "text-heavy":
|
||||
verbosity_instructions += "Make slide as text-heavy as possible."
|
||||
|
||||
{"# Verbosity:" if verbosity else ""}
|
||||
{verbosity or ""}
|
||||
output_fields_instructions = "# Output Fields:\n" + _get_schema_markdown(
|
||||
response_schema
|
||||
)
|
||||
|
||||
# Steps
|
||||
1. Analyze the outline.
|
||||
2. Generate structured slide based on the outline.
|
||||
3. Generate speaker note that is simple, clear, concise and to the point.
|
||||
|
||||
# Notes
|
||||
- Slide body should not use words like "This slide", "This presentation".
|
||||
- Rephrase the slide body to make it flow naturally.
|
||||
- Only use markdown to highlight important points.
|
||||
- Make sure to follow language guidelines.
|
||||
- Speaker note should be normal text, not markdown.
|
||||
- Strictly follow the max and min character limit for every property in the slide.
|
||||
- Never ever go over the max character limit. Limit your narration to make sure you never go over the max character limit.
|
||||
- Number of items should not be more than max number of items specified in slide schema. If you have to put multiple points then merge them to obey max numebr of items.
|
||||
- Generate content as per the given tone.
|
||||
- Be very careful with number of words to generate for given field. As generating more than max characters will overflow in the design. So, analyze early and never generate more characters than allowed.
|
||||
- Do not add emoji in the content.
|
||||
- Metrics should be in abbreviated form with least possible characters. Do not add long sequence of words for metrics.
|
||||
- For verbosity:
|
||||
- If verbosity is 'concise', then generate description as 1/3 or lower of the max character limit. Don't worry if you miss content or context.
|
||||
- If verbosity is 'standard', then generate description as 2/3 of the max character limit.
|
||||
- If verbosity is 'text-heavy', then generate description as 3/4 or higher of the max character limit. Make sure it does not exceed the max character limit.
|
||||
|
||||
User instructions, tone and verbosity should always be followed and should supercede any other instruction, except for max and min character limit, slide schema and number of items.
|
||||
|
||||
- Provide output in json format and **don't include <parameters> tags**.
|
||||
|
||||
# Image and Icon Output Format
|
||||
image: {{
|
||||
__image_prompt__: string,
|
||||
}}
|
||||
icon: {{
|
||||
__icon_query__: string,
|
||||
}}
|
||||
|
||||
"""
|
||||
return SLIDE_CONTENT_SYSTEM_PROMPT.format(
|
||||
markdown_emphasis_rules=markdown_emphasis_rules,
|
||||
user_instructions=user_instructions,
|
||||
tone_instructions=tone_instructions,
|
||||
verbosity_instructions=verbosity_instructions,
|
||||
output_fields_instructions=output_fields_instructions,
|
||||
)
|
||||
|
||||
|
||||
def get_user_prompt(outline: str, language: str):
|
||||
return f"""
|
||||
## Current Date and Time
|
||||
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
## Icon Query And Image Prompt Language
|
||||
English
|
||||
|
||||
## Slide Content Language
|
||||
{language}
|
||||
|
||||
## Slide Outline
|
||||
{outline}
|
||||
"""
|
||||
def get_user_prompt(outline: str, language: Optional[str]):
|
||||
return SLIDE_CONTENT_USER_PROMPT.format(
|
||||
current_date_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
language=_resolve_prompt_language(language),
|
||||
content=outline,
|
||||
)
|
||||
|
||||
|
||||
def get_messages(
|
||||
outline: str,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
response_schema: Optional[dict] = None,
|
||||
):
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=get_system_prompt(tone, verbosity, instructions),
|
||||
content=get_system_prompt(
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
response_schema,
|
||||
),
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=get_user_prompt(outline, language),
|
||||
|
|
@ -101,7 +150,7 @@ def get_messages(
|
|||
async def get_slide_content_from_type_and_outline(
|
||||
slide_layout: SlideLayoutModel,
|
||||
outline: SlideOutlineModel,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
|
|
@ -134,6 +183,7 @@ async def get_slide_content_from_type_and_outline(
|
|||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
response_schema,
|
||||
),
|
||||
response_format=response_schema,
|
||||
strict=False,
|
||||
|
|
|
|||
205
electron/servers/fastapi/utils/outline_utils.py
Normal file
205
electron/servers/fastapi/utils/outline_utils.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import math
|
||||
import re
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from models.presentation_outline_model import (
|
||||
PresentationOutlineModel,
|
||||
SlideOutlineModel,
|
||||
)
|
||||
|
||||
|
||||
HEADING_PATTERN = re.compile(r"^\s{0,3}#+\s*(.+)$", re.MULTILINE)
|
||||
FIRST_SENTENCE_PATTERN = re.compile(r"^\s*([^.?!]+?[.?!])", re.DOTALL)
|
||||
IMAGE_URL_PATTERN = re.compile(
|
||||
r"https?://[-\w./%~:!$&'()*+,;=]+?\.(?:jpe?g|png|webp)(?:\?[^\s\"\'\\]*)?",
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def get_presentation_title_from_presentation_outline(
|
||||
presentation_outline: PresentationOutlineModel,
|
||||
) -> str:
|
||||
if not presentation_outline.slides:
|
||||
return "Untitled Presentation"
|
||||
|
||||
first_content = presentation_outline.slides[0].content or ""
|
||||
|
||||
if re.match(r"^\s*#{1,6}\s*Page\s+\d+\b", first_content):
|
||||
first_content = re.sub(
|
||||
r"^\s*#{1,6}\s*Page\s+\d+\b[\s,:\-]*",
|
||||
"",
|
||||
first_content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
return (
|
||||
first_content[:100]
|
||||
.replace("#", "")
|
||||
.replace("/", "")
|
||||
.replace("\\", "")
|
||||
.replace("\n", " ")
|
||||
)
|
||||
|
||||
|
||||
def _get_toc_count_for_total_slides(total_slides: int, title_slide: bool) -> int:
|
||||
if total_slides <= 0:
|
||||
return 0
|
||||
|
||||
first_pass = math.ceil(((total_slides - 1) if title_slide else total_slides) / 10)
|
||||
return math.ceil((total_slides - first_pass) / 10)
|
||||
|
||||
|
||||
def get_no_of_toc_required_for_n_outlines(
|
||||
*,
|
||||
n_outlines: int,
|
||||
title_slide: bool,
|
||||
target_total_slides: Optional[int] = None,
|
||||
) -> int:
|
||||
if target_total_slides is not None:
|
||||
adjusted_total = max(target_total_slides, n_outlines)
|
||||
return _get_toc_count_for_total_slides(adjusted_total, title_slide)
|
||||
|
||||
if n_outlines <= 0:
|
||||
return 0
|
||||
|
||||
return math.ceil(((n_outlines - 1) if title_slide else n_outlines) / 10)
|
||||
|
||||
|
||||
def get_no_of_outlines_to_generate_for_n_slides(
|
||||
*,
|
||||
n_slides: int,
|
||||
toc: bool,
|
||||
title_slide: bool,
|
||||
) -> int:
|
||||
if toc:
|
||||
n_toc_1 = math.ceil(((n_slides - 1) if title_slide else n_slides) / 10)
|
||||
n_toc_2 = math.ceil((n_slides - n_toc_1) / 10)
|
||||
|
||||
return n_slides - n_toc_2
|
||||
|
||||
else:
|
||||
return n_slides
|
||||
|
||||
|
||||
def get_presentation_outline_model_with_toc(
|
||||
*,
|
||||
outline: PresentationOutlineModel,
|
||||
n_toc_slides: int,
|
||||
title_slide: bool,
|
||||
) -> PresentationOutlineModel:
|
||||
if n_toc_slides <= 0:
|
||||
return outline
|
||||
|
||||
outline_with_toc = outline.model_copy(deep=True)
|
||||
insertion_index = 1 if title_slide else 0
|
||||
|
||||
existing_outlines = outline_with_toc.slides
|
||||
outlines_for_toc = existing_outlines[insertion_index:]
|
||||
if not outlines_for_toc:
|
||||
return outline_with_toc
|
||||
|
||||
sections = _split_outlines_evenly(outlines_for_toc, n_toc_slides)
|
||||
if not sections:
|
||||
return outline_with_toc
|
||||
|
||||
toc_slides: List[SlideOutlineModel] = []
|
||||
outlines_before_toc = 1 if title_slide else 0
|
||||
total_toc_slides = len(sections)
|
||||
global_outline_index = 0
|
||||
|
||||
for section_index, section in enumerate(sections):
|
||||
section_lines = [
|
||||
"## Table of Contents",
|
||||
"",
|
||||
]
|
||||
|
||||
for outline in section:
|
||||
outline_title = _extract_outline_title(outline.content)
|
||||
page_number = (
|
||||
outlines_before_toc + total_toc_slides + global_outline_index + 1
|
||||
)
|
||||
section_lines.append(
|
||||
f"- Page number: {page_number}, Title: {outline_title}"
|
||||
)
|
||||
global_outline_index += 1
|
||||
|
||||
toc_slides.append(
|
||||
SlideOutlineModel(
|
||||
content="\n".join(
|
||||
line for line in section_lines if line is not None
|
||||
).strip()
|
||||
)
|
||||
)
|
||||
|
||||
for offset, toc_slide in enumerate(toc_slides):
|
||||
existing_outlines.insert(insertion_index + offset, toc_slide)
|
||||
|
||||
return outline_with_toc
|
||||
|
||||
|
||||
def _split_outlines_evenly(
|
||||
outlines: Iterable[SlideOutlineModel], n_sections: int
|
||||
) -> List[List[SlideOutlineModel]]:
|
||||
"""Split outlines into n contiguous sections with near-equal sizes."""
|
||||
outlines_list = list(outlines)
|
||||
if n_sections <= 0 or not outlines_list:
|
||||
return []
|
||||
|
||||
total = len(outlines_list)
|
||||
n_sections = max(1, n_sections)
|
||||
base_size = total // n_sections
|
||||
remainder = total % n_sections
|
||||
|
||||
sections: List[List[SlideOutlineModel]] = []
|
||||
start = 0
|
||||
for section_index in range(n_sections):
|
||||
current_size = base_size + (1 if section_index < remainder else 0)
|
||||
end = start + current_size
|
||||
sections.append(outlines_list[start:end])
|
||||
start = end
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _extract_outline_title(content: str) -> str:
|
||||
"""Get a human-friendly title from an outline's markdown content."""
|
||||
text = content or ""
|
||||
|
||||
heading_match = HEADING_PATTERN.search(text)
|
||||
if heading_match:
|
||||
return heading_match.group(1).strip()
|
||||
|
||||
sentence_match = FIRST_SENTENCE_PATTERN.search(text.strip())
|
||||
if sentence_match:
|
||||
return sentence_match.group(1).strip()
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped_line = line.strip()
|
||||
if stripped_line:
|
||||
return stripped_line
|
||||
|
||||
return "Slide"
|
||||
|
||||
|
||||
def get_images_for_slides_from_outline(
|
||||
slides: List[SlideOutlineModel],
|
||||
) -> List[List[str]]:
|
||||
"""
|
||||
Extract image URLs (png, jpg, jpeg, webp) from each slide's content in the outline.
|
||||
|
||||
Args:
|
||||
outline: PresentationOutlineModel containing slides with content
|
||||
|
||||
Returns:
|
||||
List of lists of image URLs, one list per slide
|
||||
"""
|
||||
result: List[List[str]] = []
|
||||
|
||||
for slide in slides:
|
||||
content = slide.content or ""
|
||||
image_urls = IMAGE_URL_PATTERN.findall(content)
|
||||
# Remove duplicates while preserving order
|
||||
unique_urls = list(dict.fromkeys(image_urls))
|
||||
result.append(unique_urls)
|
||||
|
||||
return result
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import asyncio
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.slide import SlideModel
|
||||
|
|
@ -14,15 +14,27 @@ from utils.path_helpers import get_resource_path
|
|||
async def process_slide_and_fetch_assets(
|
||||
image_generation_service: ImageGenerationService,
|
||||
slide: SlideModel,
|
||||
outline_image_urls: Optional[List[str]] = None,
|
||||
) -> List[ImageAsset]:
|
||||
|
||||
async_tasks = []
|
||||
async_task_meta = []
|
||||
|
||||
image_paths = get_dict_paths_with_key(slide.content, "__image_prompt__")
|
||||
icon_paths = get_dict_paths_with_key(slide.content, "__icon_query__")
|
||||
|
||||
for image_path in image_paths:
|
||||
for image_index, image_path in enumerate(image_paths):
|
||||
__image_prompt__parent = get_dict_at_path(slide.content, image_path)
|
||||
|
||||
if (
|
||||
outline_image_urls
|
||||
and image_index < len(outline_image_urls)
|
||||
and outline_image_urls[image_index]
|
||||
):
|
||||
__image_prompt__parent["__image_url__"] = outline_image_urls[image_index]
|
||||
set_dict_at_path(slide.content, image_path, __image_prompt__parent)
|
||||
continue
|
||||
|
||||
async_tasks.append(
|
||||
image_generation_service.generate_image(
|
||||
ImagePrompt(
|
||||
|
|
@ -30,37 +42,37 @@ async def process_slide_and_fetch_assets(
|
|||
)
|
||||
)
|
||||
)
|
||||
async_task_meta.append(("image", image_path))
|
||||
|
||||
for icon_path in icon_paths:
|
||||
__icon_query__parent = get_dict_at_path(slide.content, icon_path)
|
||||
async_tasks.append(
|
||||
ICON_FINDER_SERVICE.search_icons(__icon_query__parent["__icon_query__"])
|
||||
)
|
||||
async_task_meta.append(("icon", icon_path))
|
||||
|
||||
results = await asyncio.gather(*async_tasks)
|
||||
results.reverse()
|
||||
results = await asyncio.gather(*async_tasks) if async_tasks else []
|
||||
|
||||
return_assets = []
|
||||
for image_path in image_paths:
|
||||
image_dict = get_dict_at_path(slide.content, image_path)
|
||||
result = results.pop()
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.file_url
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, image_path, image_dict)
|
||||
for (task_type, asset_path), result in zip(async_task_meta, results):
|
||||
if task_type == "image":
|
||||
image_dict = get_dict_at_path(slide.content, asset_path)
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.file_url
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, asset_path, image_dict)
|
||||
continue
|
||||
|
||||
for icon_path in icon_paths:
|
||||
icon_dict = get_dict_at_path(slide.content, icon_path)
|
||||
icon_result = results.pop()
|
||||
icon_dict = get_dict_at_path(slide.content, asset_path)
|
||||
# ICON_FINDER_SERVICE.search_icons returns a list of URLs
|
||||
if isinstance(icon_result, list) and icon_result:
|
||||
icon_dict["__icon_url__"] = icon_result[0]
|
||||
if isinstance(result, list) and result:
|
||||
icon_dict["__icon_url__"] = result[0]
|
||||
else:
|
||||
# Fallback to FastAPI static placeholder if no icon found
|
||||
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
|
||||
set_dict_at_path(slide.content, icon_path, icon_dict)
|
||||
set_dict_at_path(slide.content, asset_path, icon_dict)
|
||||
|
||||
return return_assets
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Search } from "lucide-react";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { getStaticFileUrl } from "../utils/others";
|
||||
import { resolveBackendAssetUrl } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
interface IconsEditorProps {
|
||||
icon_prompt?: string[] | null;
|
||||
|
|
@ -147,7 +147,7 @@ const IconsEditor = ({
|
|||
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={iconSrc}
|
||||
src={resolveBackendAssetUrl(iconSrc)}
|
||||
alt={`Icon ${idx + 1}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ const DocumentsPreviewPage: React.FC = () => {
|
|||
progress: true,
|
||||
});
|
||||
|
||||
const selectedLanguage = config?.language ?? "";
|
||||
|
||||
const documentPaths = fileItems.map(
|
||||
(fileItem: FileItem) => fileItem.file_path
|
||||
);
|
||||
|
|
@ -170,9 +172,9 @@ const DocumentsPreviewPage: React.FC = () => {
|
|||
const createResponse = await PresentationGenerationApi.createPresentation(
|
||||
{
|
||||
content: config?.prompt ?? "",
|
||||
n_slides: config?.slides ? parseInt(config.slides) : null,
|
||||
n_slides: config?.slides ? parseInt(config.slides, 10) : null,
|
||||
file_paths: documentPaths,
|
||||
language: config?.language ?? "",
|
||||
language: selectedLanguage,
|
||||
tone: config?.tone,
|
||||
verbosity: config?.verbosity,
|
||||
instructions: config?.instructions || null,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,27 @@ import { setPresentationData } from "@/store/slices/presentationGeneration";
|
|||
import { DashboardApi } from '../../services/api/dashboard';
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom";
|
||||
import { resolveBackendAssetUrl } from "@/utils/api";
|
||||
|
||||
const normalizePresentationAssets = <T,>(input: T): T => {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => normalizePresentationAssets(item)) as T;
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") {
|
||||
normalized[key] = resolveBackendAssetUrl(value);
|
||||
} else {
|
||||
normalized[key] = normalizePresentationAssets(value);
|
||||
}
|
||||
}
|
||||
return normalized as T;
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
export const usePresentationData = (
|
||||
presentationId: string,
|
||||
|
|
@ -16,14 +37,18 @@ export const usePresentationData = (
|
|||
const fetchUserSlides = useCallback(async () => {
|
||||
try {
|
||||
const data = await DashboardApi.getPresentation(presentationId);
|
||||
if (data) {
|
||||
dispatch(setPresentationData(data));
|
||||
const normalizedData = data
|
||||
? normalizePresentationAssets(data)
|
||||
: data;
|
||||
|
||||
if (normalizedData) {
|
||||
dispatch(setPresentationData(normalizedData));
|
||||
dispatch(clearHistory());
|
||||
setLoading(false);
|
||||
}
|
||||
if (data?.theme) {
|
||||
if (normalizedData?.theme) {
|
||||
const el = document.getElementById("presentation-slides-wrapper");
|
||||
applyPresentationThemeToElement(el, data.theme);
|
||||
applyPresentationThemeToElement(el, normalizedData.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,27 @@ import {
|
|||
import { jsonrepair } from "jsonrepair";
|
||||
import { toast } from "sonner";
|
||||
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
||||
import {getFastAPIUrl} from "@/utils/api";
|
||||
import { getFastAPIUrl, resolveBackendAssetUrl } from "@/utils/api";
|
||||
|
||||
const normalizePresentationAssets = <T,>(input: T): T => {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => normalizePresentationAssets(item)) as T;
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") {
|
||||
normalized[key] = resolveBackendAssetUrl(value);
|
||||
} else {
|
||||
normalized[key] = normalizePresentationAssets(value);
|
||||
}
|
||||
}
|
||||
return normalized as T;
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
export const usePresentationStreaming = (
|
||||
presentationId: string,
|
||||
|
|
@ -43,19 +63,20 @@ export const usePresentationStreaming = (
|
|||
try {
|
||||
const repairedJson = jsonrepair(accumulatedChunks);
|
||||
const partialData = JSON.parse(repairedJson);
|
||||
const normalizedPartialData = normalizePresentationAssets(partialData);
|
||||
|
||||
if (partialData.slides) {
|
||||
if (normalizedPartialData.slides) {
|
||||
if (
|
||||
partialData.slides.length !== previousSlidesLength.current &&
|
||||
partialData.slides.length > 0
|
||||
normalizedPartialData.slides.length !== previousSlidesLength.current &&
|
||||
normalizedPartialData.slides.length > 0
|
||||
) {
|
||||
dispatch(
|
||||
setPresentationData({
|
||||
...partialData,
|
||||
slides: partialData.slides,
|
||||
...normalizedPartialData,
|
||||
slides: normalizedPartialData.slides,
|
||||
})
|
||||
);
|
||||
previousSlidesLength.current = partialData.slides.length;
|
||||
previousSlidesLength.current = normalizedPartialData.slides.length;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +87,7 @@ export const usePresentationStreaming = (
|
|||
|
||||
case "complete":
|
||||
try {
|
||||
dispatch(setPresentationData(data.presentation));
|
||||
dispatch(setPresentationData(normalizePresentationAssets(data.presentation)));
|
||||
dispatch(setStreaming(false));
|
||||
setLoading(false);
|
||||
eventSource.close();
|
||||
|
|
@ -83,7 +104,7 @@ export const usePresentationStreaming = (
|
|||
break;
|
||||
|
||||
case "closing":
|
||||
dispatch(setPresentationData(data.presentation));
|
||||
dispatch(setPresentationData(normalizePresentationAssets(data.presentation)));
|
||||
setLoading(false);
|
||||
dispatch(setStreaming(false));
|
||||
eventSource.close();
|
||||
|
|
|
|||
|
|
@ -53,13 +53,13 @@ const SlideCountSelect: React.FC<{
|
|||
value && !SLIDE_OPTIONS.includes(value as SlideOption) ? value : ""
|
||||
);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (value && !SLIDE_OPTIONS.includes(value as SlideOption)) {
|
||||
// setCustomInput(value);
|
||||
// } else {
|
||||
// setCustomInput("");
|
||||
// }
|
||||
// }, [value]);
|
||||
useEffect(() => {
|
||||
if (value && !SLIDE_OPTIONS.includes(value as SlideOption)) {
|
||||
setCustomInput(value);
|
||||
} else {
|
||||
setCustomInput("");
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const sanitizeToPositiveInteger = (raw: string): string => {
|
||||
const digitsOnly = raw.replace(/\D+/g, "");
|
||||
|
|
@ -76,7 +76,7 @@ const SlideCountSelect: React.FC<{
|
|||
}
|
||||
};
|
||||
|
||||
const displayLabel = value ? `${value} slides` : "Select Slides";
|
||||
const displayLabel = value ? `${value} slides` : "Auto slides";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const UploadPage = () => {
|
|||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [config, setConfig] = useState<PresentationConfig>({
|
||||
slides: "5",
|
||||
slides: null,
|
||||
language: LanguageType.English,
|
||||
prompt: "",
|
||||
tone: ToneType.Default,
|
||||
|
|
@ -71,8 +71,8 @@ const UploadPage = () => {
|
|||
* @returns boolean indicating if the configuration is valid
|
||||
*/
|
||||
const validateConfiguration = (): boolean => {
|
||||
if (!config.language || !config.slides) {
|
||||
toast.error("Please select number of Slides & Language");
|
||||
if (!config.language) {
|
||||
toast.error("Please select language");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +122,8 @@ const UploadPage = () => {
|
|||
documents = uploadResponse;
|
||||
}
|
||||
|
||||
const selectedLanguage = config?.language ?? "";
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
if (documents.length > 0) {
|
||||
|
|
@ -129,7 +131,7 @@ const UploadPage = () => {
|
|||
promises.push(
|
||||
PresentationGenerationApi.decomposeDocuments(
|
||||
documents,
|
||||
config?.language ?? null
|
||||
selectedLanguage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -154,13 +156,15 @@ const UploadPage = () => {
|
|||
duration: 30,
|
||||
});
|
||||
|
||||
const selectedLanguage = config?.language ?? "";
|
||||
|
||||
// Use the first available layout group for direct generation
|
||||
trackEvent(MixpanelEvent.Upload_Create_Presentation_API_Call);
|
||||
const createResponse = await PresentationGenerationApi.createPresentation({
|
||||
content: config?.prompt ?? "",
|
||||
n_slides: config?.slides ? parseInt(config.slides) : null,
|
||||
n_slides: config?.slides ? parseInt(config.slides, 10) : null,
|
||||
file_paths: [],
|
||||
language: config?.language ?? "",
|
||||
language: selectedLanguage,
|
||||
tone: config?.tone,
|
||||
verbosity: config?.verbosity,
|
||||
instructions: config?.instructions || null,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export enum ThemeType {
|
|||
|
||||
export enum LanguageType {
|
||||
// Major World Languages
|
||||
// Auto = "Auto",
|
||||
// Auto = "Auto",
|
||||
English = "English",
|
||||
Spanish = "Spanish (Español)",
|
||||
French = "French (Français)",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { getFastAPIUrl } from "@/utils/api";
|
||||
import { resolveBackendAssetUrl } from "@/utils/api";
|
||||
|
||||
export type RemoteSvgOptions = {
|
||||
strokeColor?: string;
|
||||
|
|
@ -145,12 +145,7 @@ export function useRemoteSvgIcon(url?: string, options: RemoteSvgOptions = {}) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
// If URL starts with /static or /app_data, proxy it through FastAPI.
|
||||
let fetchUrl = url;
|
||||
if (url.startsWith('/static/') || url.startsWith('/app_data/')) {
|
||||
const fastApiUrl = getFastAPIUrl();
|
||||
fetchUrl = `${fastApiUrl}${url}`;
|
||||
}
|
||||
const fetchUrl = resolveBackendAssetUrl(url);
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react"
|
||||
import * as z from "zod"
|
||||
import { resolveBackendAssetUrl } from "@/utils/api"
|
||||
|
||||
const layoutId = "Timeline"
|
||||
const layoutName = "Timeline"
|
||||
|
|
@ -148,7 +149,7 @@ const Timeline: React.FC<SlideLayoutProps> = ({ data: slideData }) => {
|
|||
style={{ backgroundColor: 'var(--card-color, #FFFFFF)' }}
|
||||
>
|
||||
<div className="mx-auto w-12 h-12 rounded-full flex items-center justify-center mb-4" style={{ backgroundColor: 'var(--primary-color, #BFF4FF)' }}>
|
||||
<img src={it.icon.__icon_url__} alt={it.icon.__icon_query__} className="w-6 h-6 object-contain" />
|
||||
<img src={resolveBackendAssetUrl(it.icon.__icon_url__)} alt={it.icon.__icon_query__} className="w-6 h-6 object-contain" />
|
||||
</div>
|
||||
<div className="text-[18px] font-semibold" style={{ color: 'var(--background-text, #111827)' }}>{it.heading}</div>
|
||||
<p className="mt-3 text-[14px]" style={{ color: 'var(--background-text, #6B7280)' }}>{it.body}</p>
|
||||
|
|
|
|||
|
|
@ -69,4 +69,43 @@ export function getApiUrl(path: string): string {
|
|||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function hasBackendAssetPrefix(path: string): boolean {
|
||||
return path.startsWith("/static/") || path.startsWith("/app_data/");
|
||||
}
|
||||
|
||||
// Resolve backend-served asset paths to the FastAPI origin in Electron/runtime split-port setups.
|
||||
export function resolveBackendAssetUrl(path?: string): string {
|
||||
if (!path) return "";
|
||||
|
||||
const trimmedPath = path.trim();
|
||||
if (!trimmedPath) return "";
|
||||
|
||||
if (
|
||||
trimmedPath.startsWith("data:") ||
|
||||
trimmedPath.startsWith("blob:") ||
|
||||
trimmedPath.startsWith("file:")
|
||||
) {
|
||||
return trimmedPath;
|
||||
}
|
||||
|
||||
if (isAbsoluteHttpUrl(trimmedPath)) {
|
||||
try {
|
||||
const parsed = new URL(trimmedPath);
|
||||
if (hasBackendAssetPrefix(parsed.pathname)) {
|
||||
return `${getFastAPIUrl()}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
return trimmedPath;
|
||||
} catch {
|
||||
return trimmedPath;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPath = withLeadingSlash(trimmedPath);
|
||||
if (hasBackendAssetPrefix(normalizedPath)) {
|
||||
return `${getFastAPIUrl()}${normalizedPath}`;
|
||||
}
|
||||
|
||||
return trimmedPath;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue