Files
2026-07-13 13:08:41 +08:00

2681 lines
89 KiB
Python

import asyncio
import copy
from datetime import datetime
import json
import logging
import os
import random
import re
import traceback
from typing import Annotated, Any, List, Literal, Optional, Tuple
import dirtyjson
from fastapi import (
APIRouter,
BackgroundTasks,
Body,
Depends,
HTTPException,
Path,
Query,
Request,
)
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, MAX_NUMBER_OF_SLIDES
from enums.webhook_event import WebhookEvent
from models.api_error_model import APIErrorModel
from models.generate_presentation_request import GeneratePresentationRequest
from models.presentation_and_path import PresentationPathAndEditPath
from models.presentation_from_template import EditPresentationRequest
from models.presentation_outline_model import (
PresentationOutlineModel,
SlideOutlineModel,
)
from enums.tone import Tone
from enums.verbosity import Verbosity
from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import (
PresentationDetailWithSlides,
PresentationWithSlides,
)
from services.documents_loader import DocumentsLoader
from services.temp_file_service import TEMP_FILE_SERVICE
from services.webhook_service import WebhookService
from services.image_generation_service import ImageGenerationService
from services.mem0_presentation_memory_service import (
MEM0_PRESENTATION_MEMORY_SERVICE,
)
from utils.dict_utils import deep_update
from utils.export_utils import export_presentation
from utils.llm_calls.generate_presentation_outlines import (
generate_ppt_outline,
get_messages as get_outline_messages,
)
from models.sql.slide import SlideModel
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session
from services.database import async_session_maker
from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel, PresentationVersion
from models.sql.template_v2 import TemplateV2
from models.sql.async_task import AsyncTaskModel
from utils.asset_directory_utils import get_images_directory
from utils.llm_calls.generate_presentation_structure import (
generate_presentation_structure,
)
from utils.llm_calls.generate_slide_content import (
get_slide_content_from_type_and_outline,
)
from utils.ppt_utils import (
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.outline_limits import normalize_outline_payload
from utils.process_slides import (
process_slide_add_placeholder_assets,
process_slide_and_fetch_assets,
)
from utils.get_layout_by_name import get_layout_by_name
from utils.llm_utils import message_content_to_text
from utils.sse import safe_sse_stream
from utils.simple_auth import (
SESSION_COOKIE_NAME,
create_session_token,
get_session_token_from_request,
)
from utils.web_search import get_selected_web_search_provider, get_web_search_route
from models.presentation_layout import PresentationLayoutModel, SlideLayoutModel
from templates.v2.schema import get_template_schema
import uuid
logger = logging.getLogger(__name__)
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
ASYNC_TASK_TYPE_PRESENTATION_GENERATE = "presentation.generate"
def _presentation_task_progress_data(
created_slides: int,
remaining_slides: int,
) -> dict[str, int]:
return {
"created_slides": max(created_slides, 0),
"remaining_slides": max(remaining_slides, 0),
}
def _requested_slide_count(request: GeneratePresentationRequest) -> int:
if request.slides_markdown:
return len(request.slides_markdown)
return request.n_slides or 0
def _extract_custom_template_id(layout_name: Optional[str]) -> Optional[uuid.UUID]:
if not layout_name or not layout_name.startswith("custom-"):
return None
try:
return uuid.UUID(layout_name.replace("custom-", ""))
except Exception:
return None
def _extract_template_v2_id(layout_name: Optional[str]) -> Optional[str]:
if not isinstance(layout_name, str):
return None
layout_name = layout_name.strip()
if not layout_name:
return None
for prefix in ("template-v2-", "template-v2:"):
if layout_name.startswith(prefix):
candidate = layout_name[len(prefix) :].strip()
return candidate or None
return None
def _extract_requested_template_v2_id(
template_name: Optional[str],
*,
allow_bare: bool = False,
) -> Optional[str]:
if not isinstance(template_name, str):
return None
value = template_name.strip()
if not value:
return None
for prefix in ("template-v2-", "template-v2:", "custom-"):
if not value.startswith(prefix):
continue
candidate = value[len(prefix) :]
return candidate.strip() or None
return value if allow_bare else None
def _extract_template_v2_metadata_id(key: str, value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
prefixed = _extract_template_v2_id(value)
if prefixed:
return prefixed
if key in {"template_id", "template_v2_id"}:
candidate = value.strip()
return candidate or None
return None
async def _resolve_requested_template_v2(
template_name: str,
sql_session: AsyncSession,
*,
allow_bare: bool = False,
) -> Optional[TemplateV2]:
template_id = _extract_requested_template_v2_id(
template_name,
allow_bare=allow_bare,
)
if not template_id:
return None
template = await sql_session.get(TemplateV2, template_id)
if template:
return template
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
def _copy_template_v2_layout_payload(
template: TemplateV2,
) -> dict[str, Any]:
layout_payload = copy.deepcopy(template.layouts)
if not isinstance(layout_payload, dict):
raise HTTPException(
status_code=400,
detail="Template v2 layout JSON must be an object",
)
return layout_payload
async def _resolve_generation_layout(
template_name: str,
sql_session: AsyncSession,
) -> tuple[dict[str, Any], PresentationLayoutModel, Optional[dict[str, str]], bool]:
template_v2 = await _resolve_requested_template_v2(template_name, sql_session)
if template_v2:
layout_payload = _copy_template_v2_layout_payload(template_v2)
return (
layout_payload,
_build_template_v2_structure_layout(template_v2, layout_payload),
_extract_template_v2_fonts_from_assets(template_v2.assets),
True,
)
if template_name not in DEFAULT_TEMPLATES:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
layout_model = await get_layout_by_name(template_name)
return layout_model.model_dump(mode="json"), layout_model, None, False
def _is_template_v2_slide(slide: SlideModel) -> bool:
return slide.layout_group.startswith("template-v2") or slide.layout.startswith(
"template-v2"
)
def _hydrate_template_v2_slide_ui(
slide: SlideModel,
layout_payload: Any = None,
) -> None:
if not _is_template_v2_slide(slide):
return
ui = slide.ui
if not isinstance(ui, dict):
ui = _template_v2_slide_ui(layout_payload, slide.layout)
slide.ui = _apply_template_v2_content_to_ui(ui, slide.content)
def _canonical_template_v2_layout_payload(layout_payload: Any) -> Optional[str]:
if not _is_template_v2_layout_payload(layout_payload):
return None
comparable_payload = copy.deepcopy(layout_payload)
if isinstance(comparable_payload, dict):
for metadata_key in ("name", "template_id", "template_v2_id"):
comparable_payload.pop(metadata_key, None)
try:
return json.dumps(
comparable_payload,
sort_keys=True,
separators=(",", ":"),
)
except (TypeError, ValueError):
return None
def _coerce_presentation_font_map(value: Any) -> Optional[dict[str, str]]:
if not isinstance(value, dict):
return None
fonts = {
name.strip(): url.strip()
for name, url in value.items()
if isinstance(name, str)
and isinstance(url, str)
and name.strip()
and url.strip()
}
return fonts or None
def _extract_template_v2_fonts_from_assets(assets: Any) -> Optional[dict[str, str]]:
if not isinstance(assets, dict):
return None
return _coerce_presentation_font_map(assets.get("fonts"))
async def _resolve_presentation_template_v2_fonts(
presentation: PresentationModel,
slides: List[SlideModel],
sql_session: AsyncSession,
):
candidate_template_ids: List[str] = []
seen: set[str] = set()
if isinstance(presentation.layout, dict):
for key in ("name", "template_id", "template_v2_id"):
value = presentation.layout.get(key)
template_id = _extract_template_v2_metadata_id(key, value)
if template_id and template_id not in seen:
candidate_template_ids.append(template_id)
seen.add(template_id)
for slide in slides:
for value in (slide.layout_group, slide.layout):
template_id = _extract_template_v2_id(value)
if template_id and template_id not in seen:
candidate_template_ids.append(template_id)
seen.add(template_id)
for template_id in candidate_template_ids:
template = await sql_session.get(TemplateV2, template_id)
if template:
fonts = _extract_template_v2_fonts_from_assets(template.assets)
if fonts is not None:
return fonts
target_layout_payload = _canonical_template_v2_layout_payload(presentation.layout)
if not target_layout_payload:
return None
try:
result = await sql_session.execute(
select(TemplateV2.id, TemplateV2.layouts, TemplateV2.assets)
)
for _template_id, layouts, assets in result.all():
if _canonical_template_v2_layout_payload(layouts) != target_layout_payload:
continue
fonts = _extract_template_v2_fonts_from_assets(assets)
if fonts is not None:
return fonts
except Exception:
logger.exception("[presentation.detail] failed to resolve template v2 fonts")
return None
async def _resolve_presentation_fonts(
presentation: PresentationModel,
slides: List[SlideModel],
sql_session: AsyncSession,
):
stored_fonts = _coerce_presentation_font_map(getattr(presentation, "fonts", None))
if stored_fonts is not None:
return stored_fonts
candidate_template_ids: List[uuid.UUID] = []
seen: set[uuid.UUID] = set()
layout_name = None
if isinstance(presentation.layout, dict):
layout_name = presentation.layout.get("name")
layout_template_id = _extract_custom_template_id(layout_name)
if layout_template_id and layout_template_id not in seen:
candidate_template_ids.append(layout_template_id)
seen.add(layout_template_id)
for slide in slides:
template_id = _extract_custom_template_id(slide.layout_group)
if template_id and template_id not in seen:
candidate_template_ids.append(template_id)
seen.add(template_id)
for template_id in candidate_template_ids:
result = await sql_session.execute(
select(PresentationLayoutCodeModel.fonts).where(
PresentationLayoutCodeModel.presentation == template_id
)
)
fonts_list = result.scalars().all()
for fonts in fonts_list:
if fonts is not None:
return fonts
return await _resolve_presentation_template_v2_fonts(
presentation,
slides,
sql_session,
)
def _presentation_response_data(presentation: PresentationModel) -> dict:
return presentation.model_dump(exclude={"fonts"})
async def _resolve_presentation_template_v2_payload(
presentation: PresentationModel,
slides: List[SlideModel],
sql_session: AsyncSession,
payload_field: Literal["components", "merged_components"],
):
candidate_template_ids: List[str] = []
seen: set[str] = set()
if isinstance(presentation.layout, dict):
for key in ("name", "template_id", "template_v2_id"):
value = presentation.layout.get(key)
template_id = _extract_template_v2_metadata_id(key, value)
if template_id and template_id not in seen:
candidate_template_ids.append(template_id)
seen.add(template_id)
for slide in slides:
for value in (slide.layout_group, slide.layout):
template_id = _extract_template_v2_id(value)
if template_id and template_id not in seen:
candidate_template_ids.append(template_id)
seen.add(template_id)
for template_id in candidate_template_ids:
template = await sql_session.get(TemplateV2, template_id)
if template:
payload = getattr(template, payload_field, None)
if payload is not None:
return payload
target_layout_payload = _canonical_template_v2_layout_payload(presentation.layout)
if not target_layout_payload:
return None
try:
payload_column = getattr(TemplateV2, payload_field)
result = await sql_session.execute(
select(TemplateV2.id, TemplateV2.layouts, payload_column)
)
for _template_id, layouts, payload in result.all():
if payload is None:
continue
if _canonical_template_v2_layout_payload(layouts) == target_layout_payload:
return payload
except Exception:
logger.exception(
"[presentation.detail] failed to resolve template v2 %s",
payload_field,
)
return None
async def _resolve_presentation_merged_components(
presentation: PresentationModel,
slides: List[SlideModel],
sql_session: AsyncSession,
):
return await _resolve_presentation_template_v2_payload(
presentation,
slides,
sql_session,
"merged_components",
)
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)
def _layout_count(layout_payload: Any) -> int:
if isinstance(layout_payload, dict):
layouts = layout_payload.get("layouts", layout_payload.get("slides"))
return len(layouts) if isinstance(layouts, list) else 0
if isinstance(layout_payload, list):
return len(layout_payload)
return 0
def _build_template_v2_layout_model(
layout_payload: dict[str, Any],
*,
layout_name: str,
) -> PresentationLayoutModel:
try:
template_schema = get_template_schema(layout_payload)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"Invalid template v2 layout JSON: {exc}",
) from exc
source_layouts = layout_payload.get("layouts")
if not isinstance(source_layouts, list):
source_layouts = []
slides: list[SlideLayoutModel] = []
for index, schema_layout in enumerate(template_schema["layouts"]):
if not isinstance(schema_layout, dict):
continue
source_layout = (
source_layouts[index]
if index < len(source_layouts) and isinstance(source_layouts[index], dict)
else {}
)
layout_id = (
schema_layout.get("layout_id")
or source_layout.get("id")
or f"layout_{index + 1}"
)
layout_schema = schema_layout.get("schema")
if not isinstance(layout_schema, dict):
layout_schema = {
"title": str(layout_id),
"description": source_layout.get("description"),
}
slides.append(
SlideLayoutModel(
id=str(layout_id),
name=source_layout.get("name") or layout_schema.get("title"),
description=source_layout.get("description")
or layout_schema.get("description"),
json_schema=layout_schema,
)
)
if not slides:
raise HTTPException(
status_code=400,
detail="Template v2 layout JSON must contain at least one layout",
)
return PresentationLayoutModel(
name=layout_name,
ordered=False,
slides=slides,
)
def _build_template_v2_structure_layout(
template: TemplateV2,
layout_payload: dict[str, Any],
) -> PresentationLayoutModel:
return _build_template_v2_layout_model(
layout_payload,
layout_name=f"template-v2-{template.id}",
)
def _is_template_v2_layout_payload(layout_payload: Any) -> bool:
return (
isinstance(layout_payload, dict)
and isinstance(layout_payload.get("layouts"), list)
)
def _template_v2_slide_ui(
layout_payload: Any,
layout_id: str,
) -> Optional[dict[str, Any]]:
if not _is_template_v2_layout_payload(layout_payload):
return None
for layout in layout_payload["layouts"]:
if isinstance(layout, dict) and str(layout.get("id")) == str(layout_id):
return copy.deepcopy(layout)
return None
GENERATED_VALUE_ELEMENT_TYPES = {"text", "image", "text-list", "table", "chart"}
GENERATED_TABLE_TEXT_FONT = {
"family": "Sniglet",
"size": 12,
"color": "#082314",
}
GENERATED_TABLE_HEADER_FONT = {
**GENERATED_TABLE_TEXT_FONT,
"bold": True,
}
GENERATED_TABLE_CELL_FILL = {
"color": "#F8F4E9",
"opacity": 1,
}
GENERATED_TABLE_CELL_STROKE = {
"color": "#D8D3C4",
"opacity": 1,
"width": 1,
}
TEMPLATE_V2_STRONG_MARKDOWN_DELIMITERS = ("**", "__")
TEMPLATE_V2_EMPHASIS_MARKDOWN_DELIMITERS = ("*", "_")
TEMPLATE_V2_MARKDOWN_DELIMITERS = (
*TEMPLATE_V2_STRONG_MARKDOWN_DELIMITERS,
*TEMPLATE_V2_EMPHASIS_MARKDOWN_DELIMITERS,
)
def _template_v2_component_content_keys(components: list[Any]) -> list[str]:
ids: list[str] = []
for index, component in enumerate(components):
component_id = (
component.get("id")
if isinstance(component, dict) and isinstance(component.get("id"), str)
else None
)
ids.append(component_id or f"component_{index}")
counts: dict[str, int] = {}
for component_id in ids:
counts[component_id] = counts.get(component_id, 0) + 1
indexes: dict[str, int] = {}
used: set[str] = set()
keys: list[str] = []
for component_id in ids:
occurrence_index = indexes.get(component_id, 0)
indexes[component_id] = occurrence_index + 1
base = (
f"{component_id}_{occurrence_index}"
if counts.get(component_id, 0) > 1
else component_id
)
key = base
suffix = 1
while key in used:
key = f"{base}_{suffix}"
suffix += 1
used.add(key)
keys.append(key)
return keys
def _apply_template_v2_content_to_ui(
ui: Optional[dict[str, Any]],
content: dict[str, Any],
) -> Optional[dict[str, Any]]:
if not isinstance(ui, dict):
return ui
components = ui.get("components")
if not isinstance(components, list) or not components:
return ui
component_keys = _template_v2_component_content_keys(components)
hydrated_ui = copy.deepcopy(ui)
hydrated_components = hydrated_ui.get("components")
if not isinstance(hydrated_components, list):
return hydrated_ui
for index, component in enumerate(hydrated_components):
if not isinstance(component, dict):
continue
component_id = component.get("id")
component_content = content.get(component_keys[index])
if not isinstance(component_content, dict) and isinstance(component_id, str):
component_content = content.get(component_id)
if not isinstance(component_content, dict):
component_content = {}
elements = component.get("elements")
if isinstance(elements, list):
component["elements"] = [
_apply_template_v2_content_to_element(element, component_content)
for element in elements
]
return hydrated_ui
def _apply_template_v2_content_to_element(
element: Any,
content: Any,
*,
direct_value: bool = False,
) -> Any:
if not isinstance(element, dict):
return element
content_values = content if isinstance(content, dict) else {}
element_type = element.get("type")
name = element.get("name") if isinstance(element.get("name"), str) else None
has_value = False
value = None
if name:
has_value, value = _template_v2_content_value(content_values, name)
if (
element.get("decorative") is False
and name
and has_value
and element_type in GENERATED_VALUE_ELEMENT_TYPES
):
return _apply_template_v2_content_value(element, value)
# Repeated flex/grid schemas omit the child-name wrapper when an item is a
# direct generated value. For example, three image children are emitted as
# [{"image_prompt": ..., "image_url": ...}, ...], not as
# [{"gallery_photo": {...}}, ...]. In that case the array item itself is
# the value for the child element.
if (
direct_value
and not has_value
and element.get("decorative") is False
and element_type in GENERATED_VALUE_ELEMENT_TYPES
):
return _apply_template_v2_content_value(element, content)
nested_content = value if isinstance(value, dict) else content_values
nested_direct_value = direct_value and not has_value
if element_type == "container":
updated = copy.deepcopy(element)
updated["child"] = _apply_template_v2_content_to_element(
element.get("child"),
nested_content,
direct_value=nested_direct_value,
)
return updated
if element_type in {"flex", "grid", "group"}:
updated = copy.deepcopy(element)
children = element.get("children")
if not isinstance(children, list):
children = []
updated["children"] = _apply_template_v2_content_to_children(
children,
value,
nested_content,
direct_value=nested_direct_value,
)
return updated
return copy.deepcopy(element)
def _template_v2_content_value(
content: dict[str, Any],
name: str,
) -> tuple[bool, Any]:
for candidate in _template_v2_content_name_candidates(name):
if candidate in content:
return True, content[candidate]
return False, None
def _template_v2_content_name_candidates(name: str) -> list[str]:
without_numeric_token = re.sub(r"_\d+(?=_|$)", "", name)
without_prefix = (
without_numeric_token.split("_", 1)[1]
if "_" in without_numeric_token
else without_numeric_token
)
candidates = []
for candidate in (name, without_numeric_token, without_prefix):
if candidate and candidate not in candidates:
candidates.append(candidate)
return candidates
def _apply_template_v2_content_to_children(
children: list[Any],
value: Any,
content: Any,
*,
direct_value: bool = False,
) -> list[Any]:
if isinstance(value, list) and children:
return [
_apply_template_v2_content_to_element(
children[min(index, len(children) - 1)],
item,
direct_value=True,
)
for index, item in enumerate(value)
]
return [
_apply_template_v2_content_to_element(
child,
content,
direct_value=direct_value,
)
for child in children
]
def _apply_template_v2_content_value(element: dict[str, Any], value: Any) -> dict[str, Any]:
element_type = element.get("type")
if element_type == "text":
return _apply_template_v2_text_content(element, value)
if element_type == "image":
return _apply_template_v2_image_content(element, value)
if element_type == "text-list":
return _apply_template_v2_text_list_content(element, value)
if element_type == "table":
return _apply_template_v2_table_content(element, value)
if element_type == "chart":
return _apply_template_v2_chart_content(element, value)
return copy.deepcopy(element)
def _read_template_v2_text(value: Any) -> Optional[str]:
if isinstance(value, str):
return value
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
if isinstance(value, dict):
text = value.get("text")
if isinstance(text, str):
return text
if isinstance(text, (int, float)) and not isinstance(text, bool):
return str(text)
return None
def _apply_template_v2_text_content(
element: dict[str, Any],
value: Any,
) -> dict[str, Any]:
text = _read_template_v2_text(value)
if text is None or text == "":
return copy.deepcopy(element)
updated = copy.deepcopy(element)
first_run = _first_template_v2_text_run(element.get("runs"))
updated["runs"] = _template_v2_text_runs_from_markdown(
text,
first_run,
fallback_font=element.get("font"),
)
updated.pop("text", None)
return updated
def _apply_template_v2_image_content(
element: dict[str, Any],
value: Any,
) -> dict[str, Any]:
if not isinstance(value, dict):
return copy.deepcopy(element)
url = None
for key in ("image_url", "icon_url", "__image_url__", "__icon_url__", "url"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate:
url = candidate
break
if not url:
return copy.deepcopy(element)
updated = copy.deepcopy(element)
updated["data"] = url
prompt = _template_v2_asset_prompt(value, element.get("is_icon") is True)
if prompt:
updated["prompt"] = prompt
return updated
def _template_v2_asset_prompt(value: Any, is_icon: bool) -> Optional[str]:
if not isinstance(value, dict):
return None
prompt_keys = (
("icon_query", "__icon_query__", "query", "prompt")
if is_icon
else ("image_prompt", "__image_prompt__", "prompt", "query")
)
for key in prompt_keys:
prompt = value.get(key)
if isinstance(prompt, str) and prompt.strip():
return prompt
return None
def _apply_template_v2_text_list_content(
element: dict[str, Any],
value: Any,
) -> dict[str, Any]:
if not isinstance(value, list):
return copy.deepcopy(element)
existing_items = element.get("items")
if not isinstance(existing_items, list):
existing_items = []
items = []
for index, item in enumerate(value):
text = _read_template_v2_text(item)
if text is not None and text != "":
existing_runs = (
existing_items[index]
if index < len(existing_items) and isinstance(existing_items[index], list)
else None
)
first_run = (
existing_runs[0]
if isinstance(existing_runs, list)
and existing_runs
and isinstance(existing_runs[0], dict)
else {}
)
items.append(
_template_v2_text_runs_from_markdown(
text,
first_run,
fallback_font=element.get("font"),
)
)
updated = copy.deepcopy(element)
updated["items"] = items
return updated
def _apply_template_v2_table_content(
element: dict[str, Any],
value: Any,
) -> dict[str, Any]:
if not isinstance(value, dict):
return copy.deepcopy(element)
template_columns = element.get("columns")
if not isinstance(template_columns, list):
template_columns = []
template_rows = [
row
for row in element.get("rows", [])
if isinstance(row, list)
]
generated_columns = [
_read_template_v2_table_text(item)
for item in value.get("columns", [])
] if isinstance(value.get("columns"), list) else []
generated_rows = [
[_read_template_v2_table_text(cell) for cell in row]
for row in value.get("rows", [])
if isinstance(row, list)
] if isinstance(value.get("rows"), list) else []
fallback_row = template_rows[-1] if template_rows else template_columns
updated = copy.deepcopy(element)
updated["columns"] = (
_merge_template_v2_table_row_to_length(
template_columns,
generated_columns,
is_header=True,
)
if generated_columns
else copy.deepcopy(template_columns)
)
updated["rows"] = (
[
_merge_template_v2_table_row_to_length(
template_rows[index] if index < len(template_rows) else fallback_row,
row,
is_header=False,
)
for index, row in enumerate(generated_rows)
]
if generated_rows
else copy.deepcopy(template_rows)
)
return updated
def _merge_template_v2_table_row_to_length(
template_cells: list[Any],
generated_texts: list[Optional[str]],
*,
is_header: bool,
) -> list[Any]:
fallback_cell = template_cells[-1] if template_cells else None
return [
_replace_template_v2_table_cell_text(
template_cells[index] if index < len(template_cells) else fallback_cell,
text or "",
is_header=is_header,
)
for index, text in enumerate(generated_texts)
]
def _replace_template_v2_table_cell_text(
cell: Any,
text: str,
*,
is_header: bool,
) -> dict[str, Any]:
font = GENERATED_TABLE_HEADER_FONT if is_header else GENERATED_TABLE_TEXT_FONT
if not isinstance(cell, dict):
return {
"color": GENERATED_TABLE_CELL_FILL,
"stroke": GENERATED_TABLE_CELL_STROKE,
"font": font,
"runs": _template_v2_text_runs_from_markdown(
text,
{"font": font},
),
}
updated = copy.deepcopy(cell)
first_run = _first_template_v2_text_run(cell.get("runs"))
run_font = first_run.get("font") if isinstance(first_run.get("font"), dict) else None
next_font = run_font or cell.get("font") or font
updated["color"] = cell.get("color") or cell.get("fill") or GENERATED_TABLE_CELL_FILL
updated["stroke"] = cell.get("stroke") or GENERATED_TABLE_CELL_STROKE
updated["font"] = cell.get("font") or next_font
updated["runs"] = _template_v2_text_runs_from_markdown(
text,
first_run,
fallback_font=next_font,
)
updated.pop("text", None)
updated.pop("fill", None)
return updated
def _first_template_v2_text_run(runs: Any) -> dict[str, Any]:
if isinstance(runs, list) and runs and isinstance(runs[0], dict):
return runs[0]
return {}
def _template_v2_text_runs_from_markdown(
text: str,
first_run: Any,
*,
fallback_font: Any = None,
) -> list[dict[str, Any]]:
base_run = copy.deepcopy(first_run) if isinstance(first_run, dict) else {}
parsed = _parse_template_v2_markdown_text(text)
has_markdown_style = any(style for _parsed_text, style in parsed)
base_run = _template_v2_base_run_for_markdown(
base_run,
fallback_font,
strip_inline_emphasis=has_markdown_style,
)
text_runs: list[dict[str, Any]] = []
for parsed_text, style in parsed:
run = copy.deepcopy(base_run)
run["text"] = parsed_text
if style:
font = run.get("font")
run["font"] = {
**(copy.deepcopy(font) if isinstance(font, dict) else {}),
**style,
}
_append_template_v2_text_run(text_runs, run)
if text_runs:
return text_runs
return [{**base_run, "text": " "}]
def _template_v2_base_run_for_markdown(
base_run: dict[str, Any],
fallback_font: Any,
*,
strip_inline_emphasis: bool,
) -> dict[str, Any]:
font = base_run.get("font")
if isinstance(fallback_font, dict):
merged_font = {
**copy.deepcopy(fallback_font),
**(copy.deepcopy(font) if isinstance(font, dict) else {}),
}
base_run["font"] = merged_font
elif isinstance(font, dict):
base_run["font"] = copy.deepcopy(font)
if strip_inline_emphasis and isinstance(base_run.get("font"), dict):
base_run["font"].pop("bold", None)
base_run["font"].pop("italic", None)
return base_run
def _parse_template_v2_markdown_text(
text: str,
) -> list[tuple[str, dict[str, bool]]]:
parsed: list[tuple[str, dict[str, bool]]] = []
index = 0
while index < len(text):
strong_delimiter = _template_v2_read_markdown_delimiter(
text,
index,
TEMPLATE_V2_STRONG_MARKDOWN_DELIMITERS,
)
if strong_delimiter:
close = text.find(strong_delimiter, index + len(strong_delimiter))
if close > index + len(strong_delimiter):
parsed.append(
(
text[index + len(strong_delimiter) : close],
{"bold": True},
)
)
index = close + len(strong_delimiter)
continue
emphasis_delimiter = _template_v2_read_markdown_delimiter(
text,
index,
TEMPLATE_V2_EMPHASIS_MARKDOWN_DELIMITERS,
)
if emphasis_delimiter:
close = text.find(emphasis_delimiter, index + len(emphasis_delimiter))
if close > index + len(emphasis_delimiter):
parsed.append(
(
text[index + len(emphasis_delimiter) : close],
{"italic": True},
)
)
index = close + len(emphasis_delimiter)
continue
next_index = _template_v2_next_markdown_delimiter_index(text, index + 1)
parsed.append(
(
text[index : len(text) if next_index == -1 else next_index],
{},
)
)
index = len(text) if next_index == -1 else next_index
return parsed
def _template_v2_read_markdown_delimiter(
text: str,
index: int,
delimiters: tuple[str, ...],
) -> Optional[str]:
for delimiter in delimiters:
if text.startswith(delimiter, index):
return delimiter
return None
def _template_v2_next_markdown_delimiter_index(text: str, start: int) -> int:
indexes = [
index
for index in (
text.find(delimiter, start)
for delimiter in TEMPLATE_V2_MARKDOWN_DELIMITERS
)
if index != -1
]
return min(indexes) if indexes else -1
def _append_template_v2_text_run(
text_runs: list[dict[str, Any]],
run: dict[str, Any],
) -> None:
text = run.get("text")
if not isinstance(text, str) or text == "":
return
previous = text_runs[-1] if text_runs else None
if isinstance(previous, dict):
previous_style = {key: value for key, value in previous.items() if key != "text"}
next_style = {key: value for key, value in run.items() if key != "text"}
if previous_style == next_style and isinstance(previous.get("text"), str):
previous["text"] += text
return
text_runs.append(run)
def _read_template_v2_table_text(value: Any) -> Optional[str]:
primitive_text = _read_template_v2_primitive_table_text(value)
if primitive_text is not None:
return primitive_text[:80]
if not isinstance(value, dict):
return None
runs = value.get("runs")
if isinstance(runs, list):
run_text = "".join(
run.get("text", "")
for run in runs
if isinstance(run, dict) and isinstance(run.get("text"), str)
)
if run_text:
return run_text[:80]
for key in ("text", "value"):
text = _read_template_v2_primitive_table_text(value.get(key))
if text is not None:
return text[:80]
return None
def _read_template_v2_primitive_table_text(value: Any) -> Optional[str]:
if isinstance(value, str):
return value
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, (int, float)):
return str(value)
return None
def _read_template_v2_data_labels(value: Any) -> Optional[str]:
if value is True:
return "top"
if value is False or value is None:
return None
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"base", "mid", "top", "outside"}:
return normalized
return None
def _apply_template_v2_chart_content(
element: dict[str, Any],
value: Any,
) -> dict[str, Any]:
updated = copy.deepcopy(element)
updated.pop("data_labels_color", None)
updated.pop("grid", None)
if not isinstance(value, dict):
return updated
chart_type = value.get("chartType", value.get("chart_type"))
if chart_type in {
"area",
"bar",
"bubble",
"donut",
"horizontal_bar",
"horizontal_stacked_bar",
"line",
"pie",
"polar_area",
"radar",
"scatter",
"stacked_bar",
}:
updated["chart_type"] = chart_type
if isinstance(value.get("title"), str):
updated["title"] = value["title"]
if isinstance(value.get("categories"), list) and value["categories"]:
updated["categories"] = value["categories"]
if isinstance(value.get("series"), list) and value["series"]:
updated["series"] = value["series"]
colors = value.get("colors")
if isinstance(colors, list) and colors:
updated["colors"] = colors
for source_key, target_key in (
("axisColor", "axis_color"),
("axis_color", "axis_color"),
("gridColor", "grid_color"),
("grid_color", "grid_color"),
("xAxisTitle", "x_axis_title"),
("x_axis_title", "x_axis_title"),
("yAxisTitle", "y_axis_title"),
("y_axis_title", "y_axis_title"),
("source", "source"),
):
if isinstance(value.get(source_key), str):
updated[target_key] = value[source_key]
for source_key, target_key in (
("xAxis", "x_axis"),
("x_axis", "x_axis"),
("yAxis", "y_axis"),
("y_axis", "y_axis"),
("xAxisGrid", "x_axis_grid"),
("x_axis_grid", "x_axis_grid"),
("yAxisGrid", "y_axis_grid"),
("y_axis_grid", "y_axis_grid"),
):
if isinstance(value.get(source_key), bool):
updated[target_key] = value[source_key]
for source_key in ("dataLabels", "data_labels"):
if source_key in value:
updated["data_labels"] = _read_template_v2_data_labels(value.get(source_key))
return updated
def _get_presentation_stream_layout(
presentation: PresentationModel,
) -> PresentationLayoutModel:
if _is_template_v2_layout_payload(presentation.layout):
layout_name = str(presentation.layout.get("name") or "template-v2")
return _build_template_v2_layout_model(
presentation.layout,
layout_name=layout_name,
)
return presentation.get_layout()
async def _resolve_prepare_layout(
layout: PresentationLayoutModel | str,
sql_session: AsyncSession,
) -> tuple[dict[str, Any], PresentationLayoutModel, Optional[dict[str, str]]]:
if isinstance(layout, PresentationLayoutModel):
return layout.model_dump(mode="json"), layout, None
template_id = layout.strip()
if not template_id:
raise HTTPException(
status_code=400,
detail="Template v2 layout id is required",
)
template = await sql_session.get(TemplateV2, template_id)
if not template:
raise HTTPException(status_code=404, detail="Template v2 layout not found")
layout_payload = copy.deepcopy(template.layouts)
if not isinstance(layout_payload, dict):
raise HTTPException(
status_code=400,
detail="Template v2 layout JSON must be an object",
)
structure_layout = _build_template_v2_structure_layout(template, layout_payload)
return (
layout_payload,
structure_layout,
_extract_template_v2_fonts_from_assets(template.assets),
)
def _build_export_cookie_header(request: Request) -> Optional[str]:
cookie_header = (request.headers.get("cookie") or "").strip()
if cookie_header:
return cookie_header
session_token = get_session_token_from_request(request)
if session_token:
return f"{SESSION_COOKIE_NAME}={session_token}"
username = getattr(request.state, "auth_username", None)
if isinstance(username, str) and username.strip():
try:
session_token = create_session_token(username.strip())
return f"{SESSION_COOKIE_NAME}={session_token}"
except Exception:
logger.exception(
"[presentation.generate] failed to create export session token"
)
return None
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(
version: Annotated[
Optional[PresentationVersion],
Query(description="Only include presentations matching this version."),
] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
query = (
select(PresentationModel, SlideModel)
.join(
SlideModel,
(SlideModel.presentation == PresentationModel.id) & (SlideModel.index == 0),
)
)
if version is not None:
query = query.where(PresentationModel.version == version)
query = query.order_by(PresentationModel.created_at.desc())
results = await sql_session.execute(query)
rows = results.all()
presentations_with_slides = []
for presentation, first_slide in rows:
slides = [first_slide]
fonts = await _resolve_presentation_fonts(presentation, slides, sql_session)
presentations_with_slides.append(
PresentationWithSlides(
**_presentation_response_data(presentation),
slides=slides,
fonts=fonts,
)
)
return presentations_with_slides
@PRESENTATION_ROUTER.get("/{id}", response_model=PresentationDetailWithSlides)
async def get_presentation(
id: uuid.UUID,
request: Request,
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(404, "Presentation not found")
slides_result = await sql_session.scalars(
select(SlideModel)
.where(SlideModel.presentation == id)
.order_by(SlideModel.index)
)
slides = list(slides_result)
fonts = await _resolve_presentation_fonts(presentation, slides, sql_session)
merged_components = await _resolve_presentation_merged_components(
presentation,
slides,
sql_session,
)
return PresentationDetailWithSlides(
**_presentation_response_data(presentation),
slides=slides,
fonts=fonts,
merged_components=merged_components,
)
@PRESENTATION_ROUTER.delete("/{id}", status_code=204)
async def delete_presentation(
id: uuid.UUID, 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.delete(presentation)
await sql_session.commit()
@PRESENTATION_ROUTER.post("/{id}/duplicate", response_model=PresentationWithSlides)
async def duplicate_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(404, "Presentation not found")
slides = list(
await sql_session.scalars(
select(SlideModel)
.where(SlideModel.presentation == id)
.order_by(SlideModel.index)
)
)
new_presentation = presentation.get_new_presentation()
if new_presentation.title:
new_presentation.title = f"{new_presentation.title} (Copy)"
new_slides = [slide.get_new_slide(new_presentation.id) for slide in slides]
sql_session.add(new_presentation)
sql_session.add_all(new_slides)
await sql_session.commit()
await sql_session.refresh(new_presentation)
return PresentationWithSlides(
**_presentation_response_data(new_presentation),
slides=new_slides,
fonts=await _resolve_presentation_fonts(
new_presentation,
new_slides,
sql_session,
),
)
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
async def create_presentation(
content: 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,
instructions: Annotated[Optional[str], Body()] = None,
include_table_of_contents: Annotated[bool, Body()] = False,
include_title_slide: Annotated[bool, Body()] = True,
web_search: Annotated[bool, Body()] = False,
sql_session: AsyncSession = Depends(get_async_session),
):
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()
validated_file_paths = (
TEMP_FILE_SERVICE.resolve_existing_temp_paths(file_paths)
if file_paths
else None
)
# 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,
version=PresentationVersion.V2_STANDARD,
content=content,
n_slides=n_slides_to_store,
language=language_to_store,
file_paths=validated_file_paths,
tone=tone.value,
verbosity=verbosity.value,
instructions=instructions,
include_table_of_contents=include_table_of_contents,
include_title_slide=include_title_slide,
web_search=web_search,
)
sql_session.add(presentation)
await sql_session.commit()
search_route, actual_search_provider = get_web_search_route()
logger.info(
"Created presentation: id=%s web_search_enabled=%s selected_web_search_provider=%s "
"web_search_route=%s actual_web_search_provider=%s",
presentation_id,
web_search,
get_selected_web_search_provider().value,
search_route,
(
actual_search_provider.value
if actual_search_provider
else ("model-native" if search_route == "native" else "none")
),
)
return presentation
@PRESENTATION_ROUTER.post("/prepare", response_model=PresentationModel)
async def prepare_presentation(
presentation_id: Annotated[uuid.UUID, Body()],
outlines: Annotated[List[SlideOutlineModel], Body()],
layout: Annotated[PresentationLayoutModel | str, 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")
if len(outlines) > MAX_NUMBER_OF_SLIDES:
raise HTTPException(
status_code=400,
detail=f"Number of outlines cannot be greater than {MAX_NUMBER_OF_SLIDES}",
)
presentation = await sql_session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_outline_model = PresentationOutlineModel(slides=outlines)
layout_payload, structure_layout, template_fonts = await _resolve_prepare_layout(
layout, sql_session
)
total_slide_layouts = _layout_count(layout_payload)
if total_slide_layouts < 1:
raise HTTPException(
status_code=400,
detail="Layout must contain at least one slide layout",
)
total_outlines = len(outlines)
if structure_layout.ordered:
presentation_structure = structure_layout.to_presentation_structure()
else:
presentation_structure: PresentationStructureModel = (
await generate_presentation_structure(
presentation_outline=presentation_outline_model,
presentation_layout=structure_layout,
instructions=presentation.instructions,
)
)
presentation_structure.slides = presentation_structure.slides[: len(outlines)]
for index in range(total_outlines):
random_slide_index = random.randint(0, total_slide_layouts - 1)
if index >= total_outlines:
presentation_structure.slides.append(random_slide_index)
continue
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
if presentation.include_table_of_contents:
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(structure_layout)
_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")
presentation.title = title or presentation.title
# Final slide generation should follow the reviewed outline text. The
# original upload language can be stale after outline-page chat edits such
# as "convert these to Chinese".
presentation.language = ""
presentation.layout = layout_payload
presentation.fonts = template_fonts
presentation.set_structure(presentation_structure)
await sql_session.commit()
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
presentation.id,
presentation.outlines,
)
return presentation
@PRESENTATION_ROUTER.get("/stream/{id}", response_model=PresentationDetailWithSlides)
async def stream_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, 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",
)
try:
structure = presentation.get_structure()
layout = _get_presentation_stream_layout(presentation)
outline = presentation.get_presentation_outline()
except Exception as exc:
raise HTTPException(
status_code=400,
detail="Presentation has invalid generated data",
) from exc
if not layout.slides:
raise HTTPException(status_code=400, detail="Presentation layout has no slides")
if len(structure.slides) > len(outline.slides):
raise HTTPException(
status_code=400,
detail="Presentation structure has more slides than outlines",
)
invalid_layout_index = next(
(
slide_layout_index
for slide_layout_index in structure.slides
if slide_layout_index < 0 or slide_layout_index >= len(layout.slides)
),
None,
)
if invalid_layout_index is not None:
raise HTTPException(
status_code=400,
detail="Presentation structure contains an invalid slide layout",
)
image_generation_service = ImageGenerationService(get_images_directory())
async def inner():
icon_weight = layout.icon_weight
image_urls_for_slides = get_images_for_slides_from_outline(outline.slides)
async_assets_generation_tasks: List[asyncio.Task] = []
asset_events: asyncio.Queue = asyncio.Queue()
asset_warnings_by_slide: dict[int, list[dict]] = {}
async def notify_slide_assets_ready(slide_index: int, asset_task: asyncio.Task):
try:
await asset_task
except Exception:
logger.exception(
"Slide asset generation failed: presentation_id=%s slide_index=%s",
id,
slide_index,
)
asset_warnings_by_slide.setdefault(slide_index, []).append(
{
"type": "asset_generation_failed",
"message": "Some slide assets could not be generated.",
}
)
finally:
await asset_events.put(slide_index)
slides: List[SlideModel] = []
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": '{ "slides": [ '}),
).to_string()
yielded_slide_asset_sse_count = 0
for i, slide_layout_index in enumerate(structure.slides):
slide_layout = layout.slides[slide_layout_index]
try:
slide_content = await get_slide_content_from_type_and_outline(
slide_layout,
outline.slides[i],
presentation.language,
presentation.tone,
presentation.verbosity,
presentation.instructions,
)
except HTTPException as e:
yield SSEErrorResponse(detail=e.detail).to_string()
return
slide = SlideModel(
presentation=id,
layout_group=layout.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__", ""),
content=slide_content,
ui=_template_v2_slide_ui(presentation.layout, slide_layout.id),
)
slides.append(slide)
# This will mutate slide and add placeholder assets
process_slide_add_placeholder_assets(slide)
slide.ui = _apply_template_v2_content_to_ui(slide.ui, slide.content)
# This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation
asset_warnings_by_slide[i] = []
asset_task = 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
),
icon_weight=icon_weight,
allow_image_fallback=True,
image_warnings=asset_warnings_by_slide[i],
)
)
async_assets_generation_tasks.append(asset_task)
asyncio.create_task(notify_slide_assets_ready(i, asset_task))
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": slide.model_dump_json()}),
).to_string()
while True:
try:
done_idx = asset_events.get_nowait()
except asyncio.QueueEmpty:
break
slides[done_idx].ui = _apply_template_v2_content_to_ui(
slides[done_idx].ui,
slides[done_idx].content,
)
yielded_slide_asset_sse_count += 1
yield SSEResponse(
event="response",
data=json.dumps(
{
"type": "slide_assets",
"slide_index": done_idx,
"slide": slides[done_idx].model_dump(mode="json"),
"warnings": asset_warnings_by_slide.get(done_idx, []),
}
),
).to_string()
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": " ] }"}),
).to_string()
while yielded_slide_asset_sse_count < len(slides):
done_idx = await asset_events.get()
slides[done_idx].ui = _apply_template_v2_content_to_ui(
slides[done_idx].ui,
slides[done_idx].content,
)
yielded_slide_asset_sse_count += 1
yield SSEResponse(
event="response",
data=json.dumps(
{
"type": "slide_assets",
"slide_index": done_idx,
"slide": slides[done_idx].model_dump(mode="json"),
"warnings": asset_warnings_by_slide.get(done_idx, []),
}
),
).to_string()
generated_assets_lists = await asyncio.gather(
*async_assets_generation_tasks,
return_exceptions=True,
)
generated_assets = []
for assets_list in generated_assets_lists:
if isinstance(assets_list, Exception):
logger.error(
"Slide asset generation failed during final collection: %s",
assets_list,
)
continue
generated_assets.extend(assets_list)
for slide in slides:
slide.ui = _apply_template_v2_content_to_ui(slide.ui, slide.content)
# Moved this here to make sure new slides are generated before deleting the old ones
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == id)
)
await sql_session.commit()
sql_session.add(presentation)
sql_session.add_all(slides)
sql_session.add_all(generated_assets)
await sql_session.commit()
response = PresentationDetailWithSlides(
**_presentation_response_data(presentation),
slides=slides,
fonts=await _resolve_presentation_fonts(presentation, slides, sql_session),
merged_components=await _resolve_presentation_merged_components(
presentation,
slides,
sql_session,
),
)
yield SSECompleteResponse(
key="presentation",
value=response.model_dump(mode="json"),
).to_string()
async def rollback_stream_session():
await sql_session.rollback()
return StreamingResponse(
safe_sse_stream(
inner(),
logger=logger,
error_detail="Failed to generate presentation slides. Please try again.",
on_error=rollback_stream_session,
),
media_type="text/event-stream",
)
@PRESENTATION_ROUTER.patch("/update", response_model=PresentationDetailWithSlides)
async def update_presentation(
id: Annotated[uuid.UUID, Body()],
n_slides: Annotated[Optional[int], Body()] = None,
title: Annotated[Optional[str], Body()] = None,
theme: Annotated[Optional[dict], Body()] = None,
slides: Annotated[Optional[List[SlideModel]], Body()] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_update_dict = {}
if n_slides is not None:
if n_slides < 1:
raise HTTPException(
status_code=400,
detail="Number of slides must be greater than 0",
)
if n_slides > MAX_NUMBER_OF_SLIDES:
raise HTTPException(
status_code=400,
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
)
presentation_update_dict["n_slides"] = n_slides
if title:
presentation_update_dict["title"] = title
if theme or theme is None:
presentation_update_dict["theme"] = theme
if presentation_update_dict:
presentation.sqlmodel_update(presentation_update_dict)
if slides:
if len(slides) > MAX_NUMBER_OF_SLIDES:
raise HTTPException(
status_code=400,
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
)
# Just to make sure id is UUID
for slide in slides:
slide.presentation = uuid.UUID(slide.presentation)
slide.id = uuid.UUID(slide.id)
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == presentation.id)
)
sql_session.add_all(slides)
await sql_session.commit()
response_slides = slides or []
fonts = await _resolve_presentation_fonts(
presentation,
response_slides,
sql_session,
)
merged_components = await _resolve_presentation_merged_components(
presentation,
response_slides,
sql_session,
)
return PresentationDetailWithSlides(
**_presentation_response_data(presentation),
slides=response_slides,
fonts=fonts,
merged_components=merged_components,
)
async def check_if_api_request_is_valid(
request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session),
) -> Tuple[uuid.UUID,]:
presentation_id = uuid.uuid4()
print(f"Presentation ID: {presentation_id}")
# Making sure either content, slides markdown or files is provided
if not (request.content or request.slides_markdown or request.files):
raise HTTPException(
status_code=400,
detail="Either content or slides markdown or files is required to generate presentation",
)
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.slides_markdown is not None
and len(request.slides_markdown) > 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:
template_v2 = await _resolve_requested_template_v2(
request.template,
sql_session,
)
if not template_v2:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
request.template = f"template-v2-{template_v2.id}"
return (presentation_id,)
return (presentation_id,)
async def generate_presentation_handler(
request: GeneratePresentationRequest,
presentation_id: uuid.UUID,
async_status: Optional[AsyncTaskModel],
export_cookie_header: Optional[str] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
try:
using_slides_markdown = False
language_to_use = (request.language or "").strip() or None
additional_context = ""
if request.slides_markdown:
using_slides_markdown = True
if len(request.slides_markdown) > MAX_NUMBER_OF_SLIDES:
raise HTTPException(
status_code=400,
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
)
request.n_slides = len(request.slides_markdown)
if not using_slides_markdown:
# Updating async status
if async_status:
async_status.message = "Generating presentation outlines"
async_status.data = _presentation_task_progress_data(
created_slides=0,
remaining_slides=_requested_slide_count(request),
)
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
if request.files:
documents_loader = DocumentsLoader(
file_paths=request.files,
presentation_language=request.language,
)
await documents_loader.load_documents()
documents = documents_loader.documents
if documents:
additional_context = "\n\n".join(documents)
# Finding number of slides to generate by considering table of contents
n_slides_to_generate = 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,
)
)
outline_messages = get_outline_messages(
request.content,
n_slides_to_generate,
language_to_use,
additional_context,
request.tone.value,
request.verbosity.value,
request.instructions,
request.include_title_slide,
request.include_table_of_contents,
)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context(
presentation_id=presentation_id,
system_prompt=(
message_content_to_text(outline_messages[0].content)
if len(outline_messages) > 0
else None
),
user_prompt=(
message_content_to_text(outline_messages[1].content)
if len(outline_messages) > 1
else None
),
extracted_document_text=additional_context,
source_content=request.content,
instructions=request.instructions,
)
presentation_outlines_text = ""
async for chunk in generate_ppt_outline(
request.content,
n_slides_to_generate,
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):
raise chunk
presentation_outlines_text += chunk
try:
presentation_outlines_json = dict(
dirtyjson.loads(presentation_outlines_text)
)
except Exception:
traceback.print_exc()
raise HTTPException(
status_code=400,
detail="Failed to generate presentation outlines. Please try again.",
)
presentation_outlines = PresentationOutlineModel(
**normalize_outline_payload(
presentation_outlines_json,
MAX_NUMBER_OF_SLIDES,
)
)
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
presentation_outlines = PresentationOutlineModel(
slides=[
SlideOutlineModel(content=slide)
for slide in request.slides_markdown
]
)
total_outlines = len(request.slides_markdown)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context(
presentation_id=presentation_id,
system_prompt=None,
user_prompt=None,
extracted_document_text=None,
source_content=request.content,
instructions=request.instructions,
)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
presentation_id,
presentation_outlines.model_dump(mode="json"),
)
# Updating async status
if async_status:
async_status.message = "Selecting layout for each slide"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
print("-" * 40)
print(f"Generated {total_outlines} outlines for the presentation")
logger.info(
"[presentation.generate] loading layout template=%r presentation_id=%s",
request.template,
presentation_id,
)
(
layout_payload,
layout_model,
template_fonts,
is_template_v2,
) = await _resolve_generation_layout(request.template, sql_session)
logger.info(
"[presentation.generate] layout ready template=%r slides=%d ordered=%s icon_weight=%s",
request.template,
len(layout_model.slides),
layout_model.ordered,
layout_model.icon_weight,
)
total_slide_layouts = len(layout_model.slides)
# Generate Structure
if layout_model.ordered:
presentation_structure = layout_model.to_presentation_structure()
else:
presentation_structure: PresentationStructureModel = (
await generate_presentation_structure(
presentation_outlines,
layout_model,
request.instructions,
using_slides_markdown,
)
)
presentation_structure.slides = presentation_structure.slides[:total_outlines]
for index in range(total_outlines):
random_slide_index = random.randint(0, total_slide_layouts - 1)
if index >= total_outlines:
presentation_structure.slides.append(random_slide_index)
continue
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
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)
_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,
)
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,
version=(
PresentationVersion.V2_STANDARD
if is_template_v2
else PresentationVersion.V1_STANDARD
),
content=request.content,
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_payload,
structure=presentation_structure.model_dump(),
tone=request.tone.value,
verbosity=request.verbosity.value,
instructions=request.instructions,
fonts=template_fonts,
)
# Updating async status
if async_status:
async_status.message = "Generating slides"
async_status.data = _presentation_task_progress_data(
created_slides=0,
remaining_slides=final_n_slides or 0,
)
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
image_generation_service = ImageGenerationService(get_images_directory())
async_assets_generation_tasks = []
image_warnings: List[dict] = []
# 7. Generate slide content concurrently (batched), then build slides and fetch assets
slides: List[SlideModel] = []
slide_layout_indices = presentation_structure.slides
slide_layouts = [layout_model.slides[idx] for idx in slide_layout_indices]
total_slides_to_create = len(slide_layouts)
# Schedule slide content generation and asset fetching in batches of 10
batch_size = 10
for start in range(0, len(slide_layouts), batch_size):
end = min(start + batch_size, len(slide_layouts))
print(f"Generating slides from {start} to {end}")
# Generate contents for this batch concurrently
content_tasks = [
get_slide_content_from_type_and_outline(
slide_layouts[i],
presentation_outlines.slides[i],
language_to_use,
request.tone.value,
request.verbosity.value,
request.instructions,
)
for i in range(start, end)
]
batch_contents: List[dict] = await asyncio.gather(*content_tasks)
# Build slides for this batch
batch_slides: List[SlideModel] = []
for offset, slide_content in enumerate(batch_contents):
i = start + offset
slide_layout = slide_layouts[i]
slide = SlideModel(
presentation=presentation_id,
layout_group=layout_model.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__"),
content=slide_content,
ui=(
_template_v2_slide_ui(layout_payload, slide_layout.id)
if is_template_v2
else None
),
)
slides.append(slide)
batch_slides.append(slide)
if async_status:
async_status.data = _presentation_task_progress_data(
created_slides=len(slides),
remaining_slides=total_slides_to_create - len(slides),
)
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
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,
outline_image_urls=image_urls_for_batch[offset],
icon_weight=layout_model.icon_weight,
allow_image_fallback=True,
image_warnings=image_warnings,
)
)
for offset, slide in enumerate(batch_slides)
]
async_assets_generation_tasks.extend(asset_tasks)
if async_status:
async_status.message = "Fetching assets for slides"
async_status.data = _presentation_task_progress_data(
created_slides=len(slides),
remaining_slides=0,
)
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
# Run all asset tasks concurrently while batches may still be generating content
generated_assets_list = await asyncio.gather(*async_assets_generation_tasks)
generated_assets = []
for assets_list in generated_assets_list:
generated_assets.extend(assets_list)
for warning in image_warnings:
logger.warning(
"Slide image generation warning: presentation_id=%s detail=%s",
presentation_id,
warning.get("detail"),
)
if is_template_v2:
for slide in slides:
_hydrate_template_v2_slide_ui(slide, layout_payload)
# 8. Save PresentationModel and Slides
sql_session.add(presentation)
sql_session.add_all(slides)
sql_session.add_all(generated_assets)
await sql_session.commit()
if async_status:
async_status.message = "Exporting presentation"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
# 9. Export
presentation_and_path = await export_presentation(
presentation_id,
presentation.title or str(uuid.uuid4()),
request.export_as,
cookie_header=export_cookie_header,
)
response = PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={presentation_id}",
)
if async_status:
async_status.message = "Presentation generation completed"
async_status.status = "completed"
async_status.data = _presentation_task_progress_data(
created_slides=len(slides),
remaining_slides=0,
)
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
# Triggering webhook on success
CONCURRENT_SERVICE.run_task(
None,
WebhookService.send_webhook,
WebhookEvent.PRESENTATION_GENERATION_COMPLETED,
response.model_dump(mode="json"),
)
return response
except Exception as e:
if not isinstance(e, HTTPException):
traceback.print_exc()
e = HTTPException(status_code=500, detail="Presentation generation failed")
api_error_model = APIErrorModel.from_exception(e)
# Triggering webhook on failure
CONCURRENT_SERVICE.run_task(
None,
WebhookService.send_webhook,
WebhookEvent.PRESENTATION_GENERATION_FAILED,
api_error_model.model_dump(mode="json"),
)
if async_status:
async_status.status = "error"
async_status.message = "Presentation generation failed"
async_status.updated_at = datetime.now()
async_status.error = api_error_model.model_dump(mode="json")
sql_session.add(async_status)
await sql_session.commit()
else:
raise e
@PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath)
async def generate_presentation_sync(
request_http: Request,
request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
try:
(presentation_id,) = await check_if_api_request_is_valid(request, sql_session)
return await generate_presentation_handler(
request,
presentation_id,
None,
export_cookie_header=_build_export_cookie_header(request_http),
sql_session=sql_session,
)
except HTTPException:
raise
except Exception:
traceback.print_exc()
raise HTTPException(status_code=500, detail="Presentation generation failed")
async def _run_generate_presentation_task(
request: GeneratePresentationRequest,
presentation_id: uuid.UUID,
task_id: str,
export_cookie_header: Optional[str],
) -> None:
async with async_session_maker() as sql_session:
async_status = await sql_session.get(AsyncTaskModel, task_id)
if not async_status:
logger.warning(
"[presentation.generate.async] task missing task_id=%s",
task_id,
)
return
async_status.status = "processing"
async_status.message = "Starting presentation generation"
async_status.data = _presentation_task_progress_data(
created_slides=0,
remaining_slides=_requested_slide_count(request),
)
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
await generate_presentation_handler(
request,
presentation_id,
async_status=async_status,
export_cookie_header=export_cookie_header,
sql_session=sql_session,
)
@PRESENTATION_ROUTER.post("/generate/async", response_model=AsyncTaskModel)
async def generate_presentation_async(
request_http: Request,
request: GeneratePresentationRequest,
background_tasks: BackgroundTasks,
sql_session: AsyncSession = Depends(get_async_session),
):
try:
(presentation_id,) = await check_if_api_request_is_valid(request, sql_session)
async_status = AsyncTaskModel(
type=ASYNC_TASK_TYPE_PRESENTATION_GENERATE,
status="pending",
message="Queued for generation",
data=_presentation_task_progress_data(
created_slides=0,
remaining_slides=_requested_slide_count(request),
),
)
sql_session.add(async_status)
await sql_session.commit()
await sql_session.refresh(async_status)
background_tasks.add_task(
_run_generate_presentation_task,
request,
presentation_id,
async_status.id,
_build_export_cookie_header(request_http),
)
return async_status
except Exception as e:
if not isinstance(e, HTTPException):
print(e)
e = HTTPException(status_code=500, detail="Presentation generation failed")
raise e
@PRESENTATION_ROUTER.get("/status/{id}", response_model=AsyncTaskModel)
async def check_async_presentation_generation_status(
id: str = Path(description="ID of the presentation generation task"),
sql_session: AsyncSession = Depends(get_async_session),
):
status = await sql_session.get(AsyncTaskModel, id)
if not status:
raise HTTPException(
status_code=404, detail="No presentation generation task found"
)
return status
@PRESENTATION_ROUTER.post("/edit", response_model=PresentationPathAndEditPath)
async def edit_presentation_with_new_content(
request_http: Request,
data: Annotated[EditPresentationRequest, Body()],
sql_session: AsyncSession = Depends(get_async_session),
):
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_slides = []
slides_to_delete = []
for each_slide in slides:
updated_content = None
new_slide_data = list(
filter(lambda x: x.index == each_slide.index, data.slides)
)
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slide = each_slide.get_new_slide(presentation.id, updated_content)
_hydrate_template_v2_slide_ui(new_slide, presentation.layout)
new_slides.append(new_slide)
slides_to_delete.append(each_slide.id)
await sql_session.execute(
delete(SlideModel).where(SlideModel.id.in_(slides_to_delete))
)
sql_session.add_all(new_slides)
await sql_session.commit()
presentation_and_path = await export_presentation(
presentation.id,
presentation.title or str(uuid.uuid4()),
data.export_as,
cookie_header=_build_export_cookie_header(request_http),
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={presentation.id}",
)
@PRESENTATION_ROUTER.post("/derive", response_model=PresentationPathAndEditPath)
async def derive_presentation_from_existing_one(
request_http: Request,
data: Annotated[EditPresentationRequest, Body()],
sql_session: AsyncSession = Depends(get_async_session),
):
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 = []
for each_slide in slides:
updated_content = None
new_slide_data = list(
filter(lambda x: x.index == each_slide.index, data.slides)
)
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slide = each_slide.get_new_slide(new_presentation.id, updated_content)
_hydrate_template_v2_slide_ui(new_slide, new_presentation.layout)
new_slides.append(new_slide)
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 or str(uuid.uuid4()),
data.export_as,
cookie_header=_build_export_cookie_header(request_http),
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={new_presentation.id}",
)