diff --git a/electron/package-lock.json b/electron/package-lock.json index 59e05b9f..ce9ae9d8 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "presenton", - "version": "0.6.0-beta", + "version": "0.6.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "presenton", - "version": "0.6.0-beta", + "version": "0.6.1-beta", "hasInstallScript": true, "dependencies": { "@tailwindcss/cli": "^4.1.5", diff --git a/electron/package.json b/electron/package.json index d4740d54..d82264cf 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,7 +1,7 @@ { "name": "presenton", "productName": "Presenton Open Source", - "version": "0.6.0-beta", + "version": "0.6.1-beta", "main": "app_dist/main.js", "description": "Open-Source AI Presentation Generator", "homepage": "https://presenton.ai", diff --git a/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py index 3ad01ddd..27a01244 100644 --- a/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -317,9 +317,9 @@ async def stream_presentation( # This will mutate slide and add placeholder assets process_slide_add_placeholder_assets(slide) - # This will mutate slide + # This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation async_assets_generation_tasks.append( - process_slide_and_fetch_assets(image_generation_service, slide) + asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide)) ) yield SSEResponse( @@ -721,9 +721,9 @@ async def generate_presentation_handler( slides.append(slide) batch_slides.append(slide) - # Start asset fetch tasks for just-generated slides so they run while next batch is processed + # Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls asset_tasks = [ - process_slide_and_fetch_assets(image_generation_service, slide) + asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide)) for slide in batch_slides ] async_assets_generation_tasks.extend(asset_tasks) diff --git a/electron/servers/fastapi/services/llm_client.py b/electron/servers/fastapi/services/llm_client.py index 9799cc6b..c7151f6f 100644 --- a/electron/servers/fastapi/services/llm_client.py +++ b/electron/servers/fastapi/services/llm_client.py @@ -66,6 +66,7 @@ from utils.set_env import ( from utils.llm_provider import get_llm_provider, get_model from utils.parsers import parse_bool_or_none from utils.schema_utils import ( + ensure_array_schemas_have_items, ensure_strict_json_schema, flatten_json_schema, remove_titles_from_schema, @@ -702,6 +703,7 @@ class LLMClient: path=(), root=response_schema, ) + response_schema = ensure_array_schemas_have_items(response_schema) if use_tool_calls_for_structured_output and depth == 0: if all_tools is None: all_tools = [] @@ -1599,6 +1601,7 @@ class LLMClient: path=(), root=response_schema, ) + response_schema = ensure_array_schemas_have_items(response_schema) if use_tool_calls_for_structured_output and depth == 0: if all_tools is None: @@ -1793,28 +1796,16 @@ class LLMClient: """ client: AsyncOpenAI = self._client response_schema = response_format - # Apply strict schema once at root + # Apply strict schema once at root (includes array "items" fix at lines 135–155). if strict and depth == 0: response_schema = ensure_strict_json_schema( response_schema, path=(), root=response_schema, ) - - # Codex Responses API requires all array schemas to specify `items`. - def _fix_arrays(node: Any) -> Any: - if isinstance(node, dict): - # Add default items for arrays missing them - if node.get("type") == "array" and "items" not in node: - node["items"] = {"type": "string"} - for key, value in list(node.items()): - node[key] = _fix_arrays(value) - elif isinstance(node, list): - for idx, value in enumerate(node): - node[idx] = _fix_arrays(value) - return node - - response_schema = _fix_arrays(response_schema) + # When we didn't run ensure_strict_json_schema, fix arrays for Codex API (strict=False or depth > 0). + else: + response_schema = ensure_array_schemas_have_items(response_schema) # Responses API tool format: flat {type, name, description, parameters} response_schema_tool = { diff --git a/electron/servers/fastapi/utils/schema_utils.py b/electron/servers/fastapi/utils/schema_utils.py index 92aafd97..1e0241aa 100644 --- a/electron/servers/fastapi/utils/schema_utils.py +++ b/electron/servers/fastapi/utils/schema_utils.py @@ -134,11 +134,25 @@ def ensure_strict_json_schema( # arrays # { 'type': 'array', 'items': {...} } + # OpenAI requires array schemas to have "items". Zod tuples may emit prefixItems only. items = json_schema.get("items") if isinstance(items, dict): json_schema["items"] = ensure_strict_json_schema( items, path=(*path, "items"), root=root ) + elif typ == "array": + prefix_items = json_schema.get("prefixItems") + if ( + isinstance(prefix_items, list) + and len(prefix_items) > 0 + and isinstance(prefix_items[0], dict) + ): + json_schema["items"] = ensure_strict_json_schema( + prefix_items[0], path=(*path, "items"), root=root + ) + json_schema.pop("prefixItems", None) + else: + json_schema["items"] = {"type": "string"} # unions any_of = json_schema.get("anyOf") @@ -281,6 +295,34 @@ def flatten_json_schema(schema: dict) -> dict: return result +def ensure_array_schemas_have_items(schema: dict) -> dict[str, Any]: + """ + Recursively ensure every JSON schema node with type="array" has an "items" key. + Codex Responses API requires array schemas to specify items. Mutates a deep copy. + """ + result = deepcopy(schema) + + def _is_array_schema_type(type_value: Any) -> bool: + if type_value == "array": + return True + if isinstance(type_value, list): + return "array" in type_value + return False + + def _ensure(node: Any) -> Any: + if isinstance(node, dict): + if _is_array_schema_type(node.get("type")) and "items" not in node: + node["items"] = {"type": "string"} + for key, value in list(node.items()): + node[key] = _ensure(value) + elif isinstance(node, list): + for idx, value in enumerate(node): + node[idx] = _ensure(value) + return node + + return _ensure(result) + + def remove_titles_from_schema(schema: dict) -> dict[str, Any]: def _strip_titles(node: Any) -> Any: diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index a2cbc4ed..f03a3ec6 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -7,7 +7,7 @@ import random import traceback from typing import Annotated, List, Literal, Optional, Tuple import dirtyjson -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Request from fastapi.responses import StreamingResponse from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession @@ -138,6 +138,7 @@ async def create_presentation( include_table_of_contents: Annotated[bool, Body()] = False, include_title_slide: Annotated[bool, Body()] = True, web_search: Annotated[bool, Body()] = False, + theme: Annotated[Optional[dict], Body()] = None, sql_session: AsyncSession = Depends(get_async_session), ): @@ -161,6 +162,7 @@ async def create_presentation( include_table_of_contents=include_table_of_contents, include_title_slide=include_title_slide, web_search=web_search, + theme=theme, ) sql_session.add(presentation) @@ -317,9 +319,9 @@ async def stream_presentation( # This will mutate slide and add placeholder assets process_slide_add_placeholder_assets(slide) - # This will mutate slide + # This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation async_assets_generation_tasks.append( - process_slide_and_fetch_assets(image_generation_service, slide) + asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide)) ) yield SSEResponse( @@ -363,9 +365,11 @@ async def stream_presentation( @PRESENTATION_ROUTER.patch("/update", response_model=PresentationWithSlides) async def update_presentation( + request: Request, 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), ): @@ -374,12 +378,16 @@ async def update_presentation( raise HTTPException(status_code=404, detail="Presentation not found") presentation_update_dict = {} + request_body = await request.json() + theme_provided = "theme" in request_body if n_slides: presentation_update_dict["n_slides"] = n_slides if title: presentation_update_dict["title"] = title + if theme_provided: + presentation_update_dict["theme"] = theme - if n_slides or title: + if n_slides or title or theme_provided: presentation.sqlmodel_update(presentation_update_dict) if slides: @@ -716,9 +724,9 @@ async def generate_presentation_handler( slides.append(slide) batch_slides.append(slide) - # Start asset fetch tasks for just-generated slides so they run while next batch is processed + # Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls asset_tasks = [ - process_slide_and_fetch_assets(image_generation_service, slide) + asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide)) for slide in batch_slides ] async_assets_generation_tasks.extend(asset_tasks) diff --git a/servers/fastapi/models/presentation_with_slides.py b/servers/fastapi/models/presentation_with_slides.py index 3a4d83b4..a8a8e3b7 100644 --- a/servers/fastapi/models/presentation_with_slides.py +++ b/servers/fastapi/models/presentation_with_slides.py @@ -17,4 +17,5 @@ class PresentationWithSlides(BaseModel): updated_at: datetime tone: Optional[str] = None verbosity: Optional[str] = None + theme: Optional[dict] = None slides: List[SlideModel] diff --git a/servers/fastapi/models/sql/presentation.py b/servers/fastapi/models/sql/presentation.py index 050bf2f2..f619d4c2 100644 --- a/servers/fastapi/models/sql/presentation.py +++ b/servers/fastapi/models/sql/presentation.py @@ -35,6 +35,7 @@ class PresentationModel(SQLModel, table=True): ) layout: Optional[dict] = Field(sa_column=Column(JSON), default=None) structure: Optional[dict] = Field(sa_column=Column(JSON), default=None) + theme: Optional[dict] = Field(sa_column=Column(JSON), default=None) instructions: Optional[str] = Field(sa_column=Column(String), default=None) tone: Optional[str] = Field(sa_column=Column(String), default=None) verbosity: Optional[str] = Field(sa_column=Column(String), default=None) @@ -53,6 +54,7 @@ class PresentationModel(SQLModel, table=True): outlines=self.outlines, layout=self.layout, structure=self.structure, + theme=self.theme, instructions=self.instructions, tone=self.tone, verbosity=self.verbosity, diff --git a/servers/fastapi/services/database.py b/servers/fastapi/services/database.py index 99f2c479..5557451a 100644 --- a/servers/fastapi/services/database.py +++ b/servers/fastapi/services/database.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import ( async_sessionmaker, AsyncSession, ) +from sqlalchemy import text from sqlmodel import SQLModel from models.sql.async_presentation_generation_status import ( @@ -66,6 +67,12 @@ async def create_db_and_tables(): ], ) ) + # Lightweight schema migration for existing DBs: ensure `presentations.theme` exists. + if database_url.startswith("sqlite"): + result = await conn.execute(text("PRAGMA table_info(presentations)")) + column_names = {row[1] for row in result.fetchall()} + if "theme" not in column_names: + await conn.execute(text("ALTER TABLE presentations ADD COLUMN theme JSON")) async with container_db_engine.begin() as conn: await conn.run_sync( diff --git a/servers/fastapi/services/llm_client.py b/servers/fastapi/services/llm_client.py index 9799cc6b..63a97de4 100644 --- a/servers/fastapi/services/llm_client.py +++ b/servers/fastapi/services/llm_client.py @@ -66,6 +66,7 @@ from utils.set_env import ( from utils.llm_provider import get_llm_provider, get_model from utils.parsers import parse_bool_or_none from utils.schema_utils import ( + ensure_array_schemas_have_items, ensure_strict_json_schema, flatten_json_schema, remove_titles_from_schema, @@ -702,6 +703,7 @@ class LLMClient: path=(), root=response_schema, ) + response_schema = ensure_array_schemas_have_items(response_schema) if use_tool_calls_for_structured_output and depth == 0: if all_tools is None: all_tools = [] @@ -1599,6 +1601,7 @@ class LLMClient: path=(), root=response_schema, ) + response_schema = ensure_array_schemas_have_items(response_schema) if use_tool_calls_for_structured_output and depth == 0: if all_tools is None: @@ -1793,28 +1796,16 @@ class LLMClient: """ client: AsyncOpenAI = self._client response_schema = response_format - # Apply strict schema once at root + # Apply strict schema once at root (includes array "items" fix in ensure_strict_json_schema). if strict and depth == 0: response_schema = ensure_strict_json_schema( response_schema, path=(), root=response_schema, ) - - # Codex Responses API requires all array schemas to specify `items`. - def _fix_arrays(node: Any) -> Any: - if isinstance(node, dict): - # Add default items for arrays missing them - if node.get("type") == "array" and "items" not in node: - node["items"] = {"type": "string"} - for key, value in list(node.items()): - node[key] = _fix_arrays(value) - elif isinstance(node, list): - for idx, value in enumerate(node): - node[idx] = _fix_arrays(value) - return node - - response_schema = _fix_arrays(response_schema) + # When we didn't run ensure_strict_json_schema, fix arrays for Codex API (strict=False or depth > 0). + else: + response_schema = ensure_array_schemas_have_items(response_schema) # Responses API tool format: flat {type, name, description, parameters} response_schema_tool = { diff --git a/servers/fastapi/utils/schema_utils.py b/servers/fastapi/utils/schema_utils.py index 9efed7c5..66e01746 100644 --- a/servers/fastapi/utils/schema_utils.py +++ b/servers/fastapi/utils/schema_utils.py @@ -185,6 +185,20 @@ def ensure_strict_json_schema( items, path=(*path, "items"), root=root ) + elif typ == "array": + prefix_items = json_schema.get("prefixItems") + if ( + isinstance(prefix_items, list) + and len(prefix_items) > 0 + and isinstance(prefix_items[0], dict) + ): + json_schema["items"] = ensure_strict_json_schema( + prefix_items[0], path=(*path, "items"), root=root + ) + json_schema.pop("prefixItems", None) + else: + json_schema["items"] = {"type": "string"} + # unions any_of = json_schema.get("anyOf") if isinstance(any_of, list): @@ -326,6 +340,34 @@ def flatten_json_schema(schema: dict) -> dict: return result +def ensure_array_schemas_have_items(schema: dict) -> dict[str, Any]: + """ + Recursively ensure every JSON schema node with type="array" has an "items" key. + Codex Responses API requires array schemas to specify items. Mutates a deep copy. + """ + result = deepcopy(schema) + + def _is_array_schema_type(type_value: Any) -> bool: + if type_value == "array": + return True + if isinstance(type_value, list): + return "array" in type_value + return False + + def _ensure(node: Any) -> Any: + if isinstance(node, dict): + if _is_array_schema_type(node.get("type")) and "items" not in node: + node["items"] = {"type": "string"} + for key, value in list(node.items()): + node[key] = _ensure(value) + elif isinstance(node, list): + for idx, value in enumerate(node): + node[idx] = _ensure(value) + return node + + return _ensure(result) + + def remove_titles_from_schema(schema: dict) -> dict[str, Any]: def _strip_titles(node: Any) -> Any: diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx index d6322fa1..a750df95 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx @@ -4,10 +4,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { Button } from "@/components/ui/button"; import { LoadingState, Template } from "../types/index"; import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils"; -<<<<<<< feat/revamp_design import { ChevronRight } from "lucide-react"; -======= ->>>>>>> main interface GenerateButtonProps { loadingState: LoadingState; diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx index fa850f58..ef7dcabe 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx @@ -16,10 +16,7 @@ import { useOutlineManagement } from "../hooks/useOutlineManagement"; import { usePresentationGeneration } from "../hooks/usePresentationGeneration"; import TemplateSelection from "./TemplateSelection"; import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils"; -<<<<<<< feat/revamp_design import { Separator } from "@/components/ui/separator"; -======= ->>>>>>> main const OutlinePage: React.FC = () => { const { presentation_id, outlines } = useSelector( diff --git a/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts b/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts index 3d06bb8b..a8da1be9 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts +++ b/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts @@ -4,13 +4,9 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { clearPresentationData } from "@/store/slices/presentationGeneration"; import { PresentationGenerationApi } from "../../services/api/presentation-generation"; -<<<<<<< feat/revamp_design -import { LoadingState, TABS } from "../types/index"; -======= import { Template, LoadingState, TABS } from "../types/index"; import { MixpanelEvent, trackEvent } from "@/utils/mixpanel"; import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils"; ->>>>>>> main import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates"; import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils"; diff --git a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx index e765aed7..3ab5272f 100644 --- a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx @@ -13,6 +13,8 @@ import { DashboardApi } from "../services/api/dashboard"; import { V1ContentRender } from "../components/V1ContentRender"; +import { useFontLoader } from "../hooks/useFontLoad"; +import { Theme } from "../services/api/types"; @@ -52,6 +54,9 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { const data = await DashboardApi.getPresentation(presentation_id); dispatch(setPresentationData(data)); setContentLoading(false); + if (data?.theme) { + applyTheme(data.theme); + } } catch (error) { setError(true); toast.error("Failed to load presentation"); @@ -60,6 +65,43 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { } }; + const applyTheme = async (theme: Theme) => { + const element = document.getElementById('presentation-slides-wrapper') + if (!element) return; + if (!theme || !theme.data) { return; } + if (!theme.data.colors['graph_0']) { return; } + const cssVariables = { + '--primary-color': theme.data.colors['primary'], + '--background-color': theme.data.colors['background'], + '--card-color': theme.data.colors['card'], + '--stroke': theme.data.colors['stroke'], + '--primary-text': theme.data.colors['primary_text'], + '--background-text': theme.data.colors['background_text'], + '--graph-0': theme.data.colors['graph_0'], + '--graph-1': theme.data.colors['graph_1'], + '--graph-2': theme.data.colors['graph_2'], + '--graph-3': theme.data.colors['graph_3'], + '--graph-4': theme.data.colors['graph_4'], + '--graph-5': theme.data.colors['graph_5'], + '--graph-6': theme.data.colors['graph_6'], + '--graph-7': theme.data.colors['graph_7'], + '--graph-8': theme.data.colors['graph_8'], + '--graph-9': theme.data.colors['graph_9'], + } + + Object.entries(cssVariables).forEach(([key, value]) => { + element.style.setProperty(key, value) + }) + useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url }) + + // Apply fonts to preview container + element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`) + element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`) + element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`) + // Update the Presentation content with theme + } + + // Regular view return (