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

959 lines
32 KiB
Python

import json
from typing import Any, Literal
import dirtyjson # type: ignore[import-untyped]
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from constants.presentation import MAX_OUTLINE_CONTENT_WORDS
class StrictSchemaModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class OpenAIStrictSchemaModel(StrictSchemaModel):
@model_validator(mode="before")
@classmethod
def populate_missing_fields_with_none(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
for field_name, field in cls.model_fields.items():
alias = field.alias
if field_name in normalized or (alias and alias in normalized):
continue
normalized[alias or field_name] = None
return normalized
class NoArgsInput(StrictSchemaModel):
pass
class AddOutlineInput(OpenAIStrictSchemaModel):
content: str = Field(
...,
min_length=1,
max_length=20000,
description=f"Markdown content for the new outline slide. Maximum {MAX_OUTLINE_CONTENT_WORDS} words.",
)
index: int | None = Field(
...,
ge=0,
le=1000,
description="Zero-based insert index. Use null to append to the end.",
)
class UpdateOutlineInput(StrictSchemaModel):
index: int = Field(ge=0, le=1000)
content: str = Field(
min_length=1,
max_length=20000,
description=f"Replacement markdown content for this outline slide. Maximum {MAX_OUTLINE_CONTENT_WORDS} words.",
)
class DeleteOutlineInput(StrictSchemaModel):
index: int = Field(ge=0, le=1000)
class MoveOutlineInput(StrictSchemaModel):
from_index: int = Field(alias="fromIndex", ge=0, le=1000)
to_index: int = Field(alias="toIndex", ge=0, le=1000)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class GetSlideAtIndexInput(StrictSchemaModel):
index: int = Field(ge=0, le=1000)
include_full_content: bool = Field(alias="includeFullContent")
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class SearchSlidesInput(StrictSchemaModel):
query: str = Field(min_length=1, max_length=1000)
limit: int = Field(ge=1, le=10)
class ReadSourceDocumentsInput(OpenAIStrictSchemaModel):
query: str | None = Field(
...,
min_length=1,
max_length=1000,
description=(
"Optional focus query for retrieving uploaded/source document content. "
"Use null when the user asks for a general summary."
),
)
max_chars: int | None = Field(
...,
alias="maxChars",
ge=1000,
le=30000,
description="Maximum document text characters to return. Use null for the default.",
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class GetContentSchemaFromLayoutIdInput(StrictSchemaModel):
layout_id: str = Field(alias="layoutId", min_length=1, max_length=200)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class GetAvailableBlocksInput(OpenAIStrictSchemaModel):
query: str | None = Field(
...,
min_length=1,
max_length=1000,
description=(
"Optional search text for block id, description, layout, or element types."
),
)
layout_id: str | None = Field(
...,
alias="layoutId",
min_length=1,
max_length=200,
description="Optional layout id to restrict block candidates.",
)
element_type: str | None = Field(
...,
alias="elementType",
min_length=1,
max_length=80,
description=(
"Optional element type filter such as table, chart, image, text, "
"or text-list. For title/header/subtitle blocks, use text with a "
"title/header/subtitle query."
),
)
block_id: str | None = Field(
...,
alias="blockId",
min_length=1,
max_length=300,
description=(
"Optional exact block id returned by a previous getAvailableBlocks call."
),
)
include_full_content: bool | None = Field(
...,
alias="includeFullContent",
description=(
"Set true only when exact component JSON is needed for "
"addComponent/createComponent."
),
)
max_results: int | None = Field(
...,
alias="maxResults",
ge=1,
le=50,
description=(
"Maximum matching block summaries to return. Use null for the default."
),
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class GenerateImageInput(StrictSchemaModel):
prompt: str = Field(min_length=1, max_length=4000)
class GenerateIconInput(StrictSchemaModel):
query: str = Field(min_length=1, max_length=1000)
class GenerateAssetItemInput(StrictSchemaModel):
kind: Literal["image", "icon"]
prompt: str = Field(
min_length=1,
max_length=4000,
description="Image prompt or icon search query.",
)
class GenerateAssetsInput(StrictSchemaModel):
assets: list[GenerateAssetItemInput] = Field(min_length=1, max_length=12)
class AddNewSlideInput(OpenAIStrictSchemaModel):
index: int | None = Field(
...,
ge=0,
le=1000,
description="Zero-based insert index. Use null to append.",
)
class SaveSlideInput(StrictSchemaModel):
content: str = Field(
min_length=2,
max_length=200000,
description=(
"A JSON-serialized object for slide content. "
"Example: '{\"title\": \"Q4 Revenue\", \"bullets\": [\"North America +22%\"]}'"
),
)
layout_id: str = Field(alias="layoutId", min_length=1, max_length=200)
index: int = Field(ge=0, le=1000)
replace_old_slide_at_index: bool = Field(alias="replaceOldSlideAtIndex")
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
@field_validator("content")
@classmethod
def validate_content(cls, value: str) -> str:
try:
parsed: Any = dirtyjson.loads(value)
except Exception:
parsed = json.loads(value)
if not isinstance(parsed, dict):
raise ValueError("'content' must be a JSON object.")
return value
class AddNewSlideLayoutInput(StrictSchemaModel):
content: str = Field(
min_length=2,
max_length=200000,
description="A JSON-serialized object matching the selected layout schema.",
)
layout_id: str = Field(alias="layoutId", min_length=1, max_length=200)
index: int = Field(ge=0, le=1000)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
@field_validator("content")
@classmethod
def validate_content(cls, value: str) -> str:
try:
parsed: Any = dirtyjson.loads(value)
except Exception:
parsed = json.loads(value)
if not isinstance(parsed, dict):
raise ValueError("'content' must be a JSON object.")
return value
class UpdateSlideInput(AddNewSlideLayoutInput):
pass
class DeleteSlideInput(StrictSchemaModel):
index: int = Field(ge=0, le=1000)
class GetSlideElementsInput(OpenAIStrictSchemaModel):
index: int = Field(
...,
ge=0,
le=1000,
description="Zero-based slide index whose rendered UI layout you want to inspect.",
)
include_full_json: bool | None = Field(
...,
alias="includeFullJson",
description="Set true only when the exact UI layout JSON is required.",
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class SlideElementTableCellInput(OpenAIStrictSchemaModel):
section: Literal["columns", "rows"]
column_index: int = Field(..., alias="columnIndex", ge=0, le=100)
row_index: int | None = Field(
...,
alias="rowIndex",
ge=0,
le=100,
description="Required when section is rows; ignored for columns.",
)
text: str = Field(..., min_length=0, max_length=5000)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
@model_validator(mode="after")
def validate_row_target(self) -> "SlideElementTableCellInput":
if self.section == "rows" and self.row_index is None:
raise ValueError("rowIndex is required when section is rows.")
return self
class SlideElementChartSeriesInput(OpenAIStrictSchemaModel):
name: str = Field(..., min_length=1, max_length=200)
values: list[float] = Field(..., min_length=1, max_length=100)
@model_validator(mode="before")
@classmethod
def normalize_data_alias(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
if "values" not in normalized and "data" in normalized:
normalized["values"] = normalized.pop("data")
return normalized
DataLabelPosition = Literal["base", "mid", "top", "outside"]
class SlideElementChartInput(OpenAIStrictSchemaModel):
chart_type: Literal[
"area",
"bar",
"bubble",
"donut",
"horizontal_bar",
"horizontal_stacked_bar",
"line",
"pie",
"polar_area",
"radar",
"scatter",
"stacked_bar",
] | None = Field(
...,
alias="chartType",
description=(
"Chart type. Supports the same chart types as the editor: bar, "
"horizontal_bar, stacked_bar, horizontal_stacked_bar, line, area, "
"pie, donut, scatter, bubble, radar, and polar_area."
),
)
title: str | None = Field(..., min_length=0, max_length=500)
title_color: str | None = Field(
..., alias="titleColor", min_length=1, max_length=32
)
categories: list[str] | None = Field(..., min_length=1, max_length=100)
series: list[SlideElementChartSeriesInput] | None = Field(
..., min_length=1, max_length=20
)
colors: list[str] | None = Field(
...,
min_length=1,
max_length=12,
description=(
"Optional chart palette. Use user-specified colors when provided; "
"otherwise omit/null so the current theme graph colors are used."
),
)
axis_color: str | None = Field(..., alias="axisColor", min_length=1, max_length=32)
grid_color: str | None = Field(..., alias="gridColor", min_length=1, max_length=32)
x_axis: bool | None = Field(..., alias="xAxis")
y_axis: bool | None = Field(..., alias="yAxis")
x_axis_grid: bool | None = Field(..., alias="xAxisGrid")
y_axis_grid: bool | None = Field(..., alias="yAxisGrid")
x_axis_title: str | None = Field(..., alias="xAxisTitle", min_length=0, max_length=200)
y_axis_title: str | None = Field(..., alias="yAxisTitle", min_length=0, max_length=200)
data_labels: DataLabelPosition | None = Field(
...,
alias="dataLabels",
description=(
"Optional data label placement. Use null to hide labels; otherwise "
"use base, mid, top, or outside."
),
)
legend: bool | None = Field(...)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class SlideElementTableValueInput(StrictSchemaModel):
text: str = Field(min_length=0, max_length=5000)
SlideElementTableValue = (
str | int | float | bool | None | SlideElementTableValueInput
)
class SlideElementTableInput(OpenAIStrictSchemaModel):
columns: list[SlideElementTableValue] | None = Field(
..., min_length=1, max_length=100
)
headers: list[SlideElementTableValue] | None = Field(
..., min_length=1, max_length=100
)
rows: list[list[SlideElementTableValue]] = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def validate_columns_or_headers(self) -> "SlideElementTableInput":
if self.columns is None and self.headers is None:
raise ValueError("columns or headers is required.")
return self
class SlideElementPositionInput(StrictSchemaModel):
x: float = Field(ge=-10000, le=10000)
y: float = Field(ge=-10000, le=10000)
class SlideElementSizeInput(StrictSchemaModel):
width: float = Field(ge=1, le=10000)
height: float = Field(ge=1, le=10000)
class SlideElementFontInput(OpenAIStrictSchemaModel):
family: str | None = Field(..., min_length=1, max_length=200)
size: float | None = Field(..., ge=1, le=512)
color: str | None = Field(..., min_length=1, max_length=64)
bold: bool | None = Field(...)
italic: bool | None = Field(...)
underline: bool | None = Field(...)
line_height: float | None = Field(..., alias="lineHeight", ge=0.1, le=10)
letter_spacing: float | None = Field(..., alias="letterSpacing", ge=-100, le=100)
wrap: Literal["word", "char", "none"] | None = Field(...)
opacity: float | None = Field(..., ge=0, le=1)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
@model_validator(mode="before")
@classmethod
def normalize_font_aliases(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
alias_pairs = (
("fontFamily", "family"),
("font_family", "family"),
("fontName", "family"),
("font_name", "family"),
("name", "family"),
("fontSize", "size"),
("font_size", "size"),
("fontColor", "color"),
("font_color", "color"),
("textColor", "color"),
("text_color", "color"),
("line_height", "lineHeight"),
("line-height", "lineHeight"),
("letter_spacing", "letterSpacing"),
("letter-spacing", "letterSpacing"),
)
for source_key, target_key in alias_pairs:
if source_key in normalized and target_key not in normalized:
normalized[target_key] = normalized.pop(source_key)
return normalized
class SlideElementAlignmentInput(OpenAIStrictSchemaModel):
horizontal: Literal["left", "center", "right"] | None = Field(...)
vertical: Literal["top", "middle", "bottom"] | None = Field(...)
class SlideElementFillInput(OpenAIStrictSchemaModel):
color: str | None = Field(..., min_length=1, max_length=64)
opacity: float | None = Field(..., ge=0, le=1)
class SlideElementStrokeInput(OpenAIStrictSchemaModel):
color: str | None = Field(..., min_length=1, max_length=64)
opacity: float | None = Field(..., ge=0, le=1)
width: float | None = Field(..., ge=0, le=100)
dash: list[float] | None = Field(..., min_length=1, max_length=12)
class UpdateSlideElementInput(OpenAIStrictSchemaModel):
index: int = Field(..., ge=0, le=1000)
element_path: str = Field(
...,
alias="elementPath",
min_length=1,
max_length=500,
description=(
"Element path returned by getSlideAtIndex, for example "
"components[0].elements[1].children[0]."
),
)
text: str | None = Field(
...,
min_length=0,
max_length=20000,
description="Replacement text for a text element or replacement image/icon data.",
)
items: list[str] | None = Field(
...,
min_length=1,
max_length=100,
description="Replacement item text for a text-list element.",
)
table_cell: SlideElementTableCellInput | None = Field(
...,
alias="tableCell",
description="A single table header/body cell update.",
)
chart: SlideElementChartInput | None = Field(
...,
description=(
"Chart update using the new chart model: chartType, title, "
"categories, series.values, colors, axes, data labels, and legend."
),
)
table: SlideElementTableInput | None = Field(
...,
description="Whole table update with columns/headers and rows.",
)
element: str | None = Field(
...,
min_length=2,
max_length=120000,
description=(
"Optional JSON-serialized element patch for toolbar-style properties "
"such as fill, stroke, font, alignment, opacity, crop, "
"border_radius, padding, shadow, or line dash. Prefer the chart field "
"for chart type, colors, axes, legend, and data labels. "
"Object values are merged into the current element."
),
)
font: SlideElementFontInput | None = Field(
...,
description=(
"Toolbar-style text font patch. For text, text-list, and table "
"elements, this updates both the element font and existing text runs."
),
)
alignment: SlideElementAlignmentInput | None = Field(
...,
description="Toolbar-style text alignment patch for text elements.",
)
fill: SlideElementFillInput | None = Field(
...,
description="Toolbar-style fill/background patch for elements that support fill.",
)
stroke: SlideElementStrokeInput | None = Field(
...,
description="Toolbar-style stroke/border/line patch.",
)
color: str | None = Field(
...,
min_length=1,
max_length=64,
description=(
"Convenience color patch. For text, text-list, and table elements this "
"means font.color; for icons/images it updates element color; for "
"basic shapes it updates fill color."
),
)
opacity: float | None = Field(
...,
ge=0,
le=1,
description="Toolbar-style element opacity patch.",
)
position: SlideElementPositionInput | None = Field(
...,
description="Optional element position update for move requests.",
)
size: SlideElementSizeInput | None = Field(
...,
description="Optional element size update for resize/shrink/grow requests.",
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
@model_validator(mode="before")
@classmethod
def normalize_common_llm_payloads(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
chart_keys = (
"axisColor",
"axis_color",
"categories",
"chartType",
"chart_type",
"colors",
"dataLabels",
"data_labels",
"gridColor",
"grid_color",
"legend",
"series",
"title",
"titleColor",
"title_color",
"xAxis",
"xAxisGrid",
"xAxisTitle",
"x_axis",
"x_axis_grid",
"x_axis_title",
"yAxis",
"yAxisGrid",
"yAxisTitle",
"y_axis",
"y_axis_grid",
"y_axis_title",
)
if "chart" not in normalized and any(
key in normalized for key in chart_keys
):
normalized["chart"] = {
key: normalized.pop(key)
for key in chart_keys
if key in normalized
}
font_aliases = {
"fontFamily": "family",
"font_family": "family",
"fontName": "family",
"font_name": "family",
"fontSize": "size",
"font_size": "size",
"fontColor": "color",
"font_color": "color",
"textColor": "color",
"text_color": "color",
"bold": "bold",
"italic": "italic",
"underline": "underline",
"lineHeight": "lineHeight",
"line_height": "lineHeight",
"letterSpacing": "letterSpacing",
"letter_spacing": "letterSpacing",
"wrap": "wrap",
}
font_patch = (
dict(normalized["font"])
if isinstance(normalized.get("font"), dict)
else {}
)
for source_key, target_key in font_aliases.items():
if source_key in normalized and target_key not in font_patch:
font_patch[target_key] = normalized.pop(source_key)
if font_patch and "font" not in normalized:
normalized["font"] = font_patch
elif font_patch:
normalized["font"] = font_patch
alignment_aliases = {
"align": "horizontal",
"textAlign": "horizontal",
"text_align": "horizontal",
"horizontalAlign": "horizontal",
"horizontal_align": "horizontal",
"horizontalAlignment": "horizontal",
"verticalAlign": "vertical",
"vertical_align": "vertical",
"verticalAlignment": "vertical",
}
alignment_patch = (
dict(normalized["alignment"])
if isinstance(normalized.get("alignment"), dict)
else {}
)
for source_key, target_key in alignment_aliases.items():
if source_key in normalized and target_key not in alignment_patch:
alignment_patch[target_key] = normalized.pop(source_key)
if alignment_patch:
normalized["alignment"] = alignment_patch
fill_patch = (
dict(normalized["fill"])
if isinstance(normalized.get("fill"), dict)
else {}
)
for source_key in (
"fillColor",
"fill_color",
"backgroundColor",
"background_color",
):
if source_key in normalized and "color" not in fill_patch:
fill_patch["color"] = normalized.pop(source_key)
if "fillOpacity" in normalized and "opacity" not in fill_patch:
fill_patch["opacity"] = normalized.pop("fillOpacity")
if "fill_opacity" in normalized and "opacity" not in fill_patch:
fill_patch["opacity"] = normalized.pop("fill_opacity")
if fill_patch:
normalized["fill"] = fill_patch
stroke_patch = (
dict(normalized["stroke"])
if isinstance(normalized.get("stroke"), dict)
else {}
)
for source_key in ("strokeColor", "stroke_color", "borderColor", "border_color"):
if source_key in normalized and "color" not in stroke_patch:
stroke_patch["color"] = normalized.pop(source_key)
for source_key in ("strokeWidth", "stroke_width", "borderWidth", "border_width"):
if source_key in normalized and "width" not in stroke_patch:
stroke_patch["width"] = normalized.pop(source_key)
if "strokeOpacity" in normalized and "opacity" not in stroke_patch:
stroke_patch["opacity"] = normalized.pop("strokeOpacity")
if "stroke_opacity" in normalized and "opacity" not in stroke_patch:
stroke_patch["opacity"] = normalized.pop("stroke_opacity")
if stroke_patch:
normalized["stroke"] = stroke_patch
return normalized
class UpdateSlideComponentInput(OpenAIStrictSchemaModel):
index: int = Field(..., ge=0, le=1000)
component_id: str = Field(..., alias="componentId", min_length=1, max_length=120)
position: SlideElementPositionInput | None = Field(
...,
description="Optional component position update for move requests.",
)
size: SlideElementSizeInput | None = Field(
...,
description=(
"Optional component size update for resize/shrink/grow requests. "
"The component's contained elements scale with this size."
),
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class DeleteSlideComponentInput(StrictSchemaModel):
index: int = Field(ge=0, le=1000)
component_id: str = Field(alias="componentId", min_length=1, max_length=120)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class DeleteSlideElementInput(StrictSchemaModel):
index: int = Field(ge=0, le=1000)
element_path: str = Field(alias="elementPath", min_length=1, max_length=500)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class AddElementInput(OpenAIStrictSchemaModel):
index: int = Field(..., ge=0, le=1000)
element: str = Field(
...,
min_length=2,
max_length=120000,
description=(
"A JSON-serialized rendered UI element object. Use 1280 x 720 stage "
"pixels and keep new free-component geometry fully inside that window."
),
)
component_id: str | None = Field(
...,
alias="componentId",
min_length=1,
max_length=120,
description="Optional target component id. Use null to add as a new free component.",
)
insert_index: int | None = Field(
...,
alias="insertIndex",
ge=0,
le=1000,
description="Zero-based insert position. Use null to append.",
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class AddSlideComponentInput(OpenAIStrictSchemaModel):
index: int = Field(..., ge=0, le=1000)
component: str = Field(
...,
min_length=2,
max_length=200000,
description=(
"A JSON-serialized component object to add to the slide: "
'{"id": "...", "description": "...", "position": {"x": 128, "y": 120}, '
'"size": {"width": 1024, "height": 410}, "elements": [ ... ]}. '
"Use 1280 x 720 stage pixels, not normalized 0-1 values, and keep "
"position/size fully inside that visible window. "
"Copy the shape of an existing component from getAvailableBlocks or "
"getSlideAtIndex(includeFullContent=true)."
),
)
source_block_id: str | None = Field(
...,
alias="sourceBlockId",
min_length=1,
max_length=300,
description=(
"Block id returned by getAvailableBlocks when this component is adapted "
"from a reusable block. Required for table/chart component additions "
"when a matching reusable block exists, and preferred for styled "
"title/header/card/metric/text additions when a matching block exists."
),
)
insert_index: int | None = Field(
...,
alias="insertIndex",
ge=0,
le=1000,
description="Zero-based position among components. Use null to append at the end.",
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class UpdateComponentInput(OpenAIStrictSchemaModel):
index: int = Field(..., ge=0, le=1000)
component_id: str = Field(..., alias="componentId", min_length=1, max_length=120)
action: Literal[
"update",
"group",
"ungroup",
"duplicate",
"bring-to-front",
"bring-forward",
"send-backward",
"send-to-back",
"bringToFront",
"bringForward",
"sendBackward",
"sendToBack",
] | None = Field(
...,
description=(
"Use update for move/resize/replace, group to combine components, "
"ungroup to split one component, duplicate to copy a component, or "
"a layer action to reorder it."
),
)
component_ids: list[str] | None = Field(
...,
alias="componentIds",
min_length=2,
max_length=20,
description=(
"Component ids to group. Include componentId in this list; ignored for "
"update and ungroup."
),
)
position: SlideElementPositionInput | None = Field(
...,
description="Optional component position update for move requests.",
)
size: SlideElementSizeInput | None = Field(
...,
description=(
"Optional component size update for resize/shrink/grow requests. "
"The component's contained elements scale with this size."
),
)
component: str | None = Field(
...,
min_length=2,
max_length=200000,
description="Optional JSON-serialized replacement component.",
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class ThemeTextFontInput(OpenAIStrictSchemaModel):
name: str | None = Field(..., min_length=1, max_length=200)
url: str | None = Field(..., min_length=1, max_length=2000)
class ThemeFontsInput(OpenAIStrictSchemaModel):
textFont: ThemeTextFontInput | None = Field(...)
class ThemeColorsInput(OpenAIStrictSchemaModel):
primary: str | None = Field(..., min_length=4, max_length=16)
background: str | None = Field(..., min_length=4, max_length=16)
card: str | None = Field(..., min_length=4, max_length=16)
stroke: str | None = Field(..., min_length=4, max_length=16)
primary_text: str | None = Field(..., min_length=4, max_length=16)
background_text: str | None = Field(..., min_length=4, max_length=16)
graph_0: str | None = Field(..., min_length=4, max_length=16)
graph_1: str | None = Field(..., min_length=4, max_length=16)
graph_2: str | None = Field(..., min_length=4, max_length=16)
graph_3: str | None = Field(..., min_length=4, max_length=16)
graph_4: str | None = Field(..., min_length=4, max_length=16)
graph_5: str | None = Field(..., min_length=4, max_length=16)
graph_6: str | None = Field(..., min_length=4, max_length=16)
graph_7: str | None = Field(..., min_length=4, max_length=16)
graph_8: str | None = Field(..., min_length=4, max_length=16)
graph_9: str | None = Field(..., min_length=4, max_length=16)
class CustomThemeDataInput(OpenAIStrictSchemaModel):
name: str | None = Field(..., min_length=1, max_length=200)
description: str | None = Field(..., min_length=1, max_length=1000)
colors: ThemeColorsInput | None = Field(...)
fonts: ThemeFontsInput | None = Field(...)
textFont: ThemeTextFontInput | None = Field(...)
class CustomThemeInput(OpenAIStrictSchemaModel):
id: str | None = Field(..., min_length=1, max_length=200)
name: str | None = Field(..., min_length=1, max_length=200)
description: str | None = Field(..., min_length=1, max_length=1000)
user: str | None = Field(..., min_length=1, max_length=100)
logo: str | None = Field(..., min_length=1, max_length=500)
logo_url: str | None = Field(
...,
alias="logoUrl",
min_length=1,
max_length=2000,
)
company_name: str | None = Field(
...,
alias="companyName",
min_length=1,
max_length=200,
)
data: CustomThemeDataInput | None = Field(...)
colors: ThemeColorsInput | None = Field(...)
fonts: ThemeFontsInput | None = Field(...)
textFont: ThemeTextFontInput | None = Field(...)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
class SetPresentationThemeInput(OpenAIStrictSchemaModel):
theme: str | None = Field(
...,
min_length=1,
max_length=200,
description=(
"Theme target requested by the user (e.g., 'dark', "
"'professional-dark', 'light rose', or 'another'). Optional "
"when customTheme is provided."
),
)
custom_theme: CustomThemeInput | None = Field(
...,
alias="customTheme",
description=(
"Optional custom theme payload. Supports minimal colors/fonts payloads or "
"a full theme object using only declared keys such as id, name, description, "
"data.colors, and data.fonts.textFont."
),
)
save_custom_theme: bool | None = Field(
...,
alias="saveCustomTheme",
description=(
"When customTheme is provided, persist it into local custom themes for reuse."
),
)
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
@model_validator(mode="after")
def validate_theme_request(self) -> "SetPresentationThemeInput":
if self.save_custom_theme is None:
object.__setattr__(self, "save_custom_theme", True)
if self.theme is None and self.custom_theme is None:
raise ValueError("Either 'theme' or 'customTheme' must be provided.")
return self