4233 lines
152 KiB
Python
4233 lines
152 KiB
Python
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from jsonschema import Draft202012Validator
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlmodel import select
|
|
|
|
from constants.presentation import MAX_NUMBER_OF_SLIDES
|
|
from models.image_prompt import ImagePrompt
|
|
from models.presentation_outline_model import PresentationOutlineModel, SlideOutlineModel
|
|
from models.sql.image_asset import ImageAsset
|
|
from models.sql.key_value import KeyValueSqlModel
|
|
from models.sql.presentation import PresentationModel
|
|
from models.sql.slide import SlideModel
|
|
from models.sql.template_v2 import TemplateV2
|
|
from services.icon_finder_service import ICON_FINDER_SERVICE
|
|
from services.documents_loader import DocumentsLoader
|
|
from services.image_generation_service import ImageGenerationService
|
|
from services.mem0_presentation_memory_service import MEM0_PRESENTATION_MEMORY_SERVICE
|
|
from services.temp_file_service import TEMP_FILE_SERVICE
|
|
from templates.presentation_layout import PresentationLayoutModel, SlideLayoutModel
|
|
from templates.v2.schema import get_template_schema
|
|
from utils.asset_directory_utils import (
|
|
filesystem_image_path_to_app_data_url,
|
|
get_images_directory,
|
|
normalize_slide_asset_url,
|
|
)
|
|
from utils.icon_weights import DEFAULT_ICON_WEIGHT
|
|
from utils.outline_utils import get_presentation_title_from_presentation_outline
|
|
from utils.outline_limits import normalize_outline_content
|
|
from utils.process_slides import (
|
|
process_old_and_new_slides_and_fetch_assets,
|
|
process_slide_and_fetch_assets,
|
|
)
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
MAX_SCHEMA_ERRORS = 10
|
|
DEFAULT_SOURCE_DOCUMENT_CHARS = 12000
|
|
MAX_SOURCE_DOCUMENT_CHARS = 30000
|
|
SLIDE_STAGE_WIDTH = 1280.0
|
|
SLIDE_STAGE_HEIGHT = 720.0
|
|
BLANK_SLIDE_LAYOUT_ID = "__blank_slide__"
|
|
BLANK_TEMPLATE_V2_LAYOUT: dict[str, Any] = {
|
|
"id": BLANK_SLIDE_LAYOUT_ID,
|
|
"description": "Empty slide.",
|
|
"background": "#FFFFFF",
|
|
"components": [],
|
|
"elements": [
|
|
{
|
|
"type": "rectangle",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 1280, "height": 720},
|
|
"fill": {"color": "#FFFFFF"},
|
|
"decorative": True,
|
|
}
|
|
],
|
|
}
|
|
TEMPLATE_V2_GENERATED_ELEMENT_TYPES = {"text", "image", "text-list", "table", "chart"}
|
|
# Keep URL runtime fields during validation because many slide schemas require them.
|
|
# Speaker note is handled separately and should not affect JSON-schema checks.
|
|
RUNTIME_CONTENT_FIELDS = {"__speaker_note__"}
|
|
DEFAULT_INSERT_BOXES = {
|
|
"chart": {
|
|
"position": {"x": 128.0, "y": 108.0},
|
|
"size": {"width": 1024.0, "height": 460.0},
|
|
"min_size": {"width": 320.0, "height": 180.0},
|
|
},
|
|
"table": {
|
|
"position": {"x": 128.0, "y": 120.0},
|
|
"size": {"width": 1024.0, "height": 410.0},
|
|
"min_size": {"width": 420.0, "height": 160.0},
|
|
},
|
|
}
|
|
THEMES_STORAGE_KEY = "presentation_custom_themes"
|
|
CHAT_BUILTIN_THEMES: list[dict[str, Any]] = [
|
|
{
|
|
"id": "edge-yellow",
|
|
"name": "Edge Yellow",
|
|
"description": "Yellow and dark theme for professionalish and edge.",
|
|
"user": "system",
|
|
"logo": None,
|
|
"logo_url": None,
|
|
"company_name": None,
|
|
"data": {
|
|
"colors": {
|
|
"primary": "#f5f547",
|
|
"background": "#1f1f1f",
|
|
"card": "#424242",
|
|
"stroke": "#585858",
|
|
"primary_text": "#161616",
|
|
"background_text": "#f5f547",
|
|
"graph_0": "#ffff54",
|
|
"graph_1": "#f1f142",
|
|
"graph_2": "#dada15",
|
|
"graph_3": "#c1bf00",
|
|
"graph_4": "#a8a600",
|
|
"graph_5": "#908c00",
|
|
"graph_6": "#797400",
|
|
"graph_7": "#625c00",
|
|
"graph_8": "#4d4500",
|
|
"graph_9": "#382f00",
|
|
},
|
|
"fonts": {
|
|
"textFont": {
|
|
"name": "Playfair Display",
|
|
"url": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"id": "light-rose",
|
|
"name": "Light Rose",
|
|
"description": "Rose background with punchy font.",
|
|
"user": "system",
|
|
"logo": None,
|
|
"logo_url": None,
|
|
"company_name": None,
|
|
"data": {
|
|
"colors": {
|
|
"primary": "#030204",
|
|
"background": "#f69c9c",
|
|
"card": "#ffaeb4",
|
|
"stroke": "#bf6a6b",
|
|
"primary_text": "#bebebe",
|
|
"background_text": "#030202",
|
|
"graph_0": "#2f2c32",
|
|
"graph_1": "#444147",
|
|
"graph_2": "#5a565d",
|
|
"graph_3": "#706d73",
|
|
"graph_4": "#88848b",
|
|
"graph_5": "#a09da4",
|
|
"graph_6": "#b9b6bd",
|
|
"graph_7": "#d3cfd6",
|
|
"graph_8": "#eae6ed",
|
|
"graph_9": "#f7f3fb",
|
|
},
|
|
"fonts": {
|
|
"textFont": {
|
|
"name": "Overpass",
|
|
"url": "https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"id": "mint-blue",
|
|
"name": "Mint Blue",
|
|
"description": "Mint green with blue heading.",
|
|
"user": "system",
|
|
"logo": None,
|
|
"logo_url": None,
|
|
"company_name": None,
|
|
"data": {
|
|
"colors": {
|
|
"primary": "#3b3172",
|
|
"background": "#ffffff",
|
|
"card": "#80e7cf",
|
|
"stroke": "#d1d1d1",
|
|
"primary_text": "#ffffff",
|
|
"background_text": "#3b3172",
|
|
"graph_0": "#003d2d",
|
|
"graph_1": "#005341",
|
|
"graph_2": "#006a57",
|
|
"graph_3": "#00826d",
|
|
"graph_4": "#2b9a85",
|
|
"graph_5": "#4ab39d",
|
|
"graph_6": "#65cdb6",
|
|
"graph_7": "#80e7cf",
|
|
"graph_8": "#98ffe6",
|
|
"graph_9": "#a5fff4",
|
|
},
|
|
"fonts": {
|
|
"textFont": {
|
|
"name": "Prompt",
|
|
"url": "https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"id": "professional-blue",
|
|
"name": "Professional Blue",
|
|
"description": "Clean and professional blue theme.",
|
|
"user": "system",
|
|
"logo": None,
|
|
"logo_url": None,
|
|
"company_name": None,
|
|
"data": {
|
|
"colors": {
|
|
"primary": "#161616",
|
|
"background": "#ffffff",
|
|
"card": "#dae6ff",
|
|
"stroke": "#d1d1d1",
|
|
"primary_text": "#eeeaea",
|
|
"background_text": "#000000",
|
|
"graph_0": "#2e2e2e",
|
|
"graph_1": "#424242",
|
|
"graph_2": "#585858",
|
|
"graph_3": "#6f6f6f",
|
|
"graph_4": "#868686",
|
|
"graph_5": "#9e9e9e",
|
|
"graph_6": "#b7b7b7",
|
|
"graph_7": "#d1d1d1",
|
|
"graph_8": "#e8e8e8",
|
|
"graph_9": "#f5f5f5",
|
|
},
|
|
"fonts": {
|
|
"textFont": {
|
|
"name": "Inter",
|
|
"url": "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"id": "professional-dark",
|
|
"name": "Professional Dark",
|
|
"description": "Clean and professional for dark corporate usage.",
|
|
"user": "system",
|
|
"logo": None,
|
|
"logo_url": None,
|
|
"company_name": None,
|
|
"data": {
|
|
"colors": {
|
|
"primary": "#eff5f1",
|
|
"background": "#050505",
|
|
"card": "#424242",
|
|
"stroke": "#585858",
|
|
"primary_text": "#050505",
|
|
"background_text": "#eff5f1",
|
|
"graph_0": "#ebf6ff",
|
|
"graph_1": "#dee8fa",
|
|
"graph_2": "#c7d2e3",
|
|
"graph_3": "#aeb8c9",
|
|
"graph_4": "#959fb0",
|
|
"graph_5": "#7d8797",
|
|
"graph_6": "#666f7f",
|
|
"graph_7": "#505867",
|
|
"graph_8": "#3a4351",
|
|
"graph_9": "#262e3c",
|
|
},
|
|
"fonts": {
|
|
"textFont": {
|
|
"name": "Instrument Sans",
|
|
"url": "https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
]
|
|
THEME_COLOR_KEYS = [
|
|
"primary",
|
|
"background",
|
|
"card",
|
|
"stroke",
|
|
"primary_text",
|
|
"background_text",
|
|
"graph_0",
|
|
"graph_1",
|
|
"graph_2",
|
|
"graph_3",
|
|
"graph_4",
|
|
"graph_5",
|
|
"graph_6",
|
|
"graph_7",
|
|
"graph_8",
|
|
"graph_9",
|
|
]
|
|
DEFAULT_THEME_FONT = {
|
|
"name": "Inter",
|
|
"url": "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap",
|
|
}
|
|
|
|
|
|
class PresentationChatMemoryLayer:
|
|
"""
|
|
Memory abstraction for chat tools and context retrieval.
|
|
|
|
This layer intentionally hides where data comes from (SQL-backed persisted state
|
|
and mem0 retrieval) behind `get` and `search`-style methods so chat logic stays
|
|
decoupled from storage details.
|
|
"""
|
|
|
|
def __init__(self, sql_session: AsyncSession, presentation_id: uuid.UUID):
|
|
self._sql_session = sql_session
|
|
self._presentation_id = presentation_id
|
|
|
|
async def get(self, key: str) -> Any:
|
|
if key != "presentation_outline":
|
|
return None
|
|
|
|
# Prefer live slides from SQL so slide count and slide indices are always current.
|
|
slides_result = await self._sql_session.scalars(
|
|
select(SlideModel)
|
|
.where(SlideModel.presentation == self._presentation_id)
|
|
.order_by(SlideModel.index)
|
|
)
|
|
slides = list(slides_result)
|
|
if slides:
|
|
LOGGER.info(
|
|
"Chat outline loaded from slides table (presentation_id=%s, slides=%d)",
|
|
self._presentation_id,
|
|
len(slides),
|
|
)
|
|
return {
|
|
"source": "slides_table",
|
|
"slide_count": len(slides),
|
|
"slides": [
|
|
{
|
|
"slide_id": str(slide.id),
|
|
"index": slide.index,
|
|
"layout_id": slide.layout,
|
|
"content": slide.content,
|
|
"speaker_note": slide.speaker_note,
|
|
}
|
|
for slide in slides
|
|
],
|
|
}
|
|
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation or not presentation.outlines:
|
|
LOGGER.info(
|
|
"Chat memory miss for outline (presentation_id=%s)",
|
|
self._presentation_id,
|
|
)
|
|
return None
|
|
|
|
LOGGER.info(
|
|
"Chat outline fallback hit from presentation.outlines (presentation_id=%s)",
|
|
self._presentation_id,
|
|
)
|
|
return presentation.outlines
|
|
|
|
async def search(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
|
"""
|
|
Search slides directly from SQL-backed slide rows.
|
|
|
|
Results are intentionally compact (snippet-first) to keep tool-call payloads
|
|
small for models with limited context windows.
|
|
"""
|
|
|
|
trimmed_query = (query or "").strip()
|
|
if not trimmed_query:
|
|
return []
|
|
|
|
slides_result = await self._sql_session.scalars(
|
|
select(SlideModel).where(SlideModel.presentation == self._presentation_id)
|
|
)
|
|
slides = sorted(list(slides_result), key=lambda slide: slide.index)
|
|
if not slides:
|
|
LOGGER.info(
|
|
"Chat memory miss for slide search (presentation_id=%s, reason=no_slides)",
|
|
self._presentation_id,
|
|
)
|
|
return []
|
|
|
|
query_lower = trimmed_query.lower()
|
|
query_tokens = set(re.findall(r"[a-z0-9]{2,}", query_lower))
|
|
ranked: list[tuple[int, dict[str, Any]]] = []
|
|
for slide in slides:
|
|
serialized = self._serialize_slide(slide)
|
|
searchable = serialized.lower()
|
|
|
|
score = 0
|
|
if query_lower in searchable:
|
|
score += 8
|
|
if query_tokens:
|
|
score += sum(1 for token in query_tokens if token in searchable)
|
|
if score <= 0:
|
|
continue
|
|
|
|
ranked.append(
|
|
(
|
|
score,
|
|
{
|
|
"slide_id": str(slide.id),
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"layout_id": slide.layout,
|
|
"snippet": self._build_snippet(serialized, query_lower),
|
|
"score": score,
|
|
},
|
|
)
|
|
)
|
|
|
|
ranked.sort(key=lambda item: (-item[0], item[1]["index"]))
|
|
results = [entry for _, entry in ranked[: max(1, limit)]]
|
|
LOGGER.info(
|
|
"Chat DB slide search completed (presentation_id=%s, query=%r, hits=%d)",
|
|
self._presentation_id,
|
|
trimmed_query,
|
|
len(results),
|
|
)
|
|
return results
|
|
|
|
async def get_slide_at_index(
|
|
self, index: int, *, include_full_content: bool = False
|
|
) -> dict[str, Any] | None:
|
|
slide = await self._sql_session.scalar(
|
|
select(SlideModel).where(
|
|
SlideModel.presentation == self._presentation_id,
|
|
SlideModel.index == index,
|
|
)
|
|
)
|
|
if not slide:
|
|
LOGGER.info(
|
|
"Chat memory miss for slide by index (presentation_id=%s, index=%d)",
|
|
self._presentation_id,
|
|
index,
|
|
)
|
|
return None
|
|
|
|
response: dict[str, Any] = {
|
|
"slide_id": str(slide.id),
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"layout_id": slide.layout,
|
|
"content_preview": self._build_snippet(
|
|
self._serialize_slide(slide),
|
|
query_lower="",
|
|
window=420,
|
|
),
|
|
"speaker_note": slide.speaker_note,
|
|
}
|
|
if include_full_content:
|
|
response["content"] = slide.content
|
|
response["ui"] = slide.ui
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is not None:
|
|
response["ui_summary"] = await self.get_slide_ui_elements(
|
|
index=slide.index,
|
|
include_full_json=False,
|
|
)
|
|
return response
|
|
|
|
async def get_outline_draft(self) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"found": False,
|
|
"message": "Presentation not found.",
|
|
"slide_count": 0,
|
|
"slides": [],
|
|
}
|
|
|
|
slides = self._normalize_outline_slides(presentation.outlines)
|
|
if not slides:
|
|
return {
|
|
"found": False,
|
|
"message": "No outline draft is available yet.",
|
|
"slide_count": 0,
|
|
"slides": [],
|
|
}
|
|
|
|
return {
|
|
"found": True,
|
|
"message": "Outline draft fetched successfully.",
|
|
"slide_count": len(slides),
|
|
"slides": [
|
|
{
|
|
"index": index,
|
|
"slide_number": index + 1,
|
|
"title": self._extract_outline_title(slide["content"]),
|
|
"content": slide["content"],
|
|
}
|
|
for index, slide in enumerate(slides)
|
|
],
|
|
}
|
|
|
|
async def add_outline(
|
|
self,
|
|
*,
|
|
content: str,
|
|
index: int | None = None,
|
|
) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"saved": False,
|
|
"message": "Presentation not found.",
|
|
}
|
|
|
|
slides = self._normalize_outline_slides(presentation.outlines)
|
|
if len(slides) >= MAX_NUMBER_OF_SLIDES:
|
|
return {
|
|
"saved": False,
|
|
"message": f"Outline slide limit reached. You can have at most {MAX_NUMBER_OF_SLIDES} outlines.",
|
|
"slide_count": len(slides),
|
|
"max_slide_count": MAX_NUMBER_OF_SLIDES,
|
|
}
|
|
insert_index = len(slides) if index is None else min(max(0, index), len(slides))
|
|
slides.insert(insert_index, {"content": normalize_outline_content(content.strip())})
|
|
await self._save_outline_slides(presentation, slides)
|
|
|
|
return {
|
|
"saved": True,
|
|
"action": "created",
|
|
"message": f"Outline slide added at index {insert_index}.",
|
|
"index": insert_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
async def update_outline(self, *, index: int, content: str) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"saved": False,
|
|
"message": "Presentation not found.",
|
|
}
|
|
|
|
slides = self._normalize_outline_slides(presentation.outlines)
|
|
target_index = max(0, index)
|
|
if target_index >= len(slides):
|
|
return {
|
|
"saved": False,
|
|
"message": f"No outline slide found at index {target_index}.",
|
|
"index": target_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
slides[target_index] = {"content": normalize_outline_content(content.strip())}
|
|
await self._save_outline_slides(presentation, slides)
|
|
|
|
return {
|
|
"saved": True,
|
|
"action": "updated",
|
|
"message": f"Outline slide at index {target_index} was updated.",
|
|
"index": target_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
async def delete_outline(self, *, index: int) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"deleted": False,
|
|
"message": "Presentation not found.",
|
|
}
|
|
|
|
slides = self._normalize_outline_slides(presentation.outlines)
|
|
target_index = max(0, index)
|
|
if target_index >= len(slides):
|
|
return {
|
|
"deleted": False,
|
|
"message": f"No outline slide found at index {target_index}.",
|
|
"index": target_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
slides.pop(target_index)
|
|
await self._save_outline_slides(presentation, slides)
|
|
|
|
return {
|
|
"deleted": True,
|
|
"action": "deleted",
|
|
"message": f"Outline slide at index {target_index} was deleted.",
|
|
"index": target_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
async def move_outline(self, *, from_index: int, to_index: int) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"saved": False,
|
|
"message": "Presentation not found.",
|
|
}
|
|
|
|
slides = self._normalize_outline_slides(presentation.outlines)
|
|
source_index = max(0, from_index)
|
|
if source_index >= len(slides):
|
|
return {
|
|
"saved": False,
|
|
"message": f"No outline slide found at index {source_index}.",
|
|
"from_index": source_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
destination_index = min(max(0, to_index), len(slides) - 1)
|
|
[slide] = slides[source_index : source_index + 1]
|
|
del slides[source_index]
|
|
slides.insert(destination_index, slide)
|
|
await self._save_outline_slides(presentation, slides)
|
|
|
|
return {
|
|
"saved": True,
|
|
"action": "moved",
|
|
"message": (
|
|
f"Outline slide moved from index {source_index} "
|
|
f"to index {destination_index}."
|
|
),
|
|
"from_index": source_index,
|
|
"to_index": destination_index,
|
|
"slide_count": len(slides),
|
|
}
|
|
|
|
async def get_available_layouts(self) -> list[dict[str, Any]]:
|
|
presentation = await self._sql_session.get(
|
|
PresentationModel, self._presentation_id
|
|
)
|
|
layout_model = await self._get_layout_model(presentation)
|
|
if not layout_model:
|
|
return []
|
|
|
|
return [
|
|
{
|
|
"id": layout.id,
|
|
"name": layout.name,
|
|
"description": layout.description,
|
|
}
|
|
for layout in layout_model.slides
|
|
]
|
|
|
|
async def get_available_blocks(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
layout_id: str | None = None,
|
|
element_type: str | None = None,
|
|
block_id: str | None = None,
|
|
include_full_content: bool = False,
|
|
max_results: int = 20,
|
|
) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(
|
|
PresentationModel, self._presentation_id
|
|
)
|
|
if not presentation:
|
|
return {
|
|
"found": False,
|
|
"count": 0,
|
|
"blocks": [],
|
|
"message": "Presentation not found.",
|
|
}
|
|
|
|
candidates = await self._collect_template_block_candidates(presentation)
|
|
query_text = (query or "").strip().lower()
|
|
layout_filter = (layout_id or "").strip()
|
|
element_filter = (element_type or "").strip().lower()
|
|
block_filter = (block_id or "").strip()
|
|
|
|
matches: list[tuple[int, int, dict[str, Any]]] = []
|
|
for source_index, candidate in enumerate(candidates):
|
|
if block_filter and candidate.get("block_id") != block_filter:
|
|
continue
|
|
if layout_filter and candidate.get("layout_id") != layout_filter:
|
|
continue
|
|
element_types = {
|
|
str(item).lower()
|
|
for item in candidate.get("element_types", [])
|
|
if item is not None
|
|
}
|
|
if element_filter and element_filter not in element_types:
|
|
continue
|
|
score = self._block_match_score(candidate, query_text)
|
|
if query_text and score <= 0:
|
|
continue
|
|
matches.append((score, source_index, candidate))
|
|
|
|
matches.sort(
|
|
key=lambda item: (
|
|
-item[0],
|
|
bool(item[2].get("decorative")),
|
|
item[1],
|
|
)
|
|
)
|
|
|
|
limit = min(max(max_results, 1), 50)
|
|
blocks = [
|
|
self._format_available_block(
|
|
candidate,
|
|
include_full_content=include_full_content,
|
|
)
|
|
for _, _, candidate in matches[:limit]
|
|
]
|
|
return {
|
|
"found": bool(blocks),
|
|
"count": len(blocks),
|
|
"total_matches": len(matches),
|
|
"blocks": blocks,
|
|
"truncated": len(matches) > len(blocks),
|
|
"message": (
|
|
f"Found {len(blocks)} matching block(s)."
|
|
if blocks
|
|
else "No matching blocks were found."
|
|
),
|
|
}
|
|
|
|
async def read_source_documents(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
max_chars: int | None = None,
|
|
) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(
|
|
PresentationModel, self._presentation_id
|
|
)
|
|
if not presentation:
|
|
return {
|
|
"found": False,
|
|
"message": "Presentation not found.",
|
|
"documents": [],
|
|
}
|
|
|
|
char_budget = min(
|
|
max(max_chars or DEFAULT_SOURCE_DOCUMENT_CHARS, 1000),
|
|
MAX_SOURCE_DOCUMENT_CHARS,
|
|
)
|
|
source_paths = [
|
|
path
|
|
for path in (presentation.file_paths or [])
|
|
if isinstance(path, str) and path.strip()
|
|
]
|
|
|
|
documents: list[dict[str, Any]] = []
|
|
errors: list[dict[str, str]] = []
|
|
remaining_chars = char_budget
|
|
|
|
for source_index, raw_path in enumerate(source_paths):
|
|
if remaining_chars <= 0:
|
|
break
|
|
|
|
name = os.path.basename(raw_path) or f"Document {source_index + 1}"
|
|
try:
|
|
resolved_path = TEMP_FILE_SERVICE.resolve_temp_path(
|
|
raw_path,
|
|
must_exist=True,
|
|
)
|
|
loader = DocumentsLoader(
|
|
file_paths=[resolved_path],
|
|
presentation_language=presentation.language,
|
|
)
|
|
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
|
|
await loader.load_documents(temp_dir=temp_dir)
|
|
parsed_text = loader.documents[0] if loader.documents else ""
|
|
except Exception as exc:
|
|
errors.append({"name": name, "error": str(exc)})
|
|
continue
|
|
|
|
trimmed = self._trim_document_text(parsed_text, remaining_chars)
|
|
if not trimmed:
|
|
errors.append({"name": name, "error": "No text was extracted."})
|
|
continue
|
|
|
|
documents.append(
|
|
{
|
|
"index": source_index,
|
|
"name": name,
|
|
"content": trimmed,
|
|
"truncated": len(parsed_text.strip()) > len(trimmed),
|
|
}
|
|
)
|
|
remaining_chars -= len(trimmed)
|
|
|
|
if documents:
|
|
omitted_count = max(0, len(source_paths) - len(documents) - len(errors))
|
|
return {
|
|
"found": True,
|
|
"source": "uploaded_files",
|
|
"count": len(documents),
|
|
"documents": documents,
|
|
"errors": errors,
|
|
"omitted_count": omitted_count,
|
|
"message": f"Read {len(documents)} source document(s).",
|
|
}
|
|
|
|
fallback_query = (
|
|
(query or "").strip()
|
|
or "uploaded source document extracted PDF document text summary"
|
|
)
|
|
fallback_context = await MEM0_PRESENTATION_MEMORY_SERVICE.retrieve_context(
|
|
self._presentation_id,
|
|
fallback_query,
|
|
)
|
|
if fallback_context.strip():
|
|
return {
|
|
"found": True,
|
|
"source": "presentation_memory",
|
|
"count": 1,
|
|
"documents": [
|
|
{
|
|
"index": 0,
|
|
"name": "Indexed source document context",
|
|
"content": self._trim_document_text(
|
|
fallback_context,
|
|
char_budget,
|
|
),
|
|
"truncated": len(fallback_context.strip()) > char_budget,
|
|
}
|
|
],
|
|
"errors": errors,
|
|
"message": (
|
|
"Source upload files were unavailable, so indexed document "
|
|
"memory was returned instead."
|
|
),
|
|
}
|
|
|
|
if source_paths:
|
|
return {
|
|
"found": False,
|
|
"source": "uploaded_files",
|
|
"count": 0,
|
|
"documents": [],
|
|
"errors": errors,
|
|
"message": (
|
|
"Source document files are recorded for this presentation, "
|
|
"but no readable text could be extracted."
|
|
),
|
|
}
|
|
|
|
return {
|
|
"found": False,
|
|
"source": "presentation",
|
|
"count": 0,
|
|
"documents": [],
|
|
"errors": errors,
|
|
"message": "No uploaded source documents are linked to this presentation.",
|
|
}
|
|
|
|
async def get_content_schema_from_layout_id(
|
|
self, layout_id: str
|
|
) -> dict[str, Any] | None:
|
|
layout = await self._get_layout_by_id(layout_id)
|
|
if not layout:
|
|
return None
|
|
return layout.json_schema
|
|
|
|
async def _get_presentation_icon_weight(
|
|
self, presentation: PresentationModel | None = None
|
|
) -> str:
|
|
if presentation is None:
|
|
presentation = await self._sql_session.get(
|
|
PresentationModel, self._presentation_id
|
|
)
|
|
layout_model = await self._get_layout_model(presentation)
|
|
return layout_model.icon_weight if layout_model else DEFAULT_ICON_WEIGHT
|
|
|
|
async def generate_image(self, prompt: str) -> str:
|
|
image_generation_service = ImageGenerationService(get_images_directory())
|
|
image = await image_generation_service.generate_image(ImagePrompt(prompt=prompt))
|
|
|
|
if isinstance(image, ImageAsset):
|
|
self._sql_session.add(image)
|
|
await self._sql_session.commit()
|
|
return filesystem_image_path_to_app_data_url(image.path)
|
|
|
|
return normalize_slide_asset_url(str(image))
|
|
|
|
async def generate_icon(self, query: str) -> str:
|
|
icons = await ICON_FINDER_SERVICE.search_icons(
|
|
query,
|
|
k=1,
|
|
weight=await self._get_presentation_icon_weight(),
|
|
)
|
|
if icons:
|
|
return normalize_slide_asset_url(icons[0])
|
|
return normalize_slide_asset_url("/static/icons/placeholder.svg")
|
|
|
|
async def add_blank_slide(self, *, index: int | None = None) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {"added": False, "message": "Presentation not found."}
|
|
|
|
slides_result = await self._sql_session.scalars(
|
|
select(SlideModel)
|
|
.where(SlideModel.presentation == self._presentation_id)
|
|
.order_by(SlideModel.index)
|
|
)
|
|
slides = list(slides_result)
|
|
if len(slides) >= MAX_NUMBER_OF_SLIDES:
|
|
return {
|
|
"added": False,
|
|
"message": f"Slide limit reached. You can have at most {MAX_NUMBER_OF_SLIDES} slides.",
|
|
"slide_count": len(slides),
|
|
"max_slide_count": MAX_NUMBER_OF_SLIDES,
|
|
}
|
|
insert_index = (
|
|
len(slides)
|
|
if index is None
|
|
else min(max(0, index), len(slides))
|
|
)
|
|
for slide in sorted(
|
|
[slide for slide in slides if slide.index >= insert_index],
|
|
key=lambda each: each.index,
|
|
reverse=True,
|
|
):
|
|
slide.index += 1
|
|
self._sql_session.add(slide)
|
|
|
|
presentation.n_slides = len(slides) + 1
|
|
self._sql_session.add(presentation)
|
|
new_slide = SlideModel(
|
|
presentation=self._presentation_id,
|
|
layout_group=self._resolve_layout_group(presentation=presentation),
|
|
layout=BLANK_SLIDE_LAYOUT_ID,
|
|
index=insert_index,
|
|
content={},
|
|
speaker_note="",
|
|
ui=self._blank_slide_ui(),
|
|
)
|
|
self._sql_session.add(new_slide)
|
|
await self._sql_session.commit()
|
|
await self._sql_session.refresh(new_slide)
|
|
return {
|
|
"added": True,
|
|
"message": f"Blank slide added at index {insert_index}.",
|
|
"slide_id": str(new_slide.id),
|
|
"index": insert_index,
|
|
"slide_number": insert_index + 1,
|
|
}
|
|
|
|
async def save_slide(
|
|
self,
|
|
*,
|
|
content: dict[str, Any],
|
|
layout_id: str,
|
|
index: int,
|
|
replace_old_slide_at_index: bool,
|
|
) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"saved": False,
|
|
"message": "Presentation not found.",
|
|
"validation_errors": [],
|
|
}
|
|
|
|
layout = await self._get_layout_by_id(layout_id, presentation=presentation)
|
|
if not layout:
|
|
return {
|
|
"saved": False,
|
|
"message": f"Layout '{layout_id}' was not found in this presentation.",
|
|
"validation_errors": [f"Unknown layout_id '{layout_id}'."],
|
|
}
|
|
icon_weight = await self._get_presentation_icon_weight(presentation)
|
|
layout_group = self._resolve_layout_group(presentation=presentation)
|
|
|
|
validation_errors = self._validate_slide_content(
|
|
content=content,
|
|
schema=layout.json_schema,
|
|
)
|
|
if validation_errors:
|
|
return {
|
|
"saved": False,
|
|
"message": "Slide content failed schema validation.",
|
|
"validation_errors": validation_errors,
|
|
}
|
|
|
|
target_index = max(0, index)
|
|
image_generation_service = ImageGenerationService(get_images_directory())
|
|
|
|
if replace_old_slide_at_index:
|
|
existing_slide = await self._sql_session.scalar(
|
|
select(SlideModel).where(
|
|
SlideModel.presentation == self._presentation_id,
|
|
SlideModel.index == target_index,
|
|
)
|
|
)
|
|
if not existing_slide:
|
|
return {
|
|
"saved": False,
|
|
"message": f"No existing slide found at index {target_index} to replace.",
|
|
"validation_errors": [],
|
|
}
|
|
|
|
updated_content = copy.deepcopy(content)
|
|
image_warnings: list[dict] = []
|
|
new_assets = await process_old_and_new_slides_and_fetch_assets(
|
|
image_generation_service=image_generation_service,
|
|
old_slide_content=existing_slide.content or {},
|
|
new_slide_content=updated_content,
|
|
icon_weight=icon_weight,
|
|
use_template_v2_asset_fields=existing_slide.layout_group.startswith(
|
|
"template-v2"
|
|
),
|
|
allow_image_fallback=True,
|
|
image_warnings=image_warnings,
|
|
)
|
|
for warning in image_warnings:
|
|
LOGGER.warning(
|
|
"Chat slide replacement image generation warning: "
|
|
"presentation_id=%s slide_index=%s detail=%s",
|
|
self._presentation_id,
|
|
target_index,
|
|
warning.get("detail"),
|
|
)
|
|
|
|
existing_slide.id = uuid.uuid4()
|
|
existing_slide.layout = layout_id
|
|
existing_slide.layout_group = layout_group
|
|
existing_slide.content = updated_content
|
|
existing_slide.ui = await self._build_template_v2_slide_ui(
|
|
presentation=presentation,
|
|
layout_id=layout_id,
|
|
content=updated_content,
|
|
)
|
|
existing_slide.speaker_note = self._extract_speaker_note(updated_content)
|
|
self._sql_session.add(existing_slide)
|
|
self._sql_session.add_all(new_assets)
|
|
await self._sql_session.commit()
|
|
|
|
await MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit(
|
|
presentation_id=self._presentation_id,
|
|
slide_index=target_index,
|
|
edit_prompt=f"[chat_tool_save_slide_replace] layout_id={layout_id}",
|
|
edited_slide_content=updated_content,
|
|
)
|
|
|
|
return {
|
|
"saved": True,
|
|
"action": "replaced",
|
|
"message": f"Slide at index {target_index} was replaced successfully.",
|
|
"slide_id": str(existing_slide.id),
|
|
"index": target_index,
|
|
}
|
|
|
|
slides_result = await self._sql_session.scalars(
|
|
select(SlideModel)
|
|
.where(SlideModel.presentation == self._presentation_id)
|
|
.order_by(SlideModel.index)
|
|
)
|
|
slides = list(slides_result)
|
|
if len(slides) >= MAX_NUMBER_OF_SLIDES:
|
|
return {
|
|
"saved": False,
|
|
"message": f"Slide limit reached. You can have at most {MAX_NUMBER_OF_SLIDES} slides.",
|
|
"validation_errors": [],
|
|
"slide_count": len(slides),
|
|
"max_slide_count": MAX_NUMBER_OF_SLIDES,
|
|
}
|
|
|
|
if slides:
|
|
max_index = max(slide.index for slide in slides)
|
|
insert_index = min(target_index, max_index + 1)
|
|
slides_to_shift = [slide for slide in slides if slide.index >= insert_index]
|
|
else:
|
|
insert_index = 0
|
|
slides_to_shift = []
|
|
|
|
for slide in sorted(slides_to_shift, key=lambda each: each.index, reverse=True):
|
|
slide.index += 1
|
|
self._sql_session.add(slide)
|
|
|
|
new_slide_content = copy.deepcopy(content)
|
|
new_slide = SlideModel(
|
|
presentation=self._presentation_id,
|
|
layout_group=layout_group,
|
|
layout=layout_id,
|
|
index=insert_index,
|
|
content=new_slide_content,
|
|
speaker_note=self._extract_speaker_note(new_slide_content),
|
|
)
|
|
image_warnings: list[dict] = []
|
|
new_assets = await process_slide_and_fetch_assets(
|
|
image_generation_service=image_generation_service,
|
|
slide=new_slide,
|
|
icon_weight=icon_weight,
|
|
allow_image_fallback=True,
|
|
image_warnings=image_warnings,
|
|
)
|
|
for warning in image_warnings:
|
|
LOGGER.warning(
|
|
"Chat slide image generation warning: presentation_id=%s detail=%s",
|
|
self._presentation_id,
|
|
warning.get("detail"),
|
|
)
|
|
new_slide.ui = await self._build_template_v2_slide_ui(
|
|
presentation=presentation,
|
|
layout_id=layout_id,
|
|
content=new_slide.content,
|
|
)
|
|
|
|
self._sql_session.add(new_slide)
|
|
self._sql_session.add_all(new_assets)
|
|
await self._sql_session.commit()
|
|
await self._sql_session.refresh(new_slide)
|
|
|
|
await MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit(
|
|
presentation_id=self._presentation_id,
|
|
slide_index=insert_index,
|
|
edit_prompt=f"[chat_tool_save_slide_new] layout_id={layout_id}",
|
|
edited_slide_content=new_slide.content,
|
|
)
|
|
|
|
return {
|
|
"saved": True,
|
|
"action": "created",
|
|
"message": f"New slide saved at index {insert_index}.",
|
|
"slide_id": str(new_slide.id),
|
|
"index": insert_index,
|
|
"shifted_slide_count": len(slides_to_shift),
|
|
}
|
|
|
|
async def delete_slide(self, *, index: int) -> dict[str, Any]:
|
|
target_index = max(0, index)
|
|
slide = await self._sql_session.scalar(
|
|
select(SlideModel).where(
|
|
SlideModel.presentation == self._presentation_id,
|
|
SlideModel.index == target_index,
|
|
)
|
|
)
|
|
if not slide:
|
|
return {
|
|
"deleted": False,
|
|
"message": f"No slide found at index {target_index}.",
|
|
"index": target_index,
|
|
}
|
|
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
slides_result = await self._sql_session.scalars(
|
|
select(SlideModel)
|
|
.where(SlideModel.presentation == self._presentation_id)
|
|
.order_by(SlideModel.index)
|
|
)
|
|
slides = sorted(list(slides_result), key=lambda each: each.index)
|
|
deleted_slide_id = str(slide.id)
|
|
|
|
if len(slides) <= 1:
|
|
fallback_slide = self._create_blank_slide_from_reference(
|
|
presentation=presentation,
|
|
source_slide=slide,
|
|
index=0,
|
|
)
|
|
await self._sql_session.delete(slide)
|
|
if presentation:
|
|
presentation.n_slides = 1
|
|
self._sql_session.add(presentation)
|
|
self._sql_session.add(fallback_slide)
|
|
await self._sql_session.commit()
|
|
await self._sql_session.refresh(fallback_slide)
|
|
|
|
return {
|
|
"deleted": True,
|
|
"message": "Deleted the final slide and added a blank fallback slide.",
|
|
"deleted_slide_id": deleted_slide_id,
|
|
"slide_id": str(fallback_slide.id),
|
|
"index": 0,
|
|
"slide_number": 1,
|
|
"shifted_slide_count": 0,
|
|
"blank_fallback": True,
|
|
}
|
|
|
|
await self._sql_session.delete(slide)
|
|
|
|
remaining_slides = [
|
|
each_slide for each_slide in slides if each_slide.id != slide.id
|
|
]
|
|
shifted_count = 0
|
|
for each_slide in remaining_slides:
|
|
if each_slide.index <= target_index:
|
|
continue
|
|
each_slide.index -= 1
|
|
self._sql_session.add(each_slide)
|
|
shifted_count += 1
|
|
|
|
if presentation:
|
|
presentation.n_slides = len(remaining_slides)
|
|
self._sql_session.add(presentation)
|
|
|
|
await self._sql_session.commit()
|
|
|
|
return {
|
|
"deleted": True,
|
|
"message": f"Slide at index {target_index} was deleted successfully.",
|
|
"deleted_slide_id": deleted_slide_id,
|
|
"index": target_index,
|
|
"shifted_slide_count": shifted_count,
|
|
}
|
|
|
|
async def _get_slide_by_index(self, index: int) -> SlideModel | None:
|
|
return await self._sql_session.scalar(
|
|
select(SlideModel).where(
|
|
SlideModel.presentation == self._presentation_id,
|
|
SlideModel.index == max(0, index),
|
|
)
|
|
)
|
|
|
|
async def _get_current_theme(self) -> dict[str, Any] | None:
|
|
presentation = await self._sql_session.get(
|
|
PresentationModel,
|
|
self._presentation_id,
|
|
)
|
|
return (
|
|
copy.deepcopy(presentation.theme)
|
|
if presentation and isinstance(presentation.theme, dict)
|
|
else None
|
|
)
|
|
|
|
@staticmethod
|
|
def _blank_slide_ui() -> dict[str, Any]:
|
|
return copy.deepcopy(BLANK_TEMPLATE_V2_LAYOUT)
|
|
|
|
def _create_blank_slide_from_reference(
|
|
self,
|
|
*,
|
|
presentation: PresentationModel | None,
|
|
source_slide: SlideModel,
|
|
index: int,
|
|
) -> SlideModel:
|
|
layout_group = (
|
|
source_slide.layout_group.strip()
|
|
if isinstance(source_slide.layout_group, str)
|
|
else ""
|
|
)
|
|
if not layout_group and presentation:
|
|
layout_group = self._resolve_layout_group(presentation=presentation)
|
|
if not layout_group:
|
|
layout_group = "presentation"
|
|
|
|
layout = (
|
|
f"{layout_group}:{BLANK_SLIDE_LAYOUT_ID}"
|
|
if layout_group.startswith("custom-")
|
|
else BLANK_SLIDE_LAYOUT_ID
|
|
)
|
|
return SlideModel(
|
|
presentation=self._presentation_id,
|
|
layout_group=layout_group,
|
|
layout=layout,
|
|
index=index,
|
|
content={},
|
|
speaker_note="",
|
|
ui=self._blank_slide_ui(),
|
|
)
|
|
|
|
@staticmethod
|
|
def _slide_ui_layout(slide: SlideModel) -> dict[str, Any] | None:
|
|
ui = slide.ui
|
|
if not isinstance(ui, dict):
|
|
return None
|
|
if not isinstance(ui.get("components"), list):
|
|
return None
|
|
return ui
|
|
|
|
async def _save_slide_ui(self, slide: SlideModel, ui: dict[str, Any]) -> None:
|
|
# Persist the mutated raw layout dict directly. We intentionally avoid
|
|
# round-tripping through pydantic so richer runtime fields on the slide
|
|
# UI (assets, tiptap ids, etc.) are preserved untouched.
|
|
slide.ui = ui
|
|
self._sql_session.add(slide)
|
|
await self._sql_session.commit()
|
|
await self._sql_session.refresh(slide)
|
|
|
|
async def get_slide_ui_elements(
|
|
self, *, index: int, include_full_json: bool = False
|
|
) -> dict[str, Any]:
|
|
# Imported lazily so chat startup does not load rendered-slide helper code
|
|
# unless the assistant actually inspects UI elements.
|
|
from services.chat.slide_ui_helpers import (
|
|
_collect_editable_elements,
|
|
_compact_components,
|
|
)
|
|
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {
|
|
"found": False,
|
|
"message": f"No slide found at index {max(0, index)}.",
|
|
}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"found": True,
|
|
"editable": False,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"message": (
|
|
"This slide is not a rendered template (ui) slide. Use "
|
|
"saveSlide or updateSlide to edit it instead."
|
|
),
|
|
}
|
|
|
|
editable = _collect_editable_elements(ui, include_visual_elements=True)
|
|
response: dict[str, Any] = {
|
|
"found": True,
|
|
"editable": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"layout_id": ui.get("id"),
|
|
"description": ui.get("description"),
|
|
"component_count": len(ui.get("components", [])),
|
|
"components": _compact_components(ui),
|
|
"editable_count": len(editable),
|
|
"elements": editable,
|
|
"message": (
|
|
f"Slide {slide.index + 1} renders from its ui layout with "
|
|
f"{len(ui.get('components', []))} component(s) and "
|
|
f"{len(editable)} editable element(s)."
|
|
),
|
|
}
|
|
if include_full_json:
|
|
response["ui"] = ui
|
|
return response
|
|
|
|
async def update_slide_ui_element(
|
|
self,
|
|
*,
|
|
index: int,
|
|
element_path: str,
|
|
text: str | None = None,
|
|
items: list[str] | None = None,
|
|
table_cell: dict[str, Any] | None = None,
|
|
table: dict[str, Any] | None = None,
|
|
chart: dict[str, Any] | None = None,
|
|
element_patch: dict[str, Any] | None = None,
|
|
position: dict[str, Any] | None = None,
|
|
size: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
from services.chat.slide_ui_helpers import (
|
|
_apply_element_style_patch,
|
|
_apply_image_element_value,
|
|
_component_id_for_path,
|
|
_content_update_requested_for_type,
|
|
_looks_like_asset_reference,
|
|
_normalize_chart_element,
|
|
_resolve_element_path,
|
|
_resolve_image_update_payload,
|
|
_update_chart_element,
|
|
_update_table_element,
|
|
_update_table_cell,
|
|
_update_text_element,
|
|
_update_text_list_element,
|
|
)
|
|
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {"updated": False, "message": f"No slide found at index {max(0, index)}."}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"updated": False,
|
|
"message": "This slide has no editable ui layout; use saveSlide instead.",
|
|
}
|
|
|
|
ui = copy.deepcopy(ui)
|
|
element = _resolve_element_path(ui, element_path)
|
|
element_type = str(element.get("type") or "")
|
|
theme = await self._get_current_theme()
|
|
element_updated = False
|
|
if element_patch is not None:
|
|
if "type" in element_patch and not isinstance(element_patch.get("type"), str):
|
|
raise ValueError("element.type must be a string when provided.")
|
|
self._merge_ui_patch(element, element_patch)
|
|
self._sync_ui_text_fields(element)
|
|
_apply_element_style_patch(element, element_patch)
|
|
element_type = str(element.get("type") or element_type)
|
|
if element_type == "chart":
|
|
_normalize_chart_element(element, theme)
|
|
element_updated = True
|
|
content_update_requested = _content_update_requested_for_type(
|
|
element_type,
|
|
text=text,
|
|
items=items,
|
|
table_cell=table_cell,
|
|
table=table,
|
|
chart=chart,
|
|
)
|
|
|
|
if content_update_requested and element_type == "text":
|
|
if text is None:
|
|
raise ValueError("text is required for text elements.")
|
|
_update_text_element(element, text)
|
|
elif content_update_requested and element_type == "text-list":
|
|
if items is None:
|
|
raise ValueError("items is required for text-list elements.")
|
|
_update_text_list_element(element, items)
|
|
elif content_update_requested and element_type == "table":
|
|
if table is not None:
|
|
_update_table_element(element, table)
|
|
elif table_cell is not None:
|
|
_update_table_cell(element, table_cell)
|
|
else:
|
|
raise ValueError("table or tableCell is required for table elements.")
|
|
elif content_update_requested and element_type == "chart":
|
|
if chart is None:
|
|
raise ValueError("chart is required for chart elements.")
|
|
_update_chart_element(element, chart, theme)
|
|
elif content_update_requested and element_type == "image":
|
|
payload = _resolve_image_update_payload(text, items)
|
|
if payload is None:
|
|
raise ValueError(
|
|
"Image/icon updates require `text` with a URL returned by "
|
|
"generateAssets, generateImage, or generateIcon."
|
|
)
|
|
if isinstance(payload, str) and not _looks_like_asset_reference(payload):
|
|
generated_url = await (
|
|
self.generate_icon(payload)
|
|
if element.get("is_icon") is True
|
|
else self.generate_image(payload)
|
|
)
|
|
element["data"] = generated_url
|
|
element["prompt"] = payload.strip()
|
|
else:
|
|
_apply_image_element_value(element, payload)
|
|
elif content_update_requested:
|
|
raise ValueError(f"Element type '{element_type}' is not content-editable.")
|
|
|
|
geometry_updated = self._update_ui_box(element, position=position, size=size)
|
|
if not content_update_requested and not geometry_updated and not element_updated:
|
|
raise ValueError("No element content or geometry update was provided.")
|
|
|
|
await self._save_slide_ui(slide, ui)
|
|
component_id = _component_id_for_path(ui, element_path)
|
|
return {
|
|
"updated": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"element_path": element_path,
|
|
"element_type": element_type,
|
|
"position": element.get("position"),
|
|
"size": element.get("size"),
|
|
"message": (
|
|
f"Updated {element_type} content on slide {slide.index + 1}."
|
|
),
|
|
}
|
|
|
|
async def update_slide_ui_component(
|
|
self,
|
|
*,
|
|
index: int,
|
|
component_id: str,
|
|
action: str | None = None,
|
|
component_ids: list[str] | None = None,
|
|
position: dict[str, Any] | None = None,
|
|
size: dict[str, Any] | None = None,
|
|
replacement_component: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {"updated": False, "message": f"No slide found at index {max(0, index)}."}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"updated": False,
|
|
"message": "This slide has no editable ui layout; use saveSlide instead.",
|
|
}
|
|
|
|
ui = copy.deepcopy(ui)
|
|
components = ui.get("components")
|
|
if not isinstance(components, list):
|
|
return {"updated": False, "message": "Slide has no components list."}
|
|
|
|
normalized_action = self._normalize_component_action(action)
|
|
|
|
if normalized_action == "ungroup":
|
|
return await self._ungroup_slide_ui_component(
|
|
slide=slide,
|
|
ui=ui,
|
|
components=components,
|
|
component_id=component_id,
|
|
)
|
|
if normalized_action == "group":
|
|
return await self._group_slide_ui_components(
|
|
slide=slide,
|
|
ui=ui,
|
|
components=components,
|
|
component_id=component_id,
|
|
component_ids=component_ids or [component_id],
|
|
)
|
|
if normalized_action == "duplicate":
|
|
return await self._duplicate_slide_ui_component(
|
|
slide=slide,
|
|
ui=ui,
|
|
components=components,
|
|
component_id=component_id,
|
|
)
|
|
if normalized_action in {
|
|
"bring-to-front",
|
|
"bring-forward",
|
|
"send-backward",
|
|
"send-to-back",
|
|
}:
|
|
return await self._reorder_slide_ui_component(
|
|
slide=slide,
|
|
ui=ui,
|
|
components=components,
|
|
component_id=component_id,
|
|
action=normalized_action,
|
|
)
|
|
|
|
component = next(
|
|
(
|
|
candidate
|
|
for candidate in components
|
|
if isinstance(candidate, dict) and candidate.get("id") == component_id
|
|
),
|
|
None,
|
|
)
|
|
if component is None:
|
|
return {
|
|
"updated": False,
|
|
"message": f"Component '{component_id}' was not found.",
|
|
}
|
|
updated = False
|
|
if replacement_component is not None:
|
|
from services.chat.slide_ui_helpers import (
|
|
_normalize_chart_tree,
|
|
_normalize_image_tree,
|
|
_validate_chart_insert_tree,
|
|
)
|
|
|
|
if not isinstance(replacement_component.get("elements"), list):
|
|
raise ValueError("replacement component must include elements.")
|
|
replacement = copy.deepcopy(replacement_component)
|
|
_normalize_image_tree(replacement)
|
|
_validate_chart_insert_tree(replacement)
|
|
replacement["id"] = component_id
|
|
replacement.setdefault("description", component.get("description"))
|
|
replacement.setdefault("position", component.get("position"))
|
|
replacement.setdefault("size", component.get("size"))
|
|
component_index = components.index(component)
|
|
components[component_index] = replacement
|
|
component = replacement
|
|
self._sync_ui_text_fields(component)
|
|
self._normalize_added_visual_block(component)
|
|
_normalize_chart_tree(component, await self._get_current_theme())
|
|
self._fit_component_to_stage(component)
|
|
updated = True
|
|
if size is not None:
|
|
# Match the editor's corner-resize behavior. Updating only the
|
|
# component frame leaves a standalone chart/image/table at its old
|
|
# size and creates empty space inside the enlarged selection box.
|
|
self._scale_component_contents_for_resize(component, size)
|
|
if self._update_ui_box(component, position=position, size=size):
|
|
updated = True
|
|
if not updated:
|
|
raise ValueError("No component geometry update was provided.")
|
|
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"updated": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"position": component.get("position"),
|
|
"size": component.get("size"),
|
|
"message": f"Updated component '{component_id}' on slide {slide.index + 1}.",
|
|
}
|
|
|
|
async def _ungroup_slide_ui_component(
|
|
self,
|
|
*,
|
|
slide: SlideModel,
|
|
ui: dict[str, Any],
|
|
components: list[Any],
|
|
component_id: str,
|
|
) -> dict[str, Any]:
|
|
from services.chat.slide_ui_helpers import _ungrouped_components_from_component
|
|
|
|
component_index, component = next(
|
|
(
|
|
(idx, candidate)
|
|
for idx, candidate in enumerate(components)
|
|
if isinstance(candidate, dict) and candidate.get("id") == component_id
|
|
),
|
|
(None, None),
|
|
)
|
|
if component_index is None or not isinstance(component, dict):
|
|
return {
|
|
"updated": False,
|
|
"message": f"Component '{component_id}' was not found.",
|
|
}
|
|
|
|
parts = _ungrouped_components_from_component(
|
|
component,
|
|
component_index,
|
|
used_ids={
|
|
str(item.get("id"))
|
|
for idx, item in enumerate(components)
|
|
if idx != component_index and isinstance(item, dict)
|
|
},
|
|
)
|
|
if not parts:
|
|
raise ValueError("Component does not contain safely separable elements.")
|
|
|
|
components[component_index : component_index + 1] = parts
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"updated": True,
|
|
"action": "ungrouped",
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"created_component_ids": [part["id"] for part in parts],
|
|
"message": f"Ungrouped component '{component_id}' on slide {slide.index + 1}.",
|
|
}
|
|
|
|
async def _group_slide_ui_components(
|
|
self,
|
|
*,
|
|
slide: SlideModel,
|
|
ui: dict[str, Any],
|
|
components: list[Any],
|
|
component_id: str,
|
|
component_ids: list[str],
|
|
) -> dict[str, Any]:
|
|
requested_ids = []
|
|
for raw_id in [component_id, *component_ids]:
|
|
if raw_id and raw_id not in requested_ids:
|
|
requested_ids.append(raw_id)
|
|
if len(requested_ids) < 2:
|
|
raise ValueError("Grouping requires at least two component ids.")
|
|
|
|
selected: list[tuple[int, dict[str, Any]]] = []
|
|
for idx, component in enumerate(components):
|
|
if isinstance(component, dict) and component.get("id") in requested_ids:
|
|
selected.append((idx, component))
|
|
found_ids = {str(component.get("id")) for _, component in selected}
|
|
missing_ids = [item for item in requested_ids if item not in found_ids]
|
|
if missing_ids:
|
|
raise ValueError(f"Component(s) not found: {', '.join(missing_ids)}")
|
|
|
|
boxes = [
|
|
self._component_box(component)
|
|
for _, component in selected
|
|
]
|
|
if any(box is None for box in boxes):
|
|
raise ValueError("Cannot group components without valid position and size.")
|
|
typed_boxes = [box for box in boxes if box is not None]
|
|
left = min(box["x"] for box in typed_boxes)
|
|
top = min(box["y"] for box in typed_boxes)
|
|
right = max(box["x"] + box["width"] for box in typed_boxes)
|
|
bottom = max(box["y"] + box["height"] for box in typed_boxes)
|
|
|
|
grouped_elements: list[dict[str, Any]] = []
|
|
for (_, component), box in zip(selected, typed_boxes):
|
|
elements = component.get("elements")
|
|
if not isinstance(elements, list):
|
|
continue
|
|
for element in elements:
|
|
if not isinstance(element, dict):
|
|
continue
|
|
copied = copy.deepcopy(element)
|
|
element_position = copied.get("position")
|
|
if isinstance(element_position, dict):
|
|
copied["position"] = {
|
|
"x": float(box["x"]) + float(element_position.get("x") or 0) - left,
|
|
"y": float(box["y"]) + float(element_position.get("y") or 0) - top,
|
|
}
|
|
else:
|
|
copied["position"] = {
|
|
"x": float(box["x"]) - left,
|
|
"y": float(box["y"]) - top,
|
|
}
|
|
grouped_elements.append(copied)
|
|
|
|
if not grouped_elements:
|
|
raise ValueError("Cannot group components with no elements.")
|
|
|
|
first_index = min(idx for idx, _ in selected)
|
|
selected_indices = {idx for idx, _ in selected}
|
|
group_id = self._unique_ui_component_id(component_id or "group", [
|
|
component
|
|
for idx, component in enumerate(components)
|
|
if idx not in selected_indices
|
|
])
|
|
group_component = {
|
|
"id": group_id,
|
|
"description": "Grouped component",
|
|
"position": {"x": left, "y": top},
|
|
"size": {"width": right - left, "height": bottom - top},
|
|
"elements": grouped_elements,
|
|
}
|
|
|
|
next_components = [
|
|
component
|
|
for idx, component in enumerate(components)
|
|
if idx not in selected_indices
|
|
]
|
|
next_components.insert(first_index, group_component)
|
|
ui["components"] = next_components
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"updated": True,
|
|
"action": "grouped",
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": group_id,
|
|
"grouped_component_ids": requested_ids,
|
|
"message": f"Grouped {len(selected)} components on slide {slide.index + 1}.",
|
|
}
|
|
|
|
async def _duplicate_slide_ui_component(
|
|
self,
|
|
*,
|
|
slide: SlideModel,
|
|
ui: dict[str, Any],
|
|
components: list[Any],
|
|
component_id: str,
|
|
) -> dict[str, Any]:
|
|
component_index, component = self._find_ui_component(components, component_id)
|
|
if component_index is None or not isinstance(component, dict):
|
|
return {
|
|
"updated": False,
|
|
"message": f"Component '{component_id}' was not found.",
|
|
}
|
|
|
|
duplicate = copy.deepcopy(component)
|
|
duplicate_id = self._unique_ui_component_id(f"{component_id}_copy", components)
|
|
duplicate["id"] = duplicate_id
|
|
position = duplicate.get("position")
|
|
if isinstance(position, dict):
|
|
duplicate["position"] = {
|
|
**position,
|
|
"x": float(position.get("x") or 0) + 16,
|
|
"y": float(position.get("y") or 0) + 16,
|
|
}
|
|
components.insert(component_index + 1, duplicate)
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"updated": True,
|
|
"action": "duplicated",
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": duplicate_id,
|
|
"source_component_id": component_id,
|
|
"message": f"Duplicated component '{component_id}' on slide {slide.index + 1}.",
|
|
}
|
|
|
|
async def _reorder_slide_ui_component(
|
|
self,
|
|
*,
|
|
slide: SlideModel,
|
|
ui: dict[str, Any],
|
|
components: list[Any],
|
|
component_id: str,
|
|
action: str,
|
|
) -> dict[str, Any]:
|
|
component_index, component = self._find_ui_component(components, component_id)
|
|
if component_index is None or component is None:
|
|
return {
|
|
"updated": False,
|
|
"message": f"Component '{component_id}' was not found.",
|
|
}
|
|
|
|
target_index = self._component_layer_target_index(
|
|
component_index,
|
|
len(components),
|
|
action,
|
|
)
|
|
if target_index == component_index:
|
|
return {
|
|
"updated": False,
|
|
"action": action,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"message": f"Component '{component_id}' is already at that layer.",
|
|
}
|
|
|
|
moved = components.pop(component_index)
|
|
components.insert(target_index, moved)
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"updated": True,
|
|
"action": action,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"component_index": target_index,
|
|
"message": f"Reordered component '{component_id}' on slide {slide.index + 1}.",
|
|
}
|
|
|
|
@staticmethod
|
|
def _normalize_component_action(action: str | None) -> str:
|
|
return {
|
|
"bringToFront": "bring-to-front",
|
|
"bringForward": "bring-forward",
|
|
"sendBackward": "send-backward",
|
|
"sendToBack": "send-to-back",
|
|
}.get(action or "update", action or "update")
|
|
|
|
@staticmethod
|
|
def _component_layer_target_index(
|
|
component_index: int,
|
|
component_count: int,
|
|
action: str,
|
|
) -> int:
|
|
if component_count <= 1:
|
|
return component_index
|
|
if action == "send-to-back":
|
|
return 0
|
|
if action == "send-backward":
|
|
return max(0, component_index - 1)
|
|
if action == "bring-forward":
|
|
return min(component_count - 1, component_index + 1)
|
|
if action == "bring-to-front":
|
|
return component_count - 1
|
|
return component_index
|
|
|
|
@staticmethod
|
|
def _find_ui_component(
|
|
components: list[Any],
|
|
component_id: str,
|
|
) -> tuple[int | None, dict[str, Any] | None]:
|
|
return next(
|
|
(
|
|
(idx, candidate)
|
|
for idx, candidate in enumerate(components)
|
|
if isinstance(candidate, dict) and candidate.get("id") == component_id
|
|
),
|
|
(None, None),
|
|
)
|
|
|
|
@staticmethod
|
|
def _component_box(component: dict[str, Any]) -> dict[str, float] | None:
|
|
position = component.get("position")
|
|
size = component.get("size")
|
|
if not isinstance(position, dict) or not isinstance(size, dict):
|
|
return None
|
|
x = position.get("x")
|
|
y = position.get("y")
|
|
width = size.get("width")
|
|
height = size.get("height")
|
|
if not all(isinstance(value, (int, float)) for value in (x, y, width, height)):
|
|
return None
|
|
if width <= 0 or height <= 0:
|
|
return None
|
|
return {
|
|
"x": float(x),
|
|
"y": float(y),
|
|
"width": float(width),
|
|
"height": float(height),
|
|
}
|
|
|
|
@classmethod
|
|
def _scale_component_contents_for_resize(
|
|
cls,
|
|
component: dict[str, Any],
|
|
target_size: dict[str, Any],
|
|
) -> None:
|
|
current_size = component.get("size")
|
|
if not isinstance(current_size, dict):
|
|
return
|
|
current_width = current_size.get("width")
|
|
current_height = current_size.get("height")
|
|
target_width = target_size.get("width")
|
|
target_height = target_size.get("height")
|
|
if not all(
|
|
isinstance(value, (int, float))
|
|
for value in (current_width, current_height, target_width, target_height)
|
|
):
|
|
return
|
|
if current_width <= 0 or current_height <= 0:
|
|
return
|
|
|
|
scale_x = float(target_width) / float(current_width)
|
|
scale_y = float(target_height) / float(current_height)
|
|
if abs(scale_x - 1.0) < 0.001 and abs(scale_y - 1.0) < 0.001:
|
|
return
|
|
|
|
elements = component.get("elements")
|
|
if not isinstance(elements, list):
|
|
return
|
|
font_scale = (scale_x * scale_y) ** 0.5
|
|
for element in elements:
|
|
if isinstance(element, dict):
|
|
cls._scale_ui_element_for_component_resize(
|
|
element,
|
|
scale_x=scale_x,
|
|
scale_y=scale_y,
|
|
font_scale=font_scale,
|
|
)
|
|
|
|
@classmethod
|
|
def _scale_ui_element_for_component_resize(
|
|
cls,
|
|
element: dict[str, Any],
|
|
*,
|
|
scale_x: float,
|
|
scale_y: float,
|
|
font_scale: float,
|
|
) -> None:
|
|
position = element.get("position")
|
|
if isinstance(position, dict):
|
|
x = position.get("x")
|
|
y = position.get("y")
|
|
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
|
element["position"] = {
|
|
"x": float(x) * scale_x,
|
|
"y": float(y) * scale_y,
|
|
}
|
|
|
|
size = element.get("size")
|
|
if isinstance(size, dict):
|
|
width = size.get("width")
|
|
height = size.get("height")
|
|
if isinstance(width, (int, float)) and isinstance(height, (int, float)):
|
|
element["size"] = {
|
|
"width": max(1.0, float(width) * scale_x),
|
|
"height": max(1.0, float(height) * scale_y),
|
|
}
|
|
|
|
if str(element.get("type") or "").lower() in {"text", "text-list", "table"}:
|
|
cls._scale_font_metrics_for_component_resize(element, font_scale)
|
|
|
|
for key in ("children", "elements"):
|
|
children = element.get(key)
|
|
if isinstance(children, list):
|
|
for child in children:
|
|
if isinstance(child, dict):
|
|
cls._scale_ui_element_for_component_resize(
|
|
child,
|
|
scale_x=scale_x,
|
|
scale_y=scale_y,
|
|
font_scale=font_scale,
|
|
)
|
|
child = element.get("child")
|
|
if isinstance(child, dict):
|
|
cls._scale_ui_element_for_component_resize(
|
|
child,
|
|
scale_x=scale_x,
|
|
scale_y=scale_y,
|
|
font_scale=font_scale,
|
|
)
|
|
|
|
@classmethod
|
|
def _scale_font_metrics_for_component_resize(
|
|
cls,
|
|
value: Any,
|
|
scale: float,
|
|
) -> None:
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
cls._scale_font_metrics_for_component_resize(item, scale)
|
|
return
|
|
if not isinstance(value, dict):
|
|
return
|
|
|
|
font = value.get("font")
|
|
if isinstance(font, dict):
|
|
size = font.get("size")
|
|
if isinstance(size, (int, float)):
|
|
font["size"] = min(512.0, max(1.0, round(float(size) * scale, 2)))
|
|
for key in ("letter_spacing", "letterSpacing"):
|
|
spacing = font.get(key)
|
|
if isinstance(spacing, (int, float)):
|
|
font[key] = round(float(spacing) * scale, 2)
|
|
|
|
# Child layout nodes are scaled separately so their text metrics are
|
|
# not applied twice. These fields contain only text/table content.
|
|
for key, child_value in value.items():
|
|
if key in {"font", "children", "elements", "child"}:
|
|
continue
|
|
if isinstance(child_value, (dict, list)):
|
|
cls._scale_font_metrics_for_component_resize(child_value, scale)
|
|
|
|
async def delete_slide_ui_component(
|
|
self, *, index: int, component_id: str
|
|
) -> dict[str, Any]:
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {"deleted": False, "message": f"No slide found at index {max(0, index)}."}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"deleted": False,
|
|
"message": "This slide has no editable ui layout; use saveSlide instead.",
|
|
}
|
|
|
|
ui = copy.deepcopy(ui)
|
|
components = ui.get("components")
|
|
before = len(components)
|
|
ui["components"] = [
|
|
component
|
|
for component in components
|
|
if not (
|
|
isinstance(component, dict) and component.get("id") == component_id
|
|
)
|
|
]
|
|
if len(ui["components"]) == before:
|
|
return {
|
|
"deleted": False,
|
|
"message": f"Component '{component_id}' was not found on slide {slide.index + 1}.",
|
|
}
|
|
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"deleted": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"message": (
|
|
f"Deleted component '{component_id}' from slide {slide.index + 1}."
|
|
),
|
|
}
|
|
|
|
async def delete_slide_ui_element(
|
|
self, *, index: int, element_path: str
|
|
) -> dict[str, Any]:
|
|
from services.chat.slide_ui_helpers import _component_id_for_path
|
|
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {"deleted": False, "message": f"No slide found at index {max(0, index)}."}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"deleted": False,
|
|
"message": "This slide has no editable ui layout; use saveSlide instead.",
|
|
}
|
|
|
|
ui = copy.deepcopy(ui)
|
|
removed = self._remove_element_at_path(ui, element_path)
|
|
if not removed:
|
|
return {
|
|
"deleted": False,
|
|
"message": (
|
|
f"Could not delete element at '{element_path}'. Only indexed "
|
|
"elements[] / children[] entries can be removed; to remove a whole "
|
|
"component use deleteComponent."
|
|
),
|
|
}
|
|
|
|
component_id = _component_id_for_path(ui, element_path)
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"deleted": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"element_path": element_path,
|
|
"message": f"Deleted element '{element_path}' on slide {slide.index + 1}.",
|
|
}
|
|
|
|
async def add_slide_ui_component(
|
|
self,
|
|
*,
|
|
index: int,
|
|
component: dict[str, Any],
|
|
insert_index: int | None = None,
|
|
) -> dict[str, Any]:
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {"added": False, "message": f"No slide found at index {max(0, index)}."}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"added": False,
|
|
"message": "This slide has no editable ui layout; use saveSlide instead.",
|
|
}
|
|
if not isinstance(component, dict):
|
|
raise ValueError("component must be a JSON object.")
|
|
elements = component.get("elements")
|
|
if not isinstance(elements, list) or not elements:
|
|
raise ValueError("component.elements must be a non-empty list.")
|
|
from services.chat.slide_ui_helpers import (
|
|
_normalize_chart_tree,
|
|
_normalize_image_tree,
|
|
_validate_chart_insert_tree,
|
|
)
|
|
|
|
ui = copy.deepcopy(ui)
|
|
components = ui.get("components")
|
|
new_component = copy.deepcopy(component)
|
|
_normalize_image_tree(new_component)
|
|
_validate_chart_insert_tree(new_component)
|
|
existing_ids = {
|
|
str(existing.get("id"))
|
|
for existing in components
|
|
if isinstance(existing, dict) and existing.get("id")
|
|
}
|
|
new_id = str(new_component.get("id") or "").strip()
|
|
if not new_id or new_id in existing_ids:
|
|
base = new_id or "component"
|
|
suffix = len(components) + 1
|
|
candidate = f"{base}_{suffix}"
|
|
while candidate in existing_ids:
|
|
suffix += 1
|
|
candidate = f"{base}_{suffix}"
|
|
new_id = candidate
|
|
new_component["id"] = new_id
|
|
new_component.setdefault(
|
|
"description", f"Component {new_id} added via assistant."
|
|
)
|
|
# Keep assistant-authored text content in the canonical renderable shape.
|
|
self._sync_ui_text_fields(new_component)
|
|
self._normalize_added_visual_block(new_component)
|
|
_normalize_chart_tree(new_component, await self._get_current_theme())
|
|
self._fit_component_to_stage(new_component)
|
|
|
|
position = (
|
|
len(components)
|
|
if insert_index is None
|
|
else min(max(0, insert_index), len(components))
|
|
)
|
|
components.insert(position, new_component)
|
|
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"added": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": new_id,
|
|
"component_index": position,
|
|
"message": (
|
|
f"Added component '{new_id}' to slide {slide.index + 1}."
|
|
),
|
|
}
|
|
|
|
async def add_slide_ui_element(
|
|
self,
|
|
*,
|
|
index: int,
|
|
element: dict[str, Any],
|
|
component_id: str | None = None,
|
|
insert_index: int | None = None,
|
|
) -> dict[str, Any]:
|
|
slide = await self._get_slide_by_index(index)
|
|
if not slide:
|
|
return {"added": False, "message": f"No slide found at index {max(0, index)}."}
|
|
ui = self._slide_ui_layout(slide)
|
|
if ui is None:
|
|
return {
|
|
"added": False,
|
|
"message": "This slide has no editable ui layout; use saveSlide instead.",
|
|
}
|
|
from services.chat.slide_ui_helpers import (
|
|
_normalize_chart_element,
|
|
_normalize_image_tree,
|
|
_validate_chart_insert_tree,
|
|
)
|
|
|
|
ui = copy.deepcopy(ui)
|
|
components = ui.get("components")
|
|
if not isinstance(components, list):
|
|
return {"added": False, "message": "Slide has no components list."}
|
|
|
|
new_element = copy.deepcopy(element)
|
|
_normalize_image_tree(new_element)
|
|
_validate_chart_insert_tree(new_element)
|
|
if component_id:
|
|
component_index, component = next(
|
|
(
|
|
(idx, candidate)
|
|
for idx, candidate in enumerate(components)
|
|
if isinstance(candidate, dict) and candidate.get("id") == component_id
|
|
),
|
|
(None, None),
|
|
)
|
|
if component_index is None or not isinstance(component, dict):
|
|
return {
|
|
"added": False,
|
|
"message": f"Component '{component_id}' was not found.",
|
|
}
|
|
elements = component.setdefault("elements", [])
|
|
if not isinstance(elements, list):
|
|
raise ValueError("Target component has no elements list.")
|
|
position = len(elements) if insert_index is None else min(max(0, insert_index), len(elements))
|
|
elements.insert(position, new_element)
|
|
path = f"components[{component_index}].elements[{position}]"
|
|
else:
|
|
position_box = new_element.get("position") if isinstance(new_element.get("position"), dict) else {}
|
|
size_box = new_element.get("size") if isinstance(new_element.get("size"), dict) else {}
|
|
component = {
|
|
"id": self._unique_ui_component_id(
|
|
str(new_element.get("name") or new_element.get("type") or "element"),
|
|
components,
|
|
),
|
|
"description": f"Element {new_element.get('type') or ''} added via assistant.".strip(),
|
|
"position": {
|
|
"x": float(position_box.get("x") or 128),
|
|
"y": float(position_box.get("y") or 120),
|
|
},
|
|
"size": {
|
|
"width": float(size_box.get("width") or 320),
|
|
"height": float(size_box.get("height") or 120),
|
|
},
|
|
"elements": [new_element],
|
|
}
|
|
new_element["position"] = {"x": 0, "y": 0}
|
|
self._normalize_added_visual_block(component)
|
|
self._fit_component_to_stage(component)
|
|
if isinstance(component.get("size"), dict):
|
|
new_element["size"] = copy.deepcopy(component["size"])
|
|
component_position = len(components) if insert_index is None else min(max(0, insert_index), len(components))
|
|
components.insert(component_position, component)
|
|
component_id = str(component["id"])
|
|
path = f"components[{component_position}].elements[0]"
|
|
|
|
self._sync_ui_text_fields(new_element)
|
|
if new_element.get("type") == "chart":
|
|
_normalize_chart_element(new_element, await self._get_current_theme())
|
|
await self._save_slide_ui(slide, ui)
|
|
return {
|
|
"added": True,
|
|
"index": slide.index,
|
|
"slide_number": slide.index + 1,
|
|
"component_id": component_id,
|
|
"element_path": path,
|
|
"message": f"Added element on slide {slide.index + 1}.",
|
|
}
|
|
|
|
@staticmethod
|
|
def _unique_ui_component_id(base: str, components: list[Any]) -> str:
|
|
normalized = re.sub(r"[^a-z0-9]+", "_", base.strip().lower()).strip("_") or "component"
|
|
used = {
|
|
str(component.get("id"))
|
|
for component in components
|
|
if isinstance(component, dict) and component.get("id")
|
|
}
|
|
candidate = normalized[:80]
|
|
suffix = 2
|
|
while candidate in used:
|
|
suffix_text = f"_{suffix}"
|
|
candidate = f"{normalized[: 80 - len(suffix_text)]}{suffix_text}"
|
|
suffix += 1
|
|
return candidate
|
|
|
|
@staticmethod
|
|
def _merge_ui_patch(target: dict[str, Any], patch: dict[str, Any]) -> None:
|
|
for key, value in patch.items():
|
|
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
|
PresentationChatMemoryLayer._merge_ui_patch(target[key], value)
|
|
else:
|
|
target[key] = copy.deepcopy(value)
|
|
|
|
@staticmethod
|
|
def _sync_ui_text_fields(node: Any) -> None:
|
|
"""Recursively keep assistant-authored text content renderable."""
|
|
if isinstance(node, dict):
|
|
if node.get("type") == "text":
|
|
runs = node.get("runs")
|
|
if isinstance(runs, list) and runs:
|
|
joined = "".join(
|
|
str(run.get("text") or "")
|
|
for run in runs
|
|
if isinstance(run, dict)
|
|
)
|
|
node["text"] = joined
|
|
elif isinstance(node.get("text"), str):
|
|
node["runs"] = [{"text": node["text"]}]
|
|
elif node.get("type") == "table":
|
|
PresentationChatMemoryLayer._sync_ui_table_cells(node)
|
|
for value in node.values():
|
|
PresentationChatMemoryLayer._sync_ui_text_fields(value)
|
|
elif isinstance(node, list):
|
|
for value in node:
|
|
PresentationChatMemoryLayer._sync_ui_text_fields(value)
|
|
|
|
@staticmethod
|
|
def _sync_ui_table_cells(element: dict[str, Any]) -> None:
|
|
fallback_font = element.get("font")
|
|
columns = element.get("columns")
|
|
if not isinstance(columns, list) and isinstance(element.get("headers"), list):
|
|
columns = element["headers"]
|
|
if isinstance(columns, list):
|
|
element["columns"] = [
|
|
PresentationChatMemoryLayer._normalized_ui_table_cell(
|
|
cell,
|
|
fallback_font,
|
|
)
|
|
for cell in columns
|
|
]
|
|
|
|
rows = element.get("rows")
|
|
if isinstance(rows, list):
|
|
element["rows"] = [
|
|
[
|
|
PresentationChatMemoryLayer._normalized_ui_table_cell(
|
|
cell,
|
|
fallback_font,
|
|
)
|
|
for cell in row
|
|
]
|
|
if isinstance(row, list)
|
|
else row
|
|
for row in rows
|
|
]
|
|
|
|
@staticmethod
|
|
def _normalized_ui_table_cell(cell: Any, fallback_font: Any) -> dict[str, Any]:
|
|
if isinstance(cell, dict):
|
|
normalized = copy.deepcopy(cell)
|
|
cell_font = normalized.get("font") or fallback_font
|
|
existing_runs = normalized.get("runs")
|
|
normalized_runs = (
|
|
PresentationChatMemoryLayer._normalized_ui_text_runs(
|
|
existing_runs,
|
|
cell_font,
|
|
)
|
|
if isinstance(existing_runs, list)
|
|
else []
|
|
)
|
|
alias_text = PresentationChatMemoryLayer._table_cell_alias_text(normalized)
|
|
if normalized_runs and (
|
|
PresentationChatMemoryLayer._runs_plain_text(normalized_runs)
|
|
or not alias_text
|
|
):
|
|
normalized["runs"] = normalized_runs
|
|
else:
|
|
normalized["runs"] = PresentationChatMemoryLayer._replacement_runs_from_existing(
|
|
existing_runs,
|
|
alias_text,
|
|
cell_font,
|
|
)
|
|
return normalized
|
|
|
|
return {
|
|
"runs": PresentationChatMemoryLayer._replacement_runs_from_existing(
|
|
None,
|
|
PresentationChatMemoryLayer._table_cell_text_value(cell),
|
|
fallback_font,
|
|
)
|
|
}
|
|
|
|
@staticmethod
|
|
def _normalized_ui_text_runs(runs: list[Any], fallback_font: Any) -> list[dict[str, Any]]:
|
|
normalized: list[dict[str, Any]] = []
|
|
for run in runs:
|
|
if isinstance(run, dict):
|
|
next_run = copy.deepcopy(run)
|
|
next_run["text"] = PresentationChatMemoryLayer._table_cell_text_value(
|
|
next_run.get("text")
|
|
if "text" in next_run
|
|
else next_run.get("content")
|
|
if "content" in next_run
|
|
else next_run.get("value")
|
|
)
|
|
normalized.append(next_run)
|
|
else:
|
|
next_run = {
|
|
"text": PresentationChatMemoryLayer._table_cell_text_value(run)
|
|
}
|
|
if isinstance(fallback_font, dict):
|
|
next_run["font"] = copy.deepcopy(fallback_font)
|
|
normalized.append(next_run)
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _runs_plain_text(runs: list[dict[str, Any]]) -> str:
|
|
return "".join(str(run.get("text") or "") for run in runs)
|
|
|
|
@staticmethod
|
|
def _table_cell_alias_text(cell: dict[str, Any]) -> str:
|
|
for key in ("text", "content", "value", "label", "data"):
|
|
if key in cell:
|
|
return PresentationChatMemoryLayer._table_cell_text_value(cell[key])
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _table_cell_text_value(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, (str, int, float, bool)):
|
|
return str(value)
|
|
if isinstance(value, list):
|
|
return "".join(
|
|
PresentationChatMemoryLayer._table_cell_text_value(item)
|
|
for item in value
|
|
)
|
|
if isinstance(value, dict):
|
|
runs = value.get("runs")
|
|
if isinstance(runs, list):
|
|
text = "".join(
|
|
PresentationChatMemoryLayer._table_cell_text_value(run)
|
|
for run in runs
|
|
)
|
|
if text:
|
|
return text
|
|
for key in ("text", "content", "value", "label", "data"):
|
|
if key in value:
|
|
return PresentationChatMemoryLayer._table_cell_text_value(value[key])
|
|
return ""
|
|
return str(value)
|
|
|
|
@staticmethod
|
|
def _normalize_added_visual_block(component: dict[str, Any]) -> None:
|
|
kind = PresentationChatMemoryLayer._insert_visual_kind(component)
|
|
if kind is None:
|
|
return
|
|
defaults = DEFAULT_INSERT_BOXES[kind]
|
|
min_size = defaults["min_size"]
|
|
if PresentationChatMemoryLayer._box_too_small(
|
|
component.get("size"),
|
|
min_size,
|
|
):
|
|
component["position"] = copy.deepcopy(defaults["position"])
|
|
component["size"] = copy.deepcopy(defaults["size"])
|
|
|
|
component_size = component.get("size")
|
|
if not isinstance(component_size, dict):
|
|
return
|
|
for element in component.get("elements", []):
|
|
if not isinstance(element, dict) or element.get("type") != kind:
|
|
continue
|
|
if PresentationChatMemoryLayer._box_too_small(
|
|
element.get("size"),
|
|
min_size,
|
|
):
|
|
element["position"] = {"x": 0, "y": 0}
|
|
element["size"] = {
|
|
"width": float(component_size["width"]),
|
|
"height": float(component_size["height"]),
|
|
}
|
|
|
|
@staticmethod
|
|
def _fit_component_to_stage(component: dict[str, Any]) -> None:
|
|
position = component.get("position")
|
|
size = component.get("size")
|
|
if not isinstance(position, dict) or not isinstance(size, dict):
|
|
return
|
|
x = position.get("x")
|
|
y = position.get("y")
|
|
width = size.get("width")
|
|
height = size.get("height")
|
|
if not all(isinstance(value, (int, float)) for value in (x, y, width, height)):
|
|
return
|
|
width = min(max(1.0, float(width)), SLIDE_STAGE_WIDTH)
|
|
height = min(max(1.0, float(height)), SLIDE_STAGE_HEIGHT)
|
|
component["size"] = {"width": width, "height": height}
|
|
component["position"] = {
|
|
"x": min(max(0.0, float(x)), SLIDE_STAGE_WIDTH - width),
|
|
"y": min(max(0.0, float(y)), SLIDE_STAGE_HEIGHT - height),
|
|
}
|
|
|
|
@staticmethod
|
|
def _insert_visual_kind(component: dict[str, Any]) -> str | None:
|
|
elements = component.get("elements")
|
|
if not isinstance(elements, list):
|
|
return None
|
|
types = {
|
|
element.get("type")
|
|
for element in elements
|
|
if isinstance(element, dict)
|
|
}
|
|
if "chart" in types:
|
|
return "chart"
|
|
if "table" in types:
|
|
return "table"
|
|
return None
|
|
|
|
@staticmethod
|
|
def _box_too_small(size: Any, min_size: dict[str, float]) -> bool:
|
|
if not isinstance(size, dict):
|
|
return True
|
|
width = size.get("width")
|
|
height = size.get("height")
|
|
if not isinstance(width, (int, float)) or not isinstance(height, (int, float)):
|
|
return True
|
|
return width < min_size["width"] or height < min_size["height"]
|
|
|
|
@staticmethod
|
|
def _remove_element_at_path(ui: dict[str, Any], path: str) -> bool:
|
|
segments = path.split(".")
|
|
if not segments:
|
|
return False
|
|
|
|
last = segments[-1]
|
|
match = re.match(r"^(components|elements|children)\[(\d+)\]$", last)
|
|
if not match:
|
|
return False
|
|
key = match.group(1)
|
|
target_index = int(match.group(2))
|
|
|
|
parent: Any = ui
|
|
for segment in segments[:-1]:
|
|
if segment == "child":
|
|
if not isinstance(parent, dict) or not isinstance(
|
|
parent.get("child"), dict
|
|
):
|
|
return False
|
|
parent = parent["child"]
|
|
continue
|
|
seg_match = re.match(r"^(components|elements|children)\[(\d+)\]$", segment)
|
|
if not seg_match:
|
|
return False
|
|
seg_key = seg_match.group(1)
|
|
seg_index = int(seg_match.group(2))
|
|
if not isinstance(parent, dict) or not isinstance(parent.get(seg_key), list):
|
|
return False
|
|
values = parent[seg_key]
|
|
if seg_index >= len(values) or not isinstance(values[seg_index], dict):
|
|
return False
|
|
parent = values[seg_index]
|
|
|
|
if not isinstance(parent, dict) or not isinstance(parent.get(key), list):
|
|
return False
|
|
values = parent[key]
|
|
if target_index >= len(values):
|
|
return False
|
|
values.pop(target_index)
|
|
return True
|
|
|
|
@staticmethod
|
|
def _update_ui_box(
|
|
target: dict[str, Any],
|
|
*,
|
|
position: dict[str, Any] | None = None,
|
|
size: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
updated = False
|
|
if position is not None:
|
|
target["position"] = {
|
|
"x": float(position["x"]),
|
|
"y": float(position["y"]),
|
|
}
|
|
updated = True
|
|
if size is not None:
|
|
target["size"] = {
|
|
"width": float(size["width"]),
|
|
"height": float(size["height"]),
|
|
}
|
|
updated = True
|
|
return updated
|
|
|
|
async def set_presentation_theme(
|
|
self,
|
|
*,
|
|
theme_query: str | None = None,
|
|
custom_theme: dict[str, Any] | None = None,
|
|
save_custom_theme: bool = True,
|
|
) -> dict[str, Any]:
|
|
requested_theme = (theme_query or "").strip()
|
|
has_custom_theme = isinstance(custom_theme, dict)
|
|
if not requested_theme and not has_custom_theme:
|
|
return {
|
|
"applied": False,
|
|
"message": "Theme query or custom theme payload is required.",
|
|
}
|
|
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"applied": False,
|
|
"message": "Presentation not found.",
|
|
}
|
|
|
|
current_theme = (
|
|
presentation.theme if isinstance(presentation.theme, dict) else None
|
|
)
|
|
available_themes = await self._get_chat_available_themes()
|
|
selected_theme: dict[str, Any] | None = None
|
|
custom_theme_saved = False
|
|
selected_source = "query"
|
|
|
|
if has_custom_theme:
|
|
custom_theme_payload = custom_theme if isinstance(custom_theme, dict) else {}
|
|
selected_theme = self._build_custom_theme_from_payload(
|
|
custom_theme=custom_theme_payload,
|
|
requested_theme=requested_theme,
|
|
current_theme=current_theme,
|
|
available_themes=available_themes,
|
|
)
|
|
if not selected_theme:
|
|
return {
|
|
"applied": False,
|
|
"message": (
|
|
"Invalid custom theme payload. Include colors and optional font "
|
|
"details (name/url), or use a theme name/id query."
|
|
),
|
|
"requested_theme": requested_theme or None,
|
|
}
|
|
|
|
selected_source = "custom"
|
|
if save_custom_theme:
|
|
await self._upsert_custom_theme_in_store(selected_theme)
|
|
custom_theme_saved = True
|
|
else:
|
|
selected_theme = self._select_theme_for_query(
|
|
requested_theme,
|
|
available_themes,
|
|
current_theme,
|
|
)
|
|
|
|
if not selected_theme:
|
|
return {
|
|
"applied": False,
|
|
"message": (
|
|
"No matching theme found. Try a specific theme name/id, "
|
|
"use 'dark'/'light'/'another', or provide customTheme."
|
|
),
|
|
"requested_theme": requested_theme,
|
|
"available_themes": [
|
|
{"id": str(theme.get("id") or ""), "name": str(theme.get("name") or "")}
|
|
for theme in available_themes
|
|
],
|
|
}
|
|
|
|
previous_theme = copy.deepcopy(current_theme) if current_theme else None
|
|
presentation.theme = copy.deepcopy(selected_theme)
|
|
self._sql_session.add(presentation)
|
|
await self._sql_session.commit()
|
|
|
|
selected_name = str(selected_theme.get("name") or "selected theme")
|
|
selected_id = str(selected_theme.get("id") or "")
|
|
previous_name = self._extract_theme_name(previous_theme)
|
|
|
|
return {
|
|
"applied": True,
|
|
"message": f"Theme changed to '{selected_name}'.",
|
|
"requested_theme": requested_theme or None,
|
|
"theme": selected_theme,
|
|
"theme_id": selected_id,
|
|
"theme_name": selected_name,
|
|
"theme_source": selected_source,
|
|
"custom_theme_saved": custom_theme_saved,
|
|
"previous_theme_name": previous_name,
|
|
}
|
|
|
|
async def get_presentation_theme_catalog(self) -> dict[str, Any]:
|
|
presentation = await self._sql_session.get(PresentationModel, self._presentation_id)
|
|
if not presentation:
|
|
return {
|
|
"found": False,
|
|
"message": "Presentation not found.",
|
|
"current_theme": None,
|
|
"available_themes": [],
|
|
"count": 0,
|
|
}
|
|
|
|
current_theme = (
|
|
copy.deepcopy(presentation.theme)
|
|
if isinstance(presentation.theme, dict)
|
|
else None
|
|
)
|
|
current_theme_id = (
|
|
str((current_theme or {}).get("id") or "").strip().lower()
|
|
if current_theme
|
|
else ""
|
|
)
|
|
builtin_theme_ids = {
|
|
str(theme.get("id") or "").strip().lower() for theme in CHAT_BUILTIN_THEMES
|
|
}
|
|
|
|
available_themes = await self._get_chat_available_themes()
|
|
catalog: list[dict[str, Any]] = []
|
|
for theme in available_themes:
|
|
theme_id = str(theme.get("id") or "").strip()
|
|
theme_name = str(theme.get("name") or "").strip()
|
|
if not theme_id and not theme_name:
|
|
continue
|
|
normalized_theme_id = theme_id.lower()
|
|
catalog.append(
|
|
{
|
|
"id": theme_id,
|
|
"name": theme_name or theme_id,
|
|
"description": str(theme.get("description") or "").strip(),
|
|
"source": (
|
|
"built_in"
|
|
if normalized_theme_id in builtin_theme_ids
|
|
else "custom"
|
|
),
|
|
"is_current": bool(
|
|
current_theme_id
|
|
and normalized_theme_id
|
|
and normalized_theme_id == current_theme_id
|
|
),
|
|
}
|
|
)
|
|
|
|
current_theme_summary: dict[str, Any] | None = None
|
|
if current_theme:
|
|
current_theme_colors = self._extract_theme_colors(current_theme)
|
|
current_theme_summary = {
|
|
"id": str(current_theme.get("id") or "").strip(),
|
|
"name": str(current_theme.get("name") or "").strip(),
|
|
"description": str(current_theme.get("description") or "").strip(),
|
|
"colors": current_theme_colors,
|
|
"chart_colors": self._chart_palette_from_theme_colors(
|
|
current_theme_colors,
|
|
),
|
|
}
|
|
|
|
return {
|
|
"found": True,
|
|
"count": len(catalog),
|
|
"current_theme": current_theme_summary,
|
|
"available_themes": catalog,
|
|
"available_theme_ids": [theme["id"] for theme in catalog if theme.get("id")],
|
|
"message": "Theme catalog fetched successfully.",
|
|
}
|
|
|
|
async def retrieve_context(self, query: str) -> str:
|
|
context = await MEM0_PRESENTATION_MEMORY_SERVICE.retrieve_context(
|
|
self._presentation_id,
|
|
query,
|
|
)
|
|
if context:
|
|
LOGGER.info(
|
|
"Chat memory semantic context hit (presentation_id=%s, chars=%d)",
|
|
self._presentation_id,
|
|
len(context),
|
|
)
|
|
else:
|
|
LOGGER.info(
|
|
"Chat memory semantic context miss (presentation_id=%s)",
|
|
self._presentation_id,
|
|
)
|
|
return context
|
|
|
|
async def _collect_template_block_candidates(
|
|
self,
|
|
presentation: PresentationModel,
|
|
) -> list[dict[str, Any]]:
|
|
sources: list[tuple[str, dict[str, Any]]] = []
|
|
if isinstance(presentation.layout, dict):
|
|
sources.append(("presentation_layout", presentation.layout))
|
|
|
|
seen_template_ids: set[str] = set()
|
|
for key in ("name", "template_id", "template_v2_id"):
|
|
template_id = self._extract_template_v2_id(
|
|
presentation.layout.get(key),
|
|
allow_bare=key in {"template_id", "template_v2_id"},
|
|
)
|
|
if not template_id or template_id in seen_template_ids:
|
|
continue
|
|
seen_template_ids.add(template_id)
|
|
template = await self._sql_session.get(TemplateV2, template_id)
|
|
if template:
|
|
sources.extend(self._template_block_sources(template))
|
|
|
|
candidates: list[dict[str, Any]] = []
|
|
seen_block_ids: set[str] = set()
|
|
for source, payload in sources:
|
|
candidates.extend(
|
|
self._block_candidates_from_merged_components(
|
|
payload,
|
|
source=source,
|
|
seen_block_ids=seen_block_ids,
|
|
)
|
|
)
|
|
candidates.extend(
|
|
self._block_candidates_from_layouts(
|
|
payload,
|
|
source=source,
|
|
seen_block_ids=seen_block_ids,
|
|
)
|
|
)
|
|
return candidates
|
|
|
|
@staticmethod
|
|
def _template_block_sources(template: TemplateV2) -> list[tuple[str, dict[str, Any]]]:
|
|
sources: list[tuple[str, dict[str, Any]]] = []
|
|
for label, payload in (
|
|
("template_merged_components", template.merged_components),
|
|
("template_components", template.components),
|
|
("template_layouts", template.layouts),
|
|
("template_raw_layouts", template.raw_layouts),
|
|
):
|
|
if isinstance(payload, dict):
|
|
sources.append((label, payload))
|
|
return sources
|
|
|
|
@classmethod
|
|
def _block_candidates_from_merged_components(
|
|
cls,
|
|
payload: dict[str, Any],
|
|
*,
|
|
source: str,
|
|
seen_block_ids: set[str],
|
|
) -> list[dict[str, Any]]:
|
|
raw_components = payload.get("components")
|
|
if not isinstance(raw_components, list):
|
|
return []
|
|
|
|
candidates: list[dict[str, Any]] = []
|
|
for group_index, group in enumerate(raw_components):
|
|
if not isinstance(group, dict):
|
|
continue
|
|
variants = group.get("variants")
|
|
if not isinstance(variants, list):
|
|
variants = [group]
|
|
variant_count = len([item for item in variants if isinstance(item, dict)])
|
|
for variant_index, component in enumerate(variants):
|
|
if not isinstance(component, dict):
|
|
continue
|
|
component_id = str(
|
|
component.get("id")
|
|
or group.get("id")
|
|
or f"component_{group_index + 1}"
|
|
)
|
|
block_id = cls._unique_block_id(
|
|
f"merged:{group.get('id') or component_id}:{variant_index}",
|
|
seen_block_ids,
|
|
)
|
|
candidates.append(
|
|
cls._build_block_candidate(
|
|
source=source,
|
|
block_id=block_id,
|
|
layout_id=None,
|
|
layout_description=None,
|
|
component=component,
|
|
component_id=component_id,
|
|
description=(
|
|
group.get("description")
|
|
or component.get("description")
|
|
or ""
|
|
),
|
|
variant_index=variant_index,
|
|
variant_count=variant_count,
|
|
)
|
|
)
|
|
return candidates
|
|
|
|
@classmethod
|
|
def _block_candidates_from_layouts(
|
|
cls,
|
|
payload: dict[str, Any],
|
|
*,
|
|
source: str,
|
|
seen_block_ids: set[str],
|
|
) -> list[dict[str, Any]]:
|
|
layouts = payload.get("layouts")
|
|
if not isinstance(layouts, list):
|
|
return []
|
|
|
|
candidates: list[dict[str, Any]] = []
|
|
for layout_index, layout in enumerate(layouts):
|
|
if not isinstance(layout, dict):
|
|
continue
|
|
components = layout.get("components")
|
|
if not isinstance(components, list):
|
|
continue
|
|
layout_id = str(layout.get("id") or f"layout_{layout_index + 1}")
|
|
layout_description = (
|
|
str(layout.get("description"))
|
|
if layout.get("description") is not None
|
|
else None
|
|
)
|
|
for component_index, component in enumerate(components):
|
|
if not isinstance(component, dict):
|
|
continue
|
|
component_id = str(
|
|
component.get("id") or f"component_{component_index + 1}"
|
|
)
|
|
block_id = cls._unique_block_id(
|
|
f"layout:{layout_id}:{component_id}",
|
|
seen_block_ids,
|
|
)
|
|
candidates.append(
|
|
cls._build_block_candidate(
|
|
source=source,
|
|
block_id=block_id,
|
|
layout_id=layout_id,
|
|
layout_description=layout_description,
|
|
component=component,
|
|
component_id=component_id,
|
|
description=str(component.get("description") or ""),
|
|
)
|
|
)
|
|
return candidates
|
|
|
|
@classmethod
|
|
def _build_block_candidate(
|
|
cls,
|
|
*,
|
|
source: str,
|
|
block_id: str,
|
|
layout_id: str | None,
|
|
layout_description: str | None,
|
|
component: dict[str, Any],
|
|
component_id: str,
|
|
description: str,
|
|
variant_index: int | None = None,
|
|
variant_count: int | None = None,
|
|
) -> dict[str, Any]:
|
|
candidate = {
|
|
"block_id": block_id,
|
|
"source": source,
|
|
"layout_id": layout_id,
|
|
"layout_description": layout_description,
|
|
"component_id": component_id,
|
|
"description": description,
|
|
"position": copy.deepcopy(component.get("position")),
|
|
"size": copy.deepcopy(component.get("size")),
|
|
"element_count": len(component.get("elements", []))
|
|
if isinstance(component.get("elements"), list)
|
|
else 0,
|
|
"element_types": cls._component_element_types(component),
|
|
"element_names": cls._component_element_names(component),
|
|
"decorative": cls._component_is_decorative(component),
|
|
"component": copy.deepcopy(component),
|
|
}
|
|
if variant_index is not None:
|
|
candidate["variant_index"] = variant_index
|
|
if variant_count is not None:
|
|
candidate["variant_count"] = variant_count
|
|
return candidate
|
|
|
|
@staticmethod
|
|
def _component_element_types(component: dict[str, Any]) -> list[str]:
|
|
seen: set[str] = set()
|
|
ordered: list[str] = []
|
|
|
|
def visit(element: Any) -> None:
|
|
if not isinstance(element, dict):
|
|
return
|
|
element_type = element.get("type")
|
|
if element_type is not None:
|
|
value = str(element_type)
|
|
if value not in seen:
|
|
seen.add(value)
|
|
ordered.append(value)
|
|
child = element.get("child")
|
|
if isinstance(child, dict):
|
|
visit(child)
|
|
children = element.get("children")
|
|
if isinstance(children, list):
|
|
for nested in children:
|
|
visit(nested)
|
|
|
|
elements = component.get("elements")
|
|
if isinstance(elements, list):
|
|
for element in elements:
|
|
visit(element)
|
|
return ordered
|
|
|
|
@staticmethod
|
|
def _component_element_names(component: dict[str, Any]) -> list[str]:
|
|
seen: set[str] = set()
|
|
ordered: list[str] = []
|
|
|
|
def visit(element: Any) -> None:
|
|
if not isinstance(element, dict):
|
|
return
|
|
name = element.get("name")
|
|
if name is not None:
|
|
value = str(name).strip()
|
|
if value and value not in seen:
|
|
seen.add(value)
|
|
ordered.append(value)
|
|
child = element.get("child")
|
|
if isinstance(child, dict):
|
|
visit(child)
|
|
children = element.get("children")
|
|
if isinstance(children, list):
|
|
for nested in children:
|
|
visit(nested)
|
|
|
|
elements = component.get("elements")
|
|
if isinstance(elements, list):
|
|
for element in elements:
|
|
visit(element)
|
|
return ordered
|
|
|
|
@staticmethod
|
|
def _component_is_decorative(component: dict[str, Any]) -> bool:
|
|
elements = component.get("elements")
|
|
if not isinstance(elements, list) or not elements:
|
|
return False
|
|
return all(
|
|
isinstance(element, dict) and element.get("decorative") is True
|
|
for element in elements
|
|
)
|
|
|
|
@staticmethod
|
|
def _unique_block_id(block_id: str, seen_block_ids: set[str]) -> str:
|
|
normalized = re.sub(r"[^a-zA-Z0-9:_-]+", "_", block_id).strip("_")
|
|
normalized = normalized or "block"
|
|
candidate = normalized
|
|
suffix = 2
|
|
while candidate in seen_block_ids:
|
|
candidate = f"{normalized}:{suffix}"
|
|
suffix += 1
|
|
seen_block_ids.add(candidate)
|
|
return candidate
|
|
|
|
@staticmethod
|
|
def _block_match_score(candidate: dict[str, Any], query: str) -> int:
|
|
if not query:
|
|
return 1
|
|
haystack = " ".join(
|
|
str(value or "")
|
|
for value in (
|
|
candidate.get("block_id"),
|
|
candidate.get("component_id"),
|
|
candidate.get("description"),
|
|
candidate.get("layout_id"),
|
|
candidate.get("layout_description"),
|
|
" ".join(str(item) for item in candidate.get("element_types", [])),
|
|
" ".join(str(item) for item in candidate.get("element_names", [])),
|
|
)
|
|
).lower()
|
|
score = 0
|
|
if query in haystack:
|
|
score += 10
|
|
for word in re.findall(r"[a-z0-9_-]+", query):
|
|
if word in haystack:
|
|
score += 1
|
|
return score
|
|
|
|
@staticmethod
|
|
def _format_available_block(
|
|
candidate: dict[str, Any],
|
|
*,
|
|
include_full_content: bool,
|
|
) -> dict[str, Any]:
|
|
keys = (
|
|
"block_id",
|
|
"source",
|
|
"layout_id",
|
|
"layout_description",
|
|
"component_id",
|
|
"description",
|
|
"position",
|
|
"size",
|
|
"element_count",
|
|
"element_types",
|
|
"element_names",
|
|
"decorative",
|
|
"variant_index",
|
|
"variant_count",
|
|
)
|
|
block = {key: candidate.get(key) for key in keys if key in candidate}
|
|
if include_full_content:
|
|
block["component"] = copy.deepcopy(candidate.get("component"))
|
|
return block
|
|
|
|
async def _get_layout_by_id(
|
|
self,
|
|
layout_id: str,
|
|
presentation: PresentationModel | None = None,
|
|
) -> SlideLayoutModel | None:
|
|
if not presentation:
|
|
presentation = await self._sql_session.get(
|
|
PresentationModel, self._presentation_id
|
|
)
|
|
layout_model = await self._get_layout_model(presentation)
|
|
if not layout_model:
|
|
return None
|
|
|
|
for layout in layout_model.slides:
|
|
if layout.id == layout_id:
|
|
return layout
|
|
return None
|
|
|
|
async def _get_layout_model(
|
|
self,
|
|
presentation: PresentationModel | None,
|
|
) -> PresentationLayoutModel | None:
|
|
if not presentation or not isinstance(presentation.layout, dict):
|
|
return None
|
|
|
|
if self._is_template_v2_layout_payload(presentation.layout):
|
|
return self._build_template_v2_layout_model(
|
|
presentation.layout,
|
|
layout_name=str(presentation.layout.get("name") or "template-v2"),
|
|
)
|
|
|
|
try:
|
|
return presentation.get_layout()
|
|
except Exception:
|
|
template_model = await self._resolve_template_v2_layout_model(presentation)
|
|
if template_model:
|
|
return template_model
|
|
LOGGER.exception(
|
|
"Failed to parse presentation layout (presentation_id=%s)",
|
|
self._presentation_id,
|
|
)
|
|
return None
|
|
|
|
async def _resolve_template_v2_layout_model(
|
|
self,
|
|
presentation: PresentationModel,
|
|
) -> PresentationLayoutModel | None:
|
|
candidate_ids: list[str] = []
|
|
seen_ids: set[str] = set()
|
|
|
|
if isinstance(presentation.layout, dict):
|
|
for key in ("name", "template_id", "template_v2_id"):
|
|
template_id = self._extract_template_v2_id(
|
|
presentation.layout.get(key),
|
|
allow_bare=key in {"template_id", "template_v2_id"},
|
|
)
|
|
if template_id and template_id not in seen_ids:
|
|
candidate_ids.append(template_id)
|
|
seen_ids.add(template_id)
|
|
|
|
for template_id in candidate_ids:
|
|
template = await self._sql_session.get(TemplateV2, template_id)
|
|
if not template or not isinstance(template.layouts, dict):
|
|
continue
|
|
return self._build_template_v2_layout_model(
|
|
template.layouts,
|
|
layout_name=f"template-v2-{template.id}",
|
|
)
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_template_v2_layout_payload(layout_payload: Any) -> bool:
|
|
return (
|
|
isinstance(layout_payload, dict)
|
|
and isinstance(layout_payload.get("layouts"), list)
|
|
)
|
|
|
|
@staticmethod
|
|
def _extract_template_v2_id(value: Any, *, allow_bare: bool = False) -> str | None:
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
|
|
candidate = value.strip()
|
|
if not candidate:
|
|
return None
|
|
for prefix in ("template-v2-", "template-v2:"):
|
|
if candidate.startswith(prefix):
|
|
candidate = candidate[len(prefix) :].strip()
|
|
break
|
|
else:
|
|
if not allow_bare:
|
|
return None
|
|
return candidate or None
|
|
|
|
@staticmethod
|
|
def _build_template_v2_layout_model(
|
|
layout_payload: dict[str, Any],
|
|
*,
|
|
layout_name: str,
|
|
) -> PresentationLayoutModel:
|
|
template_schema = get_template_schema(layout_payload)
|
|
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,
|
|
)
|
|
)
|
|
|
|
return PresentationLayoutModel(
|
|
name=layout_name,
|
|
ordered=False,
|
|
slides=slides,
|
|
)
|
|
|
|
async def _build_template_v2_slide_ui(
|
|
self,
|
|
*,
|
|
presentation: PresentationModel,
|
|
layout_id: str,
|
|
content: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
source_layout = await self._get_template_v2_raw_layout_by_id(
|
|
presentation=presentation,
|
|
layout_id=layout_id,
|
|
)
|
|
if source_layout is None:
|
|
return None
|
|
|
|
ui = copy.deepcopy(source_layout)
|
|
theme = presentation.theme if isinstance(presentation.theme, dict) else None
|
|
self._apply_template_v2_content_to_ui(ui, content, theme=theme)
|
|
self._sync_ui_text_fields(ui)
|
|
from services.chat.slide_ui_helpers import _normalize_chart_tree
|
|
|
|
_normalize_chart_tree(ui, theme)
|
|
return ui
|
|
|
|
async def _get_template_v2_raw_layout_by_id(
|
|
self,
|
|
*,
|
|
presentation: PresentationModel,
|
|
layout_id: str,
|
|
) -> dict[str, Any] | None:
|
|
if isinstance(presentation.layout, dict):
|
|
source = self._raw_layout_from_payload(presentation.layout, layout_id)
|
|
if source is not None:
|
|
return source
|
|
|
|
for key in ("name", "template_id", "template_v2_id"):
|
|
template_id = self._extract_template_v2_id(
|
|
presentation.layout.get(key),
|
|
allow_bare=key in {"template_id", "template_v2_id"},
|
|
)
|
|
if not template_id:
|
|
continue
|
|
template = await self._sql_session.get(TemplateV2, template_id)
|
|
if template and isinstance(template.layouts, dict):
|
|
source = self._raw_layout_from_payload(template.layouts, layout_id)
|
|
if source is not None:
|
|
return source
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _raw_layout_from_payload(
|
|
layout_payload: dict[str, Any],
|
|
layout_id: str,
|
|
) -> dict[str, Any] | None:
|
|
layouts = layout_payload.get("layouts")
|
|
if not isinstance(layouts, list):
|
|
return None
|
|
|
|
for layout in layouts:
|
|
if isinstance(layout, dict) and str(layout.get("id")) == str(layout_id):
|
|
return layout
|
|
return None
|
|
|
|
@classmethod
|
|
def _apply_template_v2_content_to_ui(
|
|
cls,
|
|
ui: dict[str, Any],
|
|
content: dict[str, Any],
|
|
*,
|
|
theme: dict[str, Any] | None = None,
|
|
) -> None:
|
|
components = ui.get("components")
|
|
if not isinstance(components, list):
|
|
return
|
|
|
|
component_counts: dict[str, int] = {}
|
|
for component in components:
|
|
if isinstance(component, dict):
|
|
component_id = str(component.get("id") or "")
|
|
component_counts[component_id] = (
|
|
component_counts.get(component_id, 0) + 1
|
|
)
|
|
|
|
component_seen: dict[str, int] = {}
|
|
used_keys: set[str] = set()
|
|
for component in components:
|
|
if not isinstance(component, dict):
|
|
continue
|
|
component_id = str(component.get("id") or "")
|
|
occurrence_index = component_seen.get(component_id, 0)
|
|
component_seen[component_id] = occurrence_index + 1
|
|
candidate_keys = [component_id]
|
|
if component_counts.get(component_id, 0) > 1:
|
|
candidate_keys.insert(0, f"{component_id}_{occurrence_index}")
|
|
|
|
component_content = None
|
|
for key in candidate_keys:
|
|
if key in used_keys:
|
|
continue
|
|
value = content.get(key)
|
|
if isinstance(value, dict):
|
|
component_content = value
|
|
used_keys.add(key)
|
|
break
|
|
if component_content is None:
|
|
continue
|
|
|
|
cls._apply_template_v2_component_content(
|
|
component,
|
|
component_content,
|
|
theme=theme,
|
|
)
|
|
|
|
@classmethod
|
|
def _apply_template_v2_component_content(
|
|
cls,
|
|
component: dict[str, Any],
|
|
content: dict[str, Any],
|
|
*,
|
|
theme: dict[str, Any] | None = None,
|
|
) -> None:
|
|
elements = component.get("elements")
|
|
if not isinstance(elements, list):
|
|
return
|
|
|
|
for element in elements:
|
|
if isinstance(element, dict):
|
|
cls._apply_template_v2_element_content(
|
|
element,
|
|
content,
|
|
theme=theme,
|
|
)
|
|
|
|
@classmethod
|
|
def _apply_template_v2_element_content(
|
|
cls,
|
|
element: dict[str, Any],
|
|
content: Any,
|
|
*,
|
|
theme: dict[str, Any] | None = None,
|
|
direct_value: bool = False,
|
|
) -> None:
|
|
content_values = content if isinstance(content, dict) else {}
|
|
element_type = element.get("type")
|
|
name = element.get("name")
|
|
has_value = False
|
|
value = None
|
|
if isinstance(name, str):
|
|
has_value, value = cls._template_v2_content_value(
|
|
content_values,
|
|
name,
|
|
)
|
|
|
|
if (
|
|
has_value
|
|
and element.get("decorative") is False
|
|
and element_type in TEMPLATE_V2_GENERATED_ELEMENT_TYPES
|
|
):
|
|
cls._set_template_v2_element_value(
|
|
element,
|
|
value,
|
|
theme=theme,
|
|
)
|
|
return
|
|
|
|
if (
|
|
direct_value
|
|
and not has_value
|
|
and element.get("decorative") is False
|
|
and element_type in TEMPLATE_V2_GENERATED_ELEMENT_TYPES
|
|
):
|
|
cls._set_template_v2_element_value(
|
|
element,
|
|
content,
|
|
theme=theme,
|
|
)
|
|
return
|
|
|
|
nested_content = value if isinstance(value, dict) else content_values
|
|
nested_direct_value = direct_value and not has_value
|
|
|
|
child = element.get("child")
|
|
if isinstance(child, dict):
|
|
cls._apply_template_v2_element_content(
|
|
child,
|
|
nested_content,
|
|
theme=theme,
|
|
direct_value=nested_direct_value,
|
|
)
|
|
|
|
children = element.get("children")
|
|
if isinstance(children, list):
|
|
if isinstance(value, list) and children:
|
|
next_children: list[Any] = []
|
|
for index, item in enumerate(value):
|
|
source_child = copy.deepcopy(children[min(index, len(children) - 1)])
|
|
if isinstance(source_child, dict):
|
|
cls._apply_template_v2_element_content(
|
|
source_child,
|
|
item,
|
|
theme=theme,
|
|
direct_value=True,
|
|
)
|
|
next_children.append(source_child)
|
|
element["children"] = next_children
|
|
return
|
|
|
|
for child_element in children:
|
|
if isinstance(child_element, dict):
|
|
cls._apply_template_v2_element_content(
|
|
child_element,
|
|
nested_content,
|
|
theme=theme,
|
|
direct_value=nested_direct_value,
|
|
)
|
|
|
|
@staticmethod
|
|
def _template_v2_content_value(
|
|
content: dict[str, Any],
|
|
name: str,
|
|
) -> tuple[bool, Any]:
|
|
for candidate in PresentationChatMemoryLayer._template_v2_content_name_candidates(
|
|
name
|
|
):
|
|
if candidate in content:
|
|
return True, content[candidate]
|
|
return False, None
|
|
|
|
@staticmethod
|
|
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: list[str] = []
|
|
for candidate in (name, without_numeric_token, without_prefix):
|
|
if candidate and candidate not in candidates:
|
|
candidates.append(candidate)
|
|
return candidates
|
|
|
|
@classmethod
|
|
def _set_template_v2_element_value(
|
|
cls,
|
|
element: dict[str, Any],
|
|
value: Any,
|
|
*,
|
|
theme: dict[str, Any] | None = None,
|
|
) -> None:
|
|
element_type = element.get("type")
|
|
if element_type == "text":
|
|
text = cls._template_v2_text_value(value)
|
|
if text is None or text == "":
|
|
return
|
|
cls._set_template_v2_runs_text(element, text)
|
|
element["text"] = text
|
|
return
|
|
|
|
if element_type == "text-list" and isinstance(value, list):
|
|
source_items = (
|
|
element.get("items") if isinstance(element.get("items"), list) else []
|
|
)
|
|
element["items"] = [
|
|
cls._replacement_runs_from_existing(
|
|
source_items[index] if index < len(source_items) else None,
|
|
cls._template_v2_text_value(item) or "",
|
|
element.get("font"),
|
|
)
|
|
for index, item in enumerate(value)
|
|
]
|
|
return
|
|
|
|
if element_type == "image":
|
|
asset_url = cls._template_v2_asset_url(value)
|
|
if asset_url:
|
|
element["data"] = asset_url
|
|
prompt = cls._template_v2_asset_prompt(
|
|
value,
|
|
element.get("is_icon") is True,
|
|
)
|
|
if prompt:
|
|
element["prompt"] = prompt
|
|
return
|
|
|
|
if element_type == "chart" and isinstance(value, dict):
|
|
from services.chat.slide_ui_helpers import _apply_chart_content_update
|
|
|
|
_apply_chart_content_update(element, value, theme)
|
|
return
|
|
|
|
if element_type == "table":
|
|
if isinstance(value, dict):
|
|
cls._set_template_v2_table_content(element, value)
|
|
elif isinstance(value, list):
|
|
cls._set_template_v2_table_rows(element, value)
|
|
|
|
@classmethod
|
|
def _set_template_v2_runs_text(cls, element: dict[str, Any], text: str) -> None:
|
|
element["runs"] = cls._replacement_runs_from_existing(
|
|
element.get("runs"),
|
|
text,
|
|
element.get("font"),
|
|
)
|
|
|
|
@staticmethod
|
|
def _template_v2_text_value(value: Any) -> str | None:
|
|
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
|
|
|
|
@staticmethod
|
|
def _replacement_runs_from_existing(
|
|
existing_runs: Any,
|
|
text: str,
|
|
fallback_font: Any,
|
|
) -> list[dict[str, Any]]:
|
|
if isinstance(existing_runs, list) and existing_runs:
|
|
first = existing_runs[0]
|
|
if isinstance(first, dict):
|
|
run = copy.deepcopy(first)
|
|
run["text"] = text
|
|
return [run]
|
|
run: dict[str, Any] = {"text": text}
|
|
if isinstance(fallback_font, dict):
|
|
run["font"] = copy.deepcopy(fallback_font)
|
|
return [run]
|
|
|
|
@staticmethod
|
|
def _template_v2_asset_url(value: Any) -> str | None:
|
|
if isinstance(value, str):
|
|
return normalize_slide_asset_url(value)
|
|
if not isinstance(value, dict):
|
|
return None
|
|
|
|
fallback_url: str | None = None
|
|
for key in (
|
|
"data",
|
|
"url",
|
|
"image_url",
|
|
"icon_url",
|
|
"__image_url__",
|
|
"__icon_url__",
|
|
):
|
|
asset_url = value.get(key)
|
|
if isinstance(asset_url, str) and asset_url.strip():
|
|
normalized_url = normalize_slide_asset_url(asset_url)
|
|
if normalized_url.strip().startswith(
|
|
("http://", "https://", "/app_data/", "/static/", "data:", "blob:")
|
|
):
|
|
return normalized_url
|
|
if fallback_url is None:
|
|
fallback_url = normalized_url
|
|
return fallback_url
|
|
|
|
@staticmethod
|
|
def _template_v2_asset_prompt(value: Any, is_icon: bool) -> str | None:
|
|
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
|
|
|
|
@classmethod
|
|
def _set_template_v2_table_content(
|
|
cls,
|
|
element: dict[str, Any],
|
|
value: dict[str, Any],
|
|
) -> None:
|
|
columns = value.get("columns")
|
|
if isinstance(columns, list):
|
|
existing_columns = (
|
|
element.get("columns")
|
|
if isinstance(element.get("columns"), list)
|
|
else []
|
|
)
|
|
element["columns"] = cls._template_v2_table_cells_from_values(
|
|
existing_columns,
|
|
columns,
|
|
element.get("font"),
|
|
)
|
|
|
|
rows = value.get("rows")
|
|
if isinstance(rows, list):
|
|
cls._set_template_v2_table_rows(element, rows)
|
|
|
|
@classmethod
|
|
def _template_v2_table_cells_from_values(
|
|
cls,
|
|
existing_cells: list[Any],
|
|
values: list[Any],
|
|
fallback_font: Any,
|
|
) -> list[dict[str, Any]]:
|
|
fallback_cell = existing_cells[-1] if existing_cells else None
|
|
cells: list[dict[str, Any]] = []
|
|
for index, value in enumerate(values):
|
|
existing_cell = (
|
|
existing_cells[index]
|
|
if index < len(existing_cells)
|
|
else fallback_cell
|
|
)
|
|
existing_runs = (
|
|
existing_cell.get("runs")
|
|
if isinstance(existing_cell, dict)
|
|
else None
|
|
)
|
|
cell = copy.deepcopy(existing_cell) if isinstance(existing_cell, dict) else {}
|
|
cell["runs"] = cls._replacement_runs_from_existing(
|
|
existing_runs,
|
|
cls._table_cell_text_value(value),
|
|
cell.get("font") or fallback_font,
|
|
)
|
|
cells.append(cell)
|
|
return cells
|
|
|
|
@classmethod
|
|
def _set_template_v2_table_rows(
|
|
cls,
|
|
element: dict[str, Any],
|
|
rows: list[Any],
|
|
) -> None:
|
|
existing_rows = (
|
|
element.get("rows") if isinstance(element.get("rows"), list) else []
|
|
)
|
|
next_rows: list[list[dict[str, Any]]] = []
|
|
for row_index, row in enumerate(rows):
|
|
if not isinstance(row, list):
|
|
continue
|
|
existing_row = (
|
|
existing_rows[row_index]
|
|
if row_index < len(existing_rows)
|
|
and isinstance(existing_rows[row_index], list)
|
|
else []
|
|
)
|
|
next_rows.append(
|
|
cls._template_v2_table_cells_from_values(
|
|
existing_row,
|
|
row,
|
|
element.get("font"),
|
|
)
|
|
)
|
|
element["rows"] = next_rows
|
|
|
|
def _validate_slide_content(
|
|
self,
|
|
*,
|
|
content: dict[str, Any],
|
|
schema: dict[str, Any],
|
|
) -> list[str]:
|
|
validation_content = self._strip_runtime_fields(content)
|
|
validator = Draft202012Validator(schema)
|
|
errors = sorted(validator.iter_errors(validation_content), key=lambda err: err.path)
|
|
|
|
if not errors:
|
|
return []
|
|
|
|
formatted_errors: list[str] = []
|
|
for err in errors[:MAX_SCHEMA_ERRORS]:
|
|
location = ".".join([str(part) for part in err.path]) or "$"
|
|
formatted_errors.append(f"{location}: {err.message}")
|
|
return formatted_errors
|
|
|
|
@staticmethod
|
|
def _strip_runtime_fields(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
sanitized: dict[str, Any] = {}
|
|
for key, nested_value in value.items():
|
|
if key in RUNTIME_CONTENT_FIELDS:
|
|
continue
|
|
sanitized[key] = PresentationChatMemoryLayer._strip_runtime_fields(
|
|
nested_value
|
|
)
|
|
return sanitized
|
|
|
|
if isinstance(value, list):
|
|
return [
|
|
PresentationChatMemoryLayer._strip_runtime_fields(item) for item in value
|
|
]
|
|
|
|
return value
|
|
|
|
@staticmethod
|
|
def _extract_speaker_note(content: dict[str, Any]) -> str:
|
|
value = content.get("__speaker_note__")
|
|
if isinstance(value, str):
|
|
return value
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _resolve_layout_group(
|
|
*,
|
|
presentation: PresentationModel,
|
|
fallback: str = "presentation",
|
|
) -> str:
|
|
if isinstance(presentation.layout, dict):
|
|
name = str(presentation.layout.get("name") or "").strip()
|
|
if name:
|
|
return name
|
|
if PresentationChatMemoryLayer._is_template_v2_layout_payload(
|
|
presentation.layout
|
|
):
|
|
return "template-v2"
|
|
return fallback
|
|
|
|
@staticmethod
|
|
def _normalize_outline_slides(outlines: Any) -> list[dict[str, str]]:
|
|
if not isinstance(outlines, dict):
|
|
return []
|
|
|
|
raw_slides = outlines.get("slides")
|
|
if not isinstance(raw_slides, list):
|
|
return []
|
|
|
|
slides: list[dict[str, str]] = []
|
|
for raw_slide in raw_slides:
|
|
raw_content: Any
|
|
if isinstance(raw_slide, dict):
|
|
raw_content = raw_slide.get("content", "")
|
|
else:
|
|
raw_content = raw_slide
|
|
|
|
if isinstance(raw_content, str):
|
|
content = raw_content
|
|
elif raw_content is None:
|
|
content = ""
|
|
else:
|
|
try:
|
|
content = json.dumps(raw_content, ensure_ascii=False)
|
|
except Exception:
|
|
content = str(raw_content)
|
|
|
|
slides.append({"content": normalize_outline_content(content)})
|
|
|
|
return slides
|
|
|
|
async def _save_outline_slides(
|
|
self,
|
|
presentation: PresentationModel,
|
|
slides: list[dict[str, str]],
|
|
) -> None:
|
|
outline_model = PresentationOutlineModel(
|
|
slides=[SlideOutlineModel(content=slide["content"]) for slide in slides]
|
|
)
|
|
presentation.outlines = outline_model.model_dump(mode="json")
|
|
presentation.n_slides = len(outline_model.slides)
|
|
presentation.title = get_presentation_title_from_presentation_outline(
|
|
outline_model
|
|
)
|
|
|
|
self._sql_session.add(presentation)
|
|
await self._sql_session.commit()
|
|
|
|
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
|
|
presentation.id,
|
|
presentation.outlines,
|
|
)
|
|
|
|
@staticmethod
|
|
def _extract_outline_title(markdown_content: str) -> str:
|
|
for line in markdown_content.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
heading_match = re.match(r"^#{1,6}\s*(.+?)\s*$", stripped)
|
|
if heading_match:
|
|
return heading_match.group(1).strip()
|
|
return stripped[:120]
|
|
return "Untitled outline"
|
|
|
|
@staticmethod
|
|
def _serialize_slide(slide: SlideModel) -> str:
|
|
content_text = ""
|
|
try:
|
|
content_text = json.dumps(slide.content or {}, ensure_ascii=False)
|
|
except Exception:
|
|
content_text = str(slide.content)
|
|
|
|
speaker_note = slide.speaker_note or ""
|
|
return f"slide_index={slide.index}\nlayout_id={slide.layout}\n{content_text}\n{speaker_note}"
|
|
|
|
@staticmethod
|
|
def _build_snippet(text: str, query_lower: str, window: int = 320) -> str:
|
|
normalized = " ".join(text.split())
|
|
if not normalized:
|
|
return ""
|
|
|
|
offset = normalized.lower().find(query_lower)
|
|
if offset == -1:
|
|
return normalized[:window]
|
|
|
|
start = max(0, offset - window // 3)
|
|
end = min(len(normalized), start + window)
|
|
return normalized[start:end]
|
|
|
|
@staticmethod
|
|
def _trim_document_text(text: str, limit: int) -> str:
|
|
normalized = (text or "").strip()
|
|
if not normalized:
|
|
return ""
|
|
if len(normalized) <= limit:
|
|
return normalized
|
|
return f"{normalized[:limit].rstrip()}\n[Document content truncated]"
|
|
|
|
@staticmethod
|
|
def _extract_theme_colors(theme: dict[str, Any]) -> dict[str, Any]:
|
|
data = theme.get("data")
|
|
colors = data.get("colors") if isinstance(data, dict) else None
|
|
if isinstance(colors, dict):
|
|
return copy.deepcopy(colors)
|
|
colors = theme.get("colors")
|
|
return copy.deepcopy(colors) if isinstance(colors, dict) else {}
|
|
|
|
@staticmethod
|
|
def _chart_palette_from_theme_colors(colors: dict[str, Any]) -> list[str]:
|
|
palette: list[str] = []
|
|
for index in range(10):
|
|
value = colors.get(f"graph_{index}")
|
|
if not isinstance(value, str):
|
|
continue
|
|
color = PresentationChatMemoryLayer._normalize_hex_color(value)
|
|
if color:
|
|
palette.append(color)
|
|
if palette:
|
|
return palette
|
|
fallback_keys = ("primary", "card", "stroke", "background_text", "primary_text")
|
|
fallback_palette: list[str] = []
|
|
for key in fallback_keys:
|
|
value = colors.get(key)
|
|
if not isinstance(value, str):
|
|
continue
|
|
color = PresentationChatMemoryLayer._normalize_hex_color(value)
|
|
if color:
|
|
fallback_palette.append(color)
|
|
return fallback_palette
|
|
|
|
async def _get_chat_available_themes(self) -> list[dict[str, Any]]:
|
|
merged_themes: list[dict[str, Any]] = [copy.deepcopy(theme) for theme in CHAT_BUILTIN_THEMES]
|
|
row = await self._sql_session.scalar(
|
|
select(KeyValueSqlModel).where(KeyValueSqlModel.key == THEMES_STORAGE_KEY)
|
|
)
|
|
if not row or not isinstance(row.value, dict):
|
|
return merged_themes
|
|
|
|
custom_themes = row.value.get("themes")
|
|
if not isinstance(custom_themes, list):
|
|
return merged_themes
|
|
|
|
existing_ids = {
|
|
str(theme.get("id") or "").strip().lower() for theme in merged_themes
|
|
}
|
|
for custom_theme in custom_themes:
|
|
if not isinstance(custom_theme, dict):
|
|
continue
|
|
theme_data = custom_theme.get("data")
|
|
colors = theme_data.get("colors") if isinstance(theme_data, dict) else None
|
|
if not isinstance(colors, dict) or "background" not in colors:
|
|
continue
|
|
|
|
custom_theme_copy = copy.deepcopy(custom_theme)
|
|
custom_theme_copy.setdefault("user", "local")
|
|
theme_id = str(custom_theme_copy.get("id") or "").strip().lower()
|
|
if theme_id and theme_id in existing_ids:
|
|
continue
|
|
if theme_id:
|
|
existing_ids.add(theme_id)
|
|
merged_themes.append(custom_theme_copy)
|
|
return merged_themes
|
|
|
|
async def _upsert_custom_theme_in_store(self, theme: dict[str, Any]) -> None:
|
|
row = await self._sql_session.scalar(
|
|
select(KeyValueSqlModel).where(KeyValueSqlModel.key == THEMES_STORAGE_KEY)
|
|
)
|
|
themes: list[dict[str, Any]] = []
|
|
if row and isinstance(row.value, dict):
|
|
raw_themes = row.value.get("themes")
|
|
if isinstance(raw_themes, list):
|
|
themes = copy.deepcopy(raw_themes)
|
|
|
|
theme_id = str(theme.get("id") or "").strip().lower()
|
|
replaced = False
|
|
if theme_id:
|
|
for idx, existing_theme in enumerate(themes):
|
|
existing_id = str(existing_theme.get("id") or "").strip().lower()
|
|
if existing_id == theme_id:
|
|
themes[idx] = copy.deepcopy(theme)
|
|
replaced = True
|
|
break
|
|
if not replaced:
|
|
themes.append(copy.deepcopy(theme))
|
|
|
|
if row:
|
|
row.value = {"themes": themes}
|
|
self._sql_session.add(row)
|
|
return
|
|
self._sql_session.add(KeyValueSqlModel(key=THEMES_STORAGE_KEY, value={"themes": themes}))
|
|
|
|
@staticmethod
|
|
def _resolve_base_theme_for_customization(
|
|
current_theme: dict[str, Any] | None,
|
|
available_themes: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
if isinstance(current_theme, dict):
|
|
data = current_theme.get("data")
|
|
colors = data.get("colors") if isinstance(data, dict) else None
|
|
if isinstance(colors, dict):
|
|
return copy.deepcopy(current_theme)
|
|
|
|
preferred_base = PresentationChatMemoryLayer._find_theme_by_id(
|
|
available_themes, "professional-blue"
|
|
)
|
|
if preferred_base:
|
|
return copy.deepcopy(preferred_base)
|
|
|
|
if available_themes:
|
|
return copy.deepcopy(available_themes[0])
|
|
|
|
return {
|
|
"id": "professional-blue",
|
|
"name": "Professional Blue",
|
|
"description": "Fallback base theme.",
|
|
"user": "system",
|
|
"logo": None,
|
|
"logo_url": None,
|
|
"company_name": None,
|
|
"data": {
|
|
"colors": {
|
|
"primary": "#161616",
|
|
"background": "#ffffff",
|
|
"card": "#dae6ff",
|
|
"stroke": "#d1d1d1",
|
|
"primary_text": "#eeeaea",
|
|
"background_text": "#000000",
|
|
"graph_0": "#2e2e2e",
|
|
"graph_1": "#424242",
|
|
"graph_2": "#585858",
|
|
"graph_3": "#6f6f6f",
|
|
"graph_4": "#868686",
|
|
"graph_5": "#9e9e9e",
|
|
"graph_6": "#b7b7b7",
|
|
"graph_7": "#d1d1d1",
|
|
"graph_8": "#e8e8e8",
|
|
"graph_9": "#f5f5f5",
|
|
},
|
|
"fonts": {"textFont": DEFAULT_THEME_FONT},
|
|
},
|
|
}
|
|
|
|
@staticmethod
|
|
def _build_custom_theme_from_payload(
|
|
*,
|
|
custom_theme: dict[str, Any],
|
|
requested_theme: str,
|
|
current_theme: dict[str, Any] | None,
|
|
available_themes: list[dict[str, Any]],
|
|
) -> dict[str, Any] | None:
|
|
base_theme = PresentationChatMemoryLayer._resolve_base_theme_for_customization(
|
|
current_theme, available_themes
|
|
)
|
|
|
|
payload = copy.deepcopy(custom_theme)
|
|
data_block = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
|
if not isinstance(data_block, dict):
|
|
return None
|
|
|
|
colors_override = PresentationChatMemoryLayer._extract_colors_from_payload(data_block)
|
|
if not colors_override:
|
|
return None
|
|
|
|
merged_colors = PresentationChatMemoryLayer._merge_theme_colors(
|
|
base_theme=base_theme,
|
|
color_overrides=colors_override,
|
|
)
|
|
if not merged_colors:
|
|
return None
|
|
|
|
text_font = PresentationChatMemoryLayer._extract_text_font_from_payload(
|
|
data_block, base_theme
|
|
)
|
|
if not text_font:
|
|
return None
|
|
|
|
name_candidates = [
|
|
payload.get("name"),
|
|
data_block.get("name"),
|
|
requested_theme,
|
|
"Custom Theme",
|
|
]
|
|
theme_name = next(
|
|
(
|
|
str(candidate).strip()
|
|
for candidate in name_candidates
|
|
if isinstance(candidate, str) and str(candidate).strip()
|
|
),
|
|
"Custom Theme",
|
|
)
|
|
theme_id = PresentationChatMemoryLayer._sanitize_theme_id(
|
|
str(payload.get("id") or "")
|
|
)
|
|
if not theme_id:
|
|
theme_id = PresentationChatMemoryLayer._sanitize_theme_id(theme_name)
|
|
if not theme_id:
|
|
theme_id = f"chat-custom-{uuid.uuid4().hex[:8]}"
|
|
|
|
description = str(
|
|
payload.get("description")
|
|
or data_block.get("description")
|
|
or f"Custom theme generated from chat request: {theme_name}"
|
|
).strip()
|
|
|
|
theme_data = payload.get("data")
|
|
final_data = copy.deepcopy(theme_data) if isinstance(theme_data, dict) else {}
|
|
final_data["colors"] = merged_colors
|
|
final_data["fonts"] = {"textFont": text_font}
|
|
|
|
return {
|
|
"id": theme_id,
|
|
"name": theme_name,
|
|
"description": description,
|
|
"user": str(payload.get("user") or "local"),
|
|
"logo": payload.get("logo"),
|
|
"logo_url": payload.get("logo_url"),
|
|
"company_name": payload.get("company_name"),
|
|
"data": final_data,
|
|
}
|
|
|
|
@staticmethod
|
|
def _extract_colors_from_payload(data_block: dict[str, Any]) -> dict[str, str]:
|
|
raw_colors = data_block.get("colors")
|
|
if not isinstance(raw_colors, dict):
|
|
return {}
|
|
|
|
normalized_colors: dict[str, str] = {}
|
|
for key in THEME_COLOR_KEYS:
|
|
value = raw_colors.get(key)
|
|
if not isinstance(value, str):
|
|
continue
|
|
normalized_hex = PresentationChatMemoryLayer._normalize_hex_color(value)
|
|
if normalized_hex:
|
|
normalized_colors[key] = normalized_hex
|
|
|
|
return normalized_colors
|
|
|
|
@staticmethod
|
|
def _merge_theme_colors(
|
|
*,
|
|
base_theme: dict[str, Any],
|
|
color_overrides: dict[str, str],
|
|
) -> dict[str, str] | None:
|
|
data = base_theme.get("data")
|
|
base_colors = data.get("colors") if isinstance(data, dict) else None
|
|
if not isinstance(base_colors, dict):
|
|
return None
|
|
|
|
merged: dict[str, str] = {}
|
|
for key in THEME_COLOR_KEYS:
|
|
override = color_overrides.get(key)
|
|
if override:
|
|
merged[key] = override
|
|
continue
|
|
|
|
base_value = base_colors.get(key)
|
|
if isinstance(base_value, str):
|
|
normalized = PresentationChatMemoryLayer._normalize_hex_color(base_value)
|
|
merged[key] = normalized or base_value
|
|
continue
|
|
|
|
# Keep resulting theme always complete for frontend variable mapping.
|
|
merged[key] = "#000000"
|
|
|
|
return merged
|
|
|
|
@staticmethod
|
|
def _extract_text_font_from_payload(
|
|
data_block: dict[str, Any],
|
|
base_theme: dict[str, Any],
|
|
) -> dict[str, str] | None:
|
|
candidate: dict[str, Any] | None = None
|
|
fonts = data_block.get("fonts")
|
|
if isinstance(fonts, dict):
|
|
text_font = fonts.get("textFont")
|
|
if isinstance(text_font, dict):
|
|
candidate = text_font
|
|
if not candidate:
|
|
text_font = data_block.get("textFont")
|
|
if isinstance(text_font, dict):
|
|
candidate = text_font
|
|
|
|
if not candidate:
|
|
base_data = base_theme.get("data")
|
|
base_fonts = base_data.get("fonts") if isinstance(base_data, dict) else None
|
|
base_text_font = base_fonts.get("textFont") if isinstance(base_fonts, dict) else None
|
|
if isinstance(base_text_font, dict):
|
|
candidate = base_text_font
|
|
|
|
if not candidate:
|
|
candidate = DEFAULT_THEME_FONT
|
|
|
|
name = candidate.get("name")
|
|
url = candidate.get("url")
|
|
if not isinstance(name, str) or not name.strip():
|
|
return None
|
|
if not isinstance(url, str) or not url.strip():
|
|
return None
|
|
|
|
return {"name": name.strip(), "url": url.strip()}
|
|
|
|
@staticmethod
|
|
def _sanitize_theme_id(value: str) -> str:
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
|
|
return slug[:64]
|
|
|
|
@staticmethod
|
|
def _normalize_hex_color(value: str) -> str | None:
|
|
normalized = value.strip().lower()
|
|
if not normalized:
|
|
return None
|
|
if normalized.startswith("#"):
|
|
normalized = normalized[1:]
|
|
|
|
if len(normalized) == 3:
|
|
expanded = "".join(ch * 2 for ch in normalized)
|
|
if re.fullmatch(r"[0-9a-f]{6}", expanded):
|
|
return f"#{expanded}"
|
|
return None
|
|
|
|
if len(normalized) != 6:
|
|
return None
|
|
if not re.fullmatch(r"[0-9a-f]{6}", normalized):
|
|
return None
|
|
return f"#{normalized}"
|
|
|
|
@staticmethod
|
|
def _select_theme_for_query(
|
|
requested_theme: str,
|
|
available_themes: list[dict[str, Any]],
|
|
current_theme: dict[str, Any] | None,
|
|
) -> dict[str, Any] | None:
|
|
normalized_query = requested_theme.strip().lower()
|
|
if not normalized_query:
|
|
return None
|
|
|
|
# Direct exact match by id or name.
|
|
for theme in available_themes:
|
|
theme_id = str(theme.get("id") or "").strip().lower()
|
|
theme_name = str(theme.get("name") or "").strip().lower()
|
|
if normalized_query in {theme_id, theme_name}:
|
|
return theme
|
|
|
|
current_theme_id = str((current_theme or {}).get("id") or "").strip().lower()
|
|
query_tokens = [token for token in re.split(r"[\s_-]+", normalized_query) if token]
|
|
|
|
if "dark" in query_tokens or any(
|
|
token in normalized_query for token in ("night", "black")
|
|
):
|
|
for preferred in ("professional-dark", "edge-yellow"):
|
|
theme = PresentationChatMemoryLayer._find_theme_by_id(
|
|
available_themes, preferred
|
|
)
|
|
if theme:
|
|
return theme
|
|
|
|
if "light" in query_tokens or any(
|
|
token in normalized_query for token in ("bright", "white")
|
|
):
|
|
for preferred in ("professional-blue", "mint-blue", "light-rose"):
|
|
theme = PresentationChatMemoryLayer._find_theme_by_id(
|
|
available_themes, preferred
|
|
)
|
|
if theme:
|
|
return theme
|
|
|
|
if any(token in normalized_query for token in ("another", "different", "change")):
|
|
opposite = (
|
|
not PresentationChatMemoryLayer._is_dark_theme(current_theme)
|
|
if current_theme
|
|
else True
|
|
)
|
|
candidates = [
|
|
theme
|
|
for theme in available_themes
|
|
if str(theme.get("id") or "").strip().lower() != current_theme_id
|
|
]
|
|
for theme in candidates:
|
|
if PresentationChatMemoryLayer._is_dark_theme(theme) == opposite:
|
|
return theme
|
|
if candidates:
|
|
return candidates[0]
|
|
|
|
# Fuzzy contains match over id/name/description.
|
|
for theme in available_themes:
|
|
haystack = " ".join(
|
|
[
|
|
str(theme.get("id") or "").strip().lower(),
|
|
str(theme.get("name") or "").strip().lower(),
|
|
str(theme.get("description") or "").strip().lower(),
|
|
]
|
|
)
|
|
if normalized_query in haystack:
|
|
return theme
|
|
if query_tokens and all(token in haystack for token in query_tokens):
|
|
return theme
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _find_theme_by_id(
|
|
themes: list[dict[str, Any]], theme_id: str
|
|
) -> dict[str, Any] | None:
|
|
normalized_theme_id = theme_id.strip().lower()
|
|
for theme in themes:
|
|
current_id = str(theme.get("id") or "").strip().lower()
|
|
if current_id == normalized_theme_id:
|
|
return theme
|
|
return None
|
|
|
|
@staticmethod
|
|
def _extract_theme_name(theme: dict[str, Any] | None) -> str | None:
|
|
if not isinstance(theme, dict):
|
|
return None
|
|
name = theme.get("name")
|
|
if isinstance(name, str) and name.strip():
|
|
return name.strip()
|
|
theme_id = theme.get("id")
|
|
if isinstance(theme_id, str) and theme_id.strip():
|
|
return theme_id.strip()
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_dark_theme(theme: dict[str, Any] | None) -> bool:
|
|
if not isinstance(theme, dict):
|
|
return False
|
|
data = theme.get("data")
|
|
if not isinstance(data, dict):
|
|
return False
|
|
colors = data.get("colors")
|
|
if not isinstance(colors, dict):
|
|
return False
|
|
background = colors.get("background")
|
|
if not isinstance(background, str):
|
|
return False
|
|
return PresentationChatMemoryLayer._is_dark_hex(background)
|
|
|
|
@staticmethod
|
|
def _is_dark_hex(hex_color: str) -> bool:
|
|
normalized = hex_color.strip().lstrip("#")
|
|
if len(normalized) != 6:
|
|
return False
|
|
try:
|
|
red = int(normalized[0:2], 16)
|
|
green = int(normalized[2:4], 16)
|
|
blue = int(normalized[4:6], 16)
|
|
except ValueError:
|
|
return False
|
|
# Relative luminance approximation.
|
|
luma = (0.299 * red + 0.587 * green + 0.114 * blue) / 255
|
|
return luma < 0.5
|