Optional[Path]:
+ raw = (path_value or "").strip()
+ if not raw:
+ return None
+
+ candidate = Path(raw).expanduser()
+ if not candidate.is_absolute():
+ candidate = (PROJECT_ROOT / candidate).resolve()
+
+ if candidate.is_file():
+ return candidate
+
+ log.warning("[paper2poster] output file missing on disk: %s", raw)
+ return None
+
@staticmethod
def _validate_poster_dimensions(width: float, height: float) -> None:
if width <= 0 or height <= 0:
@@ -85,6 +102,16 @@ async def generate(
api_key,
scope="paper2poster",
)
+ model = resolve_model_name(
+ model,
+ managed_default=settings.PAPER2POSTER_DEFAULT_MODEL,
+ fallback_default="gpt-4o",
+ )
+ vision_model = resolve_model_name(
+ vision_model,
+ managed_default=settings.PAPER2POSTER_VISION_MODEL,
+ fallback_default="gpt-4o",
+ )
self._validate_poster_dimensions(poster_width, poster_height)
run_dir = self._create_run_dir(email)
@@ -138,13 +165,16 @@ async def generate(
raise HTTPException(status_code=500, detail=result.get("message") or "Failed to generate poster")
pptx_path = (result.get("output_pptx_path") or "").strip()
- if not pptx_path:
- raise HTTPException(status_code=500, detail="Poster workflow finished without a PPTX output")
+ pptx_file = self._resolve_existing_output_file(pptx_path)
+ if pptx_file is None:
+ detail = result.get("message") or "Poster workflow finished without a valid PPTX output"
+ raise HTTPException(status_code=500, detail=detail)
png_path = (result.get("output_png_path") or "").strip()
+ png_file = self._resolve_existing_output_file(png_path) if png_path else None
return {
"success": True,
- "pptx_url": _to_outputs_url(pptx_path),
- "png_url": _to_outputs_url(png_path) if png_path else None,
+ "pptx_url": _to_outputs_url(str(pptx_file)),
+ "png_url": _to_outputs_url(str(png_file)) if png_file else None,
"message": "Poster generated successfully",
}
diff --git a/fastapi_app/services/paper2ppt_frontend_service.py b/fastapi_app/services/paper2ppt_frontend_service.py
index 61b6e421..7315835a 100644
--- a/fastapi_app/services/paper2ppt_frontend_service.py
+++ b/fastapi_app/services/paper2ppt_frontend_service.py
@@ -30,6 +30,7 @@
from fastapi_app.services.managed_api_service import (
resolve_image_generation_credentials,
resolve_llm_credentials,
+ resolve_model_name,
)
from fastapi_app.utils import _from_outputs_url, _to_outputs_url, resolve_outputs_path
@@ -100,7 +101,11 @@ async def generate_slides(
pagecontent=pagecontent,
chat_api_url=resolved_chat_api_url,
api_key=resolved_api_key,
- model=req.model,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_CONTENT_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
language=req.language,
style=req.style,
)
@@ -116,12 +121,20 @@ async def generate_slides(
slide_index=req.page_id,
chat_api_url=resolved_chat_api_url,
api_key=resolved_api_key,
- model=req.model,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_CONTENT_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
language=req.language,
style=req.style,
include_images=req.include_images,
image_style=req.image_style,
- image_model=req.image_model,
+ image_model=resolve_model_name(
+ req.image_model,
+ managed_default=settings.PAPER2PPT_IMAGE_GEN_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_IMAGE_MODEL,
+ ),
image_api_url=resolved_image_api_url,
image_api_key=resolved_image_api_key,
edit_prompt=req.edit_prompt,
@@ -178,12 +191,20 @@ async def generate_slides(
slide_index=index,
chat_api_url=resolved_chat_api_url,
api_key=resolved_api_key,
- model=req.model,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_CONTENT_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
language=req.language,
style=req.style,
include_images=req.include_images,
image_style=req.image_style,
- image_model=req.image_model,
+ image_model=resolve_model_name(
+ req.image_model,
+ managed_default=settings.PAPER2PPT_IMAGE_GEN_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_IMAGE_MODEL,
+ ),
image_api_url=resolved_image_api_url,
image_api_key=resolved_image_api_key,
edit_prompt=None,
@@ -846,16 +867,12 @@ def _build_visual_asset_prompt(
"sci_fi": "restrained sci-fi research visual with clean lighting",
"flat_infographic": "flat infographic-style illustration with simple shapes",
}
- key_points = [
- str(item).strip()
- for item in (outline_item.get("key_points") or [])
- if str(item).strip()
- ][:4]
+ key_points = self._normalize_outline_points(outline_item.get("key_points"), limit=4, item_limit=120)
palette = theme.get("palette") or {}
return (
"Create one supporting image for an academic presentation slide. "
- f"Page topic: {str(outline_item.get('title') or f'Slide {slide_index + 1}').strip()}. "
- f"Layout intent: {str(outline_item.get('layout_description') or '').strip()}. "
+ f"Page topic: {self._clean_text_content(outline_item.get('title'), f'Slide {slide_index + 1}', 220)}. "
+ f"Layout intent: {self._clean_text_content(outline_item.get('layout_description'), '', 220)}. "
f"Key points: {'; '.join(key_points) if key_points else 'keep it concise and presentation-friendly'}. "
f"Visual style: {style_map.get(image_style, image_style or 'academic illustration')}. "
f"Preferred palette anchors: background {palette.get('bg', '#0b1020')}, accent {palette.get('accent', '#f59e0b')}, text contrast {palette.get('text', '#e2e8f0')}. "
@@ -1145,6 +1162,7 @@ def _build_theme_messages(
{
"theme_name": "short id",
"visual_mood": "one sentence",
+ "style_family": "modern | business | academic | creative",
"palette": {
"bg": "#0b1020",
"panel": "rgba(15,23,42,0.92)",
@@ -1183,6 +1201,7 @@ def _build_theme_messages(
6. The theme_lock must be concrete enough to prevent per-slide drift during later regeneration.
7. If style_prompt contains explicit color or material directions, translate them into the palette instead of ignoring them.
8. Do not default to cyan/teal accents unless the style_prompt clearly asks for them.
+9. style_family must be one of modern, business, academic, creative and should match the tone implied by style_prompt.
""".strip()
user_payload = {
@@ -1218,39 +1237,51 @@ def _build_messages(
visual_assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
system_prompt = """
-You are an expert academic slide frontend engineer.
-Generate a single 16:9 presentation slide as HTML/CSS for a browser-based PPT editor.
+You are an expert academic presentation designer.
+Generate one strictly structured 16:9 slide for a browser PPT editor and true editable PPT export.
Hard requirements:
1. Return JSON only. No markdown fences. No explanation.
2. Output schema:
{
"title": "short string",
- "html_template": "HTML string",
- "css_code": "CSS string",
- "editable_fields": [
- {"key": "title", "label": "Title", "type": "text", "value": "..."},
- {"key": "summary", "label": "Summary", "type": "textarea", "value": "..."},
- {"key": "key_points", "label": "Key Points", "type": "list", "items": ["...", "..."]}
- ],
+ "layout_type": "cover | section | bullets | two_column | cards_2x2 | image_focus | comparison | timeline",
+ "content": {
+ "...": "layout-specific content"
+ },
"generation_note": "one short sentence"
}
-3. Every visible text in html_template must come from placeholders only:
- - text/textarea fields: {{field:key}}
- - list fields: {{list:key}}
- - controlled images, when required: {{image:key}}
-4. css_code must only target .slide-root and its descendants.
-5. Do not use external assets, remote fonts, raw image URLs, svg, canvas, script, iframe, video or img tags.
-6. The slide must fit inside a 1600x900 canvas with safe margins and no overflow.
-7. Use the supplied deck theme so every page looks like the same presentation family.
-8. Treat theme_lock as non-negotiable. Do not invent a new palette family, component language, or typography system.
-9. Keep titles within 2 lines, with title font 42-60px and body text 18-28px.
-10. Prefer grid/flex layouts over brittle absolute positioning.
-11. If visual_assets are supplied, reserve layout space and place them using {{image:key}} placeholders. Never write a raw
tag yourself.
-12. If visual_assets are empty, build a text-first slide using editable text blocks and CSS decoration only.
-13. The HTML must contain a single .slide-root root element.
-14. If reference deck slides are provided, preserve their shared component grammar, spacing rhythm, and card treatment.
-15. Never put {{field:...}}, {{list:...}}, or {{image:...}} placeholders inside HTML attributes like aria-label, title, alt, data-*, href, or style. Placeholders may only appear in element content.
+3. Never return HTML, CSS, SVG, coordinates, raw style code, or arbitrary DOM.
+4. Use only the allowed layout_type values.
+5. Keep the slide strictly editable:
+ - all visible text must live in `content`
+ - images must be referenced only through the provided visual_assets slots
+6. Use the supplied deck theme so every page looks like the same presentation family.
+7. Treat theme_lock as non-negotiable. Do not invent a new palette family, component language, or typography system.
+8. Keep titles within 2 lines, body content concise, and list lengths <= 6.
+9. Use `image_focus` only when the slide genuinely benefits from a dominant supporting visual. If no visual_assets are present, do not choose `image_focus`.
+10. `cards_2x2` must contain exactly 4 cards.
+11. `timeline` must contain 3 to 5 items.
+12. `comparison` must contain left and right sections with short bullet lists.
+13. Do not overuse one layout type across the deck. Reuse the shared theme, but vary page structure according to the content.
+
+Layout content schema:
+- cover:
+ eyebrow, title, subtitle, presenter, footer
+- section:
+ eyebrow, title, summary, quote, footer
+- bullets:
+ eyebrow, title, summary, bullets[], takeaway, footer
+- two_column:
+ eyebrow, title, summary, left_heading, left_body, left_points[], right_heading, right_body, right_points[], footer
+- cards_2x2:
+ eyebrow, title, summary, cards[{title, body} x4], footer
+- image_focus:
+ eyebrow, title, summary, bullets[], visual_caption, footer
+- comparison:
+ eyebrow, title, summary, left_title, left_points[], right_title, right_points[], footer
+- timeline:
+ eyebrow, title, summary, timeline[{label, body}], footer
""".strip()
outline_payload = {
@@ -1309,7 +1340,7 @@ def _build_messages(
user_sections.append(f"Revision request: {edit_prompt}")
user_sections.append(
- "Ensure the editable_fields fully cover all meaningful visible text shown on the slide."
+ "Return a compact structured slide. Do not emit arbitrary layout code."
)
return [
@@ -1387,13 +1418,9 @@ def _summarize_slide_for_review(self, slide: Dict[str, Any]) -> Dict[str, Any]:
"type": field_type,
}
if field_type == "list":
- entry["items"] = [
- str(item).strip()
- for item in (field.get("items") or [])
- if str(item).strip()
- ][:5]
+ entry["items"] = self._normalize_outline_points(field.get("items"), limit=5, item_limit=140)
else:
- entry["value"] = str(field.get("value") or "").strip()[:280]
+ entry["value"] = self._clean_text_content(field.get("value"), "", 280)
summarized_fields.append(entry)
visual_assets = slide.get("visual_assets") or slide.get("visualAssets") or []
@@ -1434,63 +1461,305 @@ def _normalize_slide_payload(
theme=theme,
visual_assets=visual_assets,
)
- html_template = payload.get("html_template") or payload.get("html") or ""
- css_code = payload.get("css_code") or payload.get("css") or ""
- if not isinstance(html_template, str) or not isinstance(css_code, str):
- return fallback_slide
- if len(html_template) > 16000 or len(css_code) > 20000:
+ layout_type = str(payload.get("layout_type") or payload.get("layoutType") or "").strip()
+ content = payload.get("content") or {}
+ if not isinstance(content, dict):
return fallback_slide
- if _FORBIDDEN_HTML_RE.search(html_template) or _FORBIDDEN_CSS_RE.search(css_code):
+ if layout_type not in {
+ "cover",
+ "section",
+ "bullets",
+ "two_column",
+ "cards_2x2",
+ "image_focus",
+ "comparison",
+ "timeline",
+ }:
return fallback_slide
-
- normalized_html = self._sanitize_html_template(html_template)
- normalized_css = self._sanitize_css(css_code, theme=theme)
- editable_fields = self._normalize_fields(
- payload.get("editable_fields"),
- outline_item=outline_item,
- slide_index=slide_index,
- )
- if not editable_fields:
+ if not visual_assets and layout_type == "image_focus":
return fallback_slide
- normalized_html, attribute_warnings = self._sanitize_attribute_placeholders(
- normalized_html,
- editable_fields,
- )
- if attribute_warnings:
+ try:
+ return self._build_structured_slide(
+ layout_type=layout_type,
+ content=content,
+ outline_item=outline_item,
+ slide_index=slide_index,
+ slide_count=slide_count,
+ theme=theme,
+ visual_assets=visual_assets,
+ generation_note=str(payload.get("generation_note") or "").strip(),
+ )
+ except Exception as exc: # noqa: BLE001
log.warning(
- "[Paper2PPTFrontendService] Sanitized attribute placeholders for page %s: %s",
+ "[Paper2PPTFrontendService] Failed to normalize structured slide payload for page %s: %s",
slide_index + 1,
- ", ".join(attribute_warnings),
+ exc,
)
-
- field_keys = {field["key"] for field in editable_fields}
- placeholders = set(_FIELD_PLACEHOLDER_RE.findall(normalized_html))
- image_placeholders = set(_IMAGE_PLACEHOLDER_RE.findall(normalized_html))
- asset_keys = {str(asset.get("key") or "").strip() for asset in visual_assets if str(asset.get("key") or "").strip()}
- if not placeholders:
- return fallback_slide
- if not placeholders.issubset(field_keys):
- return fallback_slide
- if image_placeholders and not image_placeholders.issubset(asset_keys):
- return fallback_slide
- if visual_assets and not image_placeholders:
return fallback_slide
- title_value = (
- self._find_field_value(editable_fields, "title")
- or outline_item.get("title")
- or f"Slide {slide_index + 1}"
+ def _field_entry(
+ self,
+ *,
+ key: str,
+ label: str,
+ field_type: str,
+ value: str = "",
+ items: Optional[List[str]] = None,
+ ) -> Dict[str, Any]:
+ return {
+ "key": key,
+ "label": label,
+ "type": field_type,
+ "value": value,
+ "items": items or [],
+ }
+
+ def _clean_text_content(self, value: Any, default: str = "", limit: int = 280) -> str:
+ text = self._extract_outline_text(value)
+ text = re.sub(r"\s+", " ", text)
+ return (text or default)[:limit]
+
+ def _extract_outline_text(self, value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value.strip()
+ if isinstance(value, (int, float, bool)):
+ return str(value).strip()
+ if isinstance(value, dict):
+ preferred_keys = (
+ "text",
+ "value",
+ "content",
+ "summary",
+ "title",
+ "label",
+ "body",
+ "description",
+ "reason",
+ "point",
+ )
+ for key in preferred_keys:
+ extracted = self._extract_outline_text(value.get(key))
+ if extracted:
+ return extracted
+ parts = [self._extract_outline_text(item) for item in value.values()]
+ joined = " ".join(part for part in parts if part)
+ return joined.strip()
+ if isinstance(value, list):
+ parts = [self._extract_outline_text(item) for item in value]
+ joined = " ".join(part for part in parts if part)
+ return joined.strip()
+ return str(value).strip()
+
+ def _normalize_outline_points(
+ self,
+ value: Any,
+ *,
+ limit: int = 6,
+ item_limit: int = 120,
+ ) -> List[str]:
+ normalized: List[str] = []
+
+ def _append(item: Any) -> None:
+ text = self._clean_text_content(item, "", item_limit)
+ if text and text not in normalized:
+ normalized.append(text)
+
+ if isinstance(value, list):
+ for item in value:
+ if isinstance(item, list):
+ for nested in item:
+ _append(nested)
+ else:
+ _append(item)
+ elif value is not None:
+ _append(value)
+ return normalized[:limit]
+
+ def _clean_list_content(
+ self,
+ value: Any,
+ *,
+ defaults: Optional[List[str]] = None,
+ limit: int = 6,
+ item_limit: int = 120,
+ ) -> List[str]:
+ cleaned: List[str] = []
+ if isinstance(value, list):
+ for item in value:
+ text = self._clean_text_content(item, "", item_limit)
+ if text:
+ cleaned.append(text)
+ elif isinstance(value, str) and value.strip():
+ cleaned = [self._clean_text_content(value, "", item_limit)]
+ if cleaned:
+ return cleaned[:limit]
+ return (defaults or [])[:limit]
+
+ def _build_structured_slide(
+ self,
+ *,
+ layout_type: str,
+ content: Dict[str, Any],
+ outline_item: Dict[str, Any],
+ slide_index: int,
+ slide_count: int,
+ theme: Dict[str, Any],
+ visual_assets: List[Dict[str, Any]],
+ generation_note: str,
+ ) -> Dict[str, Any]:
+ fallback_title = str(outline_item.get("title") or f"Slide {slide_index + 1}").strip()
+ section_template = str(theme.get("section_label_template") or "Slide {page_num:02d}/{slide_count:02d}")
+ try:
+ default_eyebrow = section_template.format(page_num=slide_index + 1, slide_count=slide_count)
+ except Exception: # noqa: BLE001
+ default_eyebrow = f"Slide {slide_index + 1:02d}/{slide_count:02d}"
+ key_points = self._normalize_outline_points(outline_item.get("key_points"), limit=6, item_limit=120)
+ default_summary = key_points[0] if key_points else self._clean_text_content(
+ outline_item.get("layout_description"),
+ "",
+ 280,
+ )
+ default_footer = str(theme.get("footer_text") or "Paper2Any Structured PPT").strip()
+
+ editable_fields: List[Dict[str, Any]] = []
+ layout_data: Dict[str, Any] = {"type": layout_type}
+
+ def add_text(key: str, label: str, default: str, *, field_type: str = "text", limit: int = 280) -> str:
+ value = self._clean_text_content(content.get(key), default, limit)
+ editable_fields.append(
+ self._field_entry(
+ key=key,
+ label=label,
+ field_type="textarea" if field_type == "textarea" else "text",
+ value=value,
+ )
+ )
+ return key
+
+ def add_list(key: str, label: str, default_items: List[str], *, limit: int = 6, item_limit: int = 120) -> str:
+ items = self._clean_list_content(
+ content.get(key),
+ defaults=default_items,
+ limit=limit,
+ item_limit=item_limit,
+ )
+ editable_fields.append(
+ self._field_entry(
+ key=key,
+ label=label,
+ field_type="list",
+ items=items,
+ )
+ )
+ return key
+
+ layout_data["eyebrow_key"] = add_text("eyebrow", "Eyebrow", default_eyebrow)
+ layout_data["title_key"] = add_text("title", "Title", fallback_title, limit=120)
+ layout_data["footer_key"] = add_text("footer", "Footer", default_footer, limit=80)
+
+ if layout_type == "cover":
+ layout_data["subtitle_key"] = add_text("subtitle", "Subtitle", default_summary, field_type="textarea", limit=220)
+ layout_data["presenter_key"] = add_text("presenter", "Presenter", "Presenter / Team", limit=80)
+ elif layout_type == "section":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=220)
+ layout_data["quote_key"] = add_text("quote", "Quote", key_points[1] if len(key_points) > 1 else default_summary, field_type="textarea", limit=200)
+ elif layout_type == "bullets":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=220)
+ layout_data["bullets_key"] = add_list("bullets", "Bullets", key_points[:5] or ["Add key points"])
+ layout_data["takeaway_key"] = add_text("takeaway", "Takeaway", key_points[-1] if key_points else default_summary, field_type="textarea", limit=180)
+ elif layout_type == "two_column":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=220)
+ layout_data["left_heading_key"] = add_text("left_heading", "Left Heading", "Core Idea", limit=80)
+ layout_data["left_body_key"] = add_text("left_body", "Left Body", key_points[0] if key_points else default_summary, field_type="textarea", limit=180)
+ layout_data["left_points_key"] = add_list("left_points", "Left Points", key_points[:3], limit=4)
+ layout_data["right_heading_key"] = add_text("right_heading", "Right Heading", "Implication", limit=80)
+ layout_data["right_body_key"] = add_text("right_body", "Right Body", key_points[1] if len(key_points) > 1 else default_summary, field_type="textarea", limit=180)
+ layout_data["right_points_key"] = add_list("right_points", "Right Points", key_points[2:5] or key_points[:2], limit=4)
+ elif layout_type == "cards_2x2":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=200)
+ raw_cards = content.get("cards")
+ cards = raw_cards if isinstance(raw_cards, list) else []
+ card_refs: List[Dict[str, str]] = []
+ for index in range(4):
+ item = cards[index] if index < len(cards) and isinstance(cards[index], dict) else {}
+ title_key = f"card_{index + 1}_title"
+ body_key = f"card_{index + 1}_body"
+ editable_fields.append(self._field_entry(
+ key=title_key,
+ label=f"Card {index + 1} Title",
+ field_type="text",
+ value=self._clean_text_content(item.get("title"), f"Point {index + 1}", 80),
+ ))
+ editable_fields.append(self._field_entry(
+ key=body_key,
+ label=f"Card {index + 1} Body",
+ field_type="textarea",
+ value=self._clean_text_content(
+ item.get("body"),
+ key_points[index] if index < len(key_points) else default_summary,
+ 140,
+ ),
+ ))
+ card_refs.append({"title_key": title_key, "body_key": body_key})
+ layout_data["cards"] = card_refs
+ elif layout_type == "image_focus":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=180)
+ layout_data["bullets_key"] = add_list("bullets", "Bullets", key_points[:4], limit=4)
+ layout_data["visual_caption_key"] = add_text("visual_caption", "Visual Caption", "Supporting visual", limit=90)
+ layout_data["visual_key"] = str((visual_assets[0].get("key") if visual_assets else _DEFAULT_VISUAL_KEY) or _DEFAULT_VISUAL_KEY)
+ elif layout_type == "comparison":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=180)
+ layout_data["left_title_key"] = add_text("left_title", "Left Title", "Track A", limit=80)
+ layout_data["left_points_key"] = add_list("left_points", "Left Points", key_points[:3], limit=4)
+ layout_data["right_title_key"] = add_text("right_title", "Right Title", "Track B", limit=80)
+ layout_data["right_points_key"] = add_list("right_points", "Right Points", key_points[3:6] or key_points[:3], limit=4)
+ elif layout_type == "timeline":
+ layout_data["summary_key"] = add_text("summary", "Summary", default_summary, field_type="textarea", limit=180)
+ raw_timeline = content.get("timeline")
+ timeline_items = raw_timeline if isinstance(raw_timeline, list) else []
+ timeline_refs: List[Dict[str, str]] = []
+ count = max(3, min(5, len(timeline_items) or 3))
+ for index in range(count):
+ item = timeline_items[index] if index < len(timeline_items) and isinstance(timeline_items[index], dict) else {}
+ label_key = f"timeline_{index + 1}_label"
+ body_key = f"timeline_{index + 1}_body"
+ editable_fields.append(self._field_entry(
+ key=label_key,
+ label=f"Timeline {index + 1} Label",
+ field_type="text",
+ value=self._clean_text_content(item.get("label"), f"Phase {index + 1}", 60),
+ ))
+ editable_fields.append(self._field_entry(
+ key=body_key,
+ label=f"Timeline {index + 1} Body",
+ field_type="textarea",
+ value=self._clean_text_content(
+ item.get("body"),
+ key_points[index] if index < len(key_points) else default_summary,
+ 120,
+ ),
+ ))
+ timeline_refs.append({"label_key": label_key, "body_key": body_key})
+ layout_data["timeline"] = timeline_refs
+ else:
+ raise ValueError(f"unsupported layout_type: {layout_type}")
+
+ title_value = next(
+ (field.get("value") for field in editable_fields if field.get("key") == "title"),
+ fallback_title,
)
return {
- "slide_id": str(payload.get("slide_id") or slide_index + 1),
+ "slide_id": str(slide_index + 1),
"page_num": slide_index + 1,
- "title": str(payload.get("title") or title_value),
- "html_template": normalized_html,
- "css_code": normalized_css,
+ "title": str(title_value or fallback_title),
+ "layout_type": layout_type,
+ "layout_data": layout_data,
"editable_fields": editable_fields,
"visual_assets": visual_assets,
- "generation_note": str(payload.get("generation_note") or ""),
+ "generation_note": generation_note or "Structured slide generated",
"status": "done",
}
@@ -1501,11 +1770,7 @@ def _normalize_review_payload(
slide: Dict[str, Any],
local_layout_issues: List[str],
) -> Dict[str, Any]:
- issues = [
- str(item).strip()
- for item in (payload.get("issues") or [])
- if str(item).strip()
- ]
+ issues = self._normalize_outline_points(payload.get("issues"), limit=12, item_limit=220)
combined_issues: List[str] = []
for issue in [*local_layout_issues, *issues]:
if issue and issue not in combined_issues:
@@ -1542,11 +1807,7 @@ def _normalize_fields(
) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
seen_keys: set[str] = set()
- outline_points = [
- str(item).strip()
- for item in (outline_item.get("key_points") or [])
- if str(item).strip()
- ]
+ outline_points = self._normalize_outline_points(outline_item.get("key_points"), limit=6, item_limit=120)
if isinstance(raw_fields, list):
for raw_field in raw_fields:
@@ -1560,11 +1821,7 @@ def _normalize_fields(
field_type = "text"
label = str(raw_field.get("label") or key.replace("_", " ").title())
if field_type == "list":
- items = [
- str(item).strip()
- for item in (raw_field.get("items") or [])
- if str(item).strip()
- ]
+ items = self._normalize_outline_points(raw_field.get("items"), limit=8, item_limit=140)
if not items:
items = outline_points[:4]
normalized.append(
@@ -1577,7 +1834,7 @@ def _normalize_fields(
}
)
else:
- value = str(raw_field.get("value") or "").strip()
+ value = self._clean_text_content(raw_field.get("value"), "", 280)
normalized.append(
{
"key": key,
@@ -1595,7 +1852,7 @@ def _normalize_fields(
"key": "title",
"label": "Title",
"type": "text",
- "value": str(outline_item.get("title") or f"Slide {slide_index + 1}"),
+ "value": self._clean_text_content(outline_item.get("title"), f"Slide {slide_index + 1}", 220),
"items": [],
}
)
@@ -1605,9 +1862,11 @@ def _normalize_fields(
"key": "summary",
"label": "Summary",
"type": "textarea",
- "value": str(
- (outline_points[0] if outline_points else outline_item.get("layout_description") or "")
- ).strip(),
+ "value": self._clean_text_content(
+ outline_points[0] if outline_points else outline_item.get("layout_description"),
+ "",
+ 280,
+ ),
"items": [],
}
)
@@ -1624,6 +1883,7 @@ def _normalize_fields(
return normalized
def _build_fallback_theme(self, *, language: str, style: str) -> Dict[str, Any]:
+ style_family = self._infer_style_family(style)
footer_text = "Paper2Any Frontend PPT"
section_label_template = (
"第 {page_num:02d}/{slide_count:02d} 页"
@@ -1637,9 +1897,11 @@ def _build_fallback_theme(self, *, language: str, style: str) -> Dict[str, Any]:
)
)
palette = self._resolve_palette_from_style(style)
+ family_rules = self._build_family_rules(style_family)
return {
"theme_name": "scholarly_signal",
"visual_mood": visual_mood,
+ "style_family": style_family,
"palette": palette,
"typography": {
"title_font_stack": 'Georgia, "Times New Roman", serif',
@@ -1649,40 +1911,130 @@ def _build_fallback_theme(self, *, language: str, style: str) -> Dict[str, Any]:
"summary_size": 26,
"body_size": 24,
},
- "layout_rules": [
- "Keep 72px+ safe margins around major content.",
- "Prefer one dominant text area plus one supporting card or metrics block.",
- "Avoid more than two visual columns in a single slide.",
- "Reserve a quiet footer area for page identity or takeaway.",
- ],
- "component_rules": [
- "Use rounded cards with subtle borders and a restrained glow.",
- "Use one accent color only for emphasis, not for large fills.",
- "Keep text hierarchy clear with title, summary, and supporting bullets.",
- ],
+ "layout_rules": family_rules["layout_rules"],
+ "component_rules": family_rules["component_rules"],
"theme_lock": {
"must_keep": [
"Use only the deck palette colors for fills, borders, and emphasis.",
"Keep the same serif title style and sans body style across the deck.",
- "Keep rounded translucent cards and a quiet footer treatment.",
- ],
- "preferred_layout_patterns": [
- "hero_with_side_card",
- "split_insight_grid",
- "stacked_cards",
- "timeline_overview",
+ family_rules["must_keep"],
],
- "component_signature": "Rounded refined cards, restrained accent usage, thin borders, and quiet academic spacing.",
+ "preferred_layout_patterns": family_rules["preferred_layout_patterns"],
+ "component_signature": family_rules["component_signature"],
"avoid": [
"Do not introduce unrelated bright color families.",
"Do not use more than two main columns.",
- "Do not use oversized billboard titles or poster-like full-bleed blocks.",
+ family_rules["avoid"],
],
},
"footer_text": footer_text,
"section_label_template": section_label_template,
}
+ def _infer_style_family(self, style: str) -> str:
+ style_text = (style or "").strip().lower()
+ if any(keyword in style_text for keyword in ("academic", "report", "paper", "research", "严谨", "学术", "报告")):
+ return "academic"
+ if any(keyword in style_text for keyword in ("business", "brand", "corporate", "executive", "商务", "商业", "品牌")):
+ return "business"
+ if any(keyword in style_text for keyword in ("creative", "illustration", "warm", "friendly", "playful", "soft", "创意", "插画", "柔和")):
+ return "creative"
+ return "modern"
+
+ def _build_family_rules(self, style_family: str) -> Dict[str, Any]:
+ family = (style_family or "modern").strip().lower()
+ if family == "academic":
+ return {
+ "layout_rules": [
+ "Keep generous white or paper-like breathing room and stable reading rhythm.",
+ "Prefer section, bullets, two-column, and comparison layouts over showy hero frames.",
+ "Use image_focus only for genuinely visual pages.",
+ "Reserve a quiet footer for provenance or page identity.",
+ ],
+ "component_rules": [
+ "Use restrained panels, subtle dividers, and report-like hierarchy.",
+ "Keep decoration secondary to text structure and evidence density.",
+ "Avoid billboard marketing blocks or exaggerated hero cards.",
+ ],
+ "preferred_layout_patterns": [
+ "section_break",
+ "split_report_grid",
+ "comparison_columns",
+ "timeline_overview",
+ ],
+ "component_signature": "Refined report-style panels, paper-like spacing, and calm academic hierarchy.",
+ "must_keep": "Keep the visual language rigorous, airy, and report-like rather than glossy.",
+ "avoid": "Do not use neon glow, oversized promo badges, or playful sticker motifs.",
+ }
+ if family == "business":
+ return {
+ "layout_rules": [
+ "Favor crisp comparison, KPI-card, and executive-summary patterns.",
+ "Use strong alignment and clear block grouping with moderate density.",
+ "Keep image_focus to showcase slides only.",
+ "Prefer horizontal momentum and strong title anchoring.",
+ ],
+ "component_rules": [
+ "Use sharp, decisive cards with stronger contrast and cleaner edges.",
+ "Accent color should be used sparingly for strategic emphasis.",
+ "Make hierarchy feel presentation-room ready rather than article-like.",
+ ],
+ "preferred_layout_patterns": [
+ "executive_hero",
+ "split_insight_grid",
+ "kpi_cards",
+ "decision_comparison",
+ ],
+ "component_signature": "Crisp executive cards, strong title anchors, and controlled business contrast.",
+ "must_keep": "Keep the deck polished, decisive, and boardroom-oriented.",
+ "avoid": "Do not use whimsical illustration accents or soft scrapbook styling.",
+ }
+ if family == "creative":
+ return {
+ "layout_rules": [
+ "Allow more asymmetry, softer framing, and stronger hero moments.",
+ "Alternate between image_focus, section, cards, and timeline layouts to keep the deck lively.",
+ "Use comparison and two-column layouts only when the content clearly calls for them.",
+ "Let accent shapes support the narrative without overpowering text.",
+ ],
+ "component_rules": [
+ "Use soft panels, expressive color accents, and warmer visual transitions.",
+ "Preserve readability, but allow more character in backgrounds and separators.",
+ "Favor friendly, presentation-forward composition over report density.",
+ ],
+ "preferred_layout_patterns": [
+ "hero_spotlight",
+ "soft_cards",
+ "story_timeline",
+ "image_caption_feature",
+ ],
+ "component_signature": "Warm expressive panels, softer geometry, and more atmospheric deck motion.",
+ "must_keep": "Keep the deck warm, expressive, and visibly more playful than academic or business presets.",
+ "avoid": "Do not collapse the deck back into a uniform report grid on every page.",
+ }
+ return {
+ "layout_rules": [
+ "Keep 72px+ safe margins around major content.",
+ "Prefer one dominant text area plus one supporting card or metrics block.",
+ "Avoid more than two visual columns in a single slide.",
+ "Reserve a quiet footer area for page identity or takeaway.",
+ ],
+ "component_rules": [
+ "Use refined rounded cards with controlled glow and layered depth.",
+ "Use one accent color only for emphasis, not for large fills.",
+ "Keep text hierarchy clear with title, summary, and supporting bullets.",
+ ],
+ "preferred_layout_patterns": [
+ "hero_with_side_card",
+ "split_insight_grid",
+ "stacked_cards",
+ "timeline_overview",
+ ],
+ "component_signature": "Modern layered cards, restrained glow, and polished presentation spacing.",
+ "must_keep": "Keep the deck sleek, layered, and contemporary without drifting into plain report style.",
+ "avoid": "Do not flatten everything into plain white report blocks unless the prompt explicitly asks for that.",
+ }
+
def _resolve_palette_from_style(self, style: str) -> Dict[str, str]:
style_text = (style or "").strip().lower()
@@ -1802,27 +2154,26 @@ def _clean_int(value: Any, default: int, min_value: int, max_value: int) -> int:
return default
return max(min_value, min(max_value, parsed))
+ def _clean_style_family(value: Any, default: str) -> str:
+ candidate = str(value or "").strip().lower()
+ if candidate in {"modern", "business", "academic", "creative"}:
+ return candidate
+ return default
+
def _clean_list(value: Any, defaults: List[str], limit: int = 6) -> List[str]:
if isinstance(value, list):
- cleaned = [str(item).strip() for item in value if str(item).strip()]
+ cleaned = self._normalize_outline_points(value, limit=limit, item_limit=140)
if cleaned:
return cleaned[:limit]
return defaults[:limit]
- layout_rules = [
- str(item).strip()
- for item in (payload.get("layout_rules") or [])
- if str(item).strip()
- ][:6]
- component_rules = [
- str(item).strip()
- for item in (payload.get("component_rules") or [])
- if str(item).strip()
- ][:6]
+ layout_rules = self._normalize_outline_points(payload.get("layout_rules"), limit=6, item_limit=180)
+ component_rules = self._normalize_outline_points(payload.get("component_rules"), limit=6, item_limit=180)
return {
"theme_name": _clean_text(payload.get("theme_name"), fallback["theme_name"]),
"visual_mood": _clean_text(payload.get("visual_mood"), fallback["visual_mood"]),
+ "style_family": _clean_style_family(payload.get("style_family"), fallback["style_family"]),
"palette": {
"bg": _clean_color(palette_raw.get("bg"), fallback["palette"]["bg"]),
"panel": _clean_color(palette_raw.get("panel"), fallback["palette"]["panel"]),
@@ -1898,25 +2249,25 @@ def _build_theme_lock(self, theme: Dict[str, Any]) -> Dict[str, Any]:
theme_lock = theme.get("theme_lock")
if isinstance(theme_lock, dict):
return {
- "must_keep": [
- str(item).strip()
- for item in (theme_lock.get("must_keep") or [])
- if str(item).strip()
- ] or fallback["theme_lock"]["must_keep"],
- "preferred_layout_patterns": [
- str(item).strip()
- for item in (theme_lock.get("preferred_layout_patterns") or [])
- if str(item).strip()
- ] or fallback["theme_lock"]["preferred_layout_patterns"],
+ "must_keep": self._normalize_outline_points(
+ theme_lock.get("must_keep"),
+ limit=8,
+ item_limit=180,
+ ) or fallback["theme_lock"]["must_keep"],
+ "preferred_layout_patterns": self._normalize_outline_points(
+ theme_lock.get("preferred_layout_patterns"),
+ limit=8,
+ item_limit=180,
+ ) or fallback["theme_lock"]["preferred_layout_patterns"],
"component_signature": str(
theme_lock.get("component_signature")
or fallback["theme_lock"]["component_signature"]
).strip(),
- "avoid": [
- str(item).strip()
- for item in (theme_lock.get("avoid") or [])
- if str(item).strip()
- ] or fallback["theme_lock"]["avoid"],
+ "avoid": self._normalize_outline_points(
+ theme_lock.get("avoid"),
+ limit=8,
+ item_limit=180,
+ ) or fallback["theme_lock"]["avoid"],
}
return fallback["theme_lock"]
@@ -1927,6 +2278,7 @@ def _build_deck_identity_summary(self, theme: Dict[str, Any]) -> Dict[str, Any]:
return {
"theme_name": str(theme.get("theme_name") or "deck_theme").strip(),
"visual_mood": str(theme.get("visual_mood") or "").strip(),
+ "style_family": str(theme.get("style_family") or "modern").strip(),
"palette_anchor": {
"bg": str(palette.get("bg") or "").strip(),
"primary": str(palette.get("primary") or "").strip(),
@@ -2146,20 +2498,21 @@ def _load_reference_slides(
return [self._summarize_reference_slide(slide) for slide in references]
def _summarize_reference_slide(self, slide: Dict[str, Any]) -> Dict[str, Any]:
- html_template = str(slide.get("html_template") or "")
- css_code = str(slide.get("css_code") or "")
editable_fields = slide.get("editable_fields") or []
return {
"page_num": int(slide.get("page_num") or 0),
"title": str(slide.get("title") or "").strip(),
+ "layout_type": str(slide.get("layout_type") or slide.get("layoutType") or "").strip(),
"field_keys": [
str(field.get("key") or "").strip()
for field in editable_fields
if isinstance(field, dict) and str(field.get("key") or "").strip()
][:10],
- "html_outline": self._extract_html_outline(html_template),
- "component_classes": self._extract_component_classes(html_template, css_code),
- "css_selectors": self._extract_css_selectors(css_code),
+ "visual_asset_keys": [
+ str(asset.get("key") or "").strip()
+ for asset in (slide.get("visual_assets") or [])
+ if isinstance(asset, dict) and str(asset.get("key") or "").strip()
+ ][:4],
}
def _extract_html_outline(self, html_template: str, limit: int = 12) -> List[str]:
@@ -2216,6 +2569,42 @@ def _extract_css_selectors(self, css_code: str, limit: int = 8) -> List[str]:
break
return cleaned
+ def _choose_fallback_layout_type(
+ self,
+ *,
+ outline_item: Dict[str, Any],
+ slide_index: int,
+ theme: Dict[str, Any],
+ visual_assets: Sequence[Dict[str, Any]],
+ ) -> str:
+ style_family = str(theme.get("style_family") or "modern").strip().lower()
+ layout_hint = str(outline_item.get("layout_description") or "").lower()
+ title = str(outline_item.get("title") or "").lower()
+ key_points = self._normalize_outline_points(outline_item.get("key_points"), limit=6, item_limit=120)
+ bullet_count = len(key_points)
+
+ if slide_index == 0:
+ return "cover"
+ if any(keyword in title for keyword in ("overview", "agenda", "outline", "introduction", "background", "summary")):
+ return "section"
+ if any(keyword in layout_hint for keyword in ("compare", "contrast", "trade-off", "versus", "vs", "对比", "比较")):
+ return "comparison"
+ if any(keyword in layout_hint for keyword in ("timeline", "process", "workflow", "loop", "pipeline", "流程", "时间线")):
+ return "timeline"
+ if any(keyword in layout_hint for keyword in ("card", "grid", "domain", "application", "industry", "module", "模块", "领域")):
+ return "cards_2x2"
+ if bullet_count >= 5 and style_family in {"business", "modern"}:
+ return "two_column"
+ if visual_assets and style_family in {"creative", "modern"} and slide_index % 3 == 1:
+ return "image_focus"
+ if bullet_count >= 4 and style_family == "academic":
+ return "bullets"
+ if style_family == "business":
+ return "two_column"
+ if style_family == "creative":
+ return "cards_2x2"
+ return "bullets"
+
def _build_fallback_slide(
self,
*,
@@ -2225,315 +2614,46 @@ def _build_fallback_slide(
theme: Dict[str, Any],
visual_assets: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
- palette = theme.get("palette") or self._build_fallback_theme(language="zh", style="")["palette"]
- typography = theme.get("typography") or {}
visual_assets = (visual_assets or [])[:_MAX_INLINE_VISUAL_ASSETS]
- has_visual = bool(visual_assets)
- has_multi_visual = len(visual_assets) > 1
- key_points = [
- str(item).strip()
- for item in (outline_item.get("key_points") or [])
- if str(item).strip()
- ][:4]
- summary = key_points[0] if key_points else str(outline_item.get("layout_description") or "").strip()
+ key_points = self._normalize_outline_points(outline_item.get("key_points"), limit=4, item_limit=120)
+ summary = key_points[0] if key_points else self._clean_text_content(
+ outline_item.get("layout_description"),
+ "",
+ 280,
+ )
takeaway = key_points[-1] if key_points else "Refine the narrative in the editor"
section_template = str(theme.get("section_label_template") or "Slide {page_num:02d}/{slide_count:02d}")
try:
eyebrow = section_template.format(page_num=slide_index + 1, slide_count=slide_count)
except Exception: # noqa: BLE001
eyebrow = f"Slide {slide_index + 1:02d}/{slide_count:02d}"
-
- visual_markup = ""
- if has_visual:
- visual_markup = "\n".join(
- f' {{{{image:{asset.get("key") or self._build_visual_asset_key(asset_index)}}}}}
'
- for asset_index, asset in enumerate(visual_assets)
- )
-
- html_template = """
-
-
-
-
-
-
{{field:eyebrow}}
-
{{field:title}}
-
{{field:summary}}
- """ + (
- """
-
-"""
- if has_visual
- else ""
- ) + """
-
- """ + (
- """
-
-""" + visual_markup + """
-
-"""
- if has_visual
- else """
-
-
{{field:points_label}}
-
-
-"""
- ) + """
-
-
-
-
-""".strip()
-
- css_code = f"""
-.slide-root {{
- width: 100%;
- height: 100%;
- background:
- radial-gradient(circle at top right, {palette["secondary"]}33 0%, transparent 28%),
- radial-gradient(circle at bottom left, {palette["accent"]}22 0%, transparent 32%),
- {palette["bg"]};
- color: {palette["text"]};
- overflow: hidden;
-}}
-.slide-root * {{
- box-sizing: border-box;
-}}
-.slide-shell {{
- position: relative;
- width: 100%;
- height: 100%;
- padding: 68px 72px;
-}}
-.grid-layer {{
- position: absolute;
- inset: 0;
- background-image:
- linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
- linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
- background-size: 48px 48px;
- opacity: 0.22;
-}}
-.hero {{
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: {'1.08fr 0.92fr' if has_visual else '1.5fr 0.95fr'};
- gap: 28px;
- height: calc(100% - 120px);
-}}
-.hero-copy {{
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 20px;
-}}
-.eyebrow {{
- display: inline-flex;
- align-self: flex-start;
- padding: 8px 14px;
- border-radius: 999px;
- background: {palette["secondary"]}22;
- border: 1px solid {palette["primary"]}55;
- color: {palette["primary"]};
- font-size: {int(typography.get("eyebrow_size") or 18)}px;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}}
-.title {{
- margin: 0;
- max-width: 880px;
- font-size: {int(typography.get("title_size") or 56)}px;
- line-height: 1.04;
- letter-spacing: -0.04em;
- font-family: {typography.get("title_font_stack") or 'Georgia, "Times New Roman", serif'};
-}}
-.summary {{
- margin: 0;
- max-width: 840px;
- font-size: {int(typography.get("summary_size") or 26)}px;
- line-height: 1.42;
- color: {palette["muted"]};
- white-space: pre-wrap;
- font-family: {typography.get("body_font_stack") or '"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'};
-}}
-.stat-card, .takeaway-card, .visual-card {{
- border-radius: 28px;
- border: 1px solid {palette["primary"]}30;
- background: {palette["panel"]};
- box-shadow: 0 30px 60px rgba(15, 23, 42, 0.35);
- backdrop-filter: blur(10px);
-}}
-.stat-card {{
- align-self: center;
- padding: 28px;
-}}
-.visual-card {{
- padding: 18px;
- min-height: 420px;
- display: flex;
- flex-direction: column;
- gap: 14px;
-}}
-.visual-card.visual-card-grid {{
- justify-content: stretch;
-}}
-.visual-shell {{
- width: 100%;
- height: 100%;
- min-height: 384px;
- border-radius: 22px;
- overflow: hidden;
-}}
-.visual-card.visual-card-grid .visual-shell {{
- flex: 1 1 0;
- min-height: 160px;
-}}
-.visual-card.visual-card-grid .visual-shell-1 {{
- min-height: 236px;
-}}
-.card-label, .takeaway-label {{
- font-size: {int(typography.get("eyebrow_size") or 18)}px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: {palette["primary"]};
- margin-bottom: 14px;
- font-weight: 700;
-}}
-.bullet-list {{
- margin: 0;
- padding-left: 26px;
- display: grid;
- gap: 14px;
- font-size: {int(typography.get("body_size") or 24)}px;
- line-height: 1.35;
- font-family: {typography.get("body_font_stack") or '"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'};
-}}
-.bullet-list li {{
- color: {palette["text"]};
-}}
-.bullet-list.compact {{
- max-width: 720px;
- gap: 10px;
- font-size: {max(18, int(typography.get("body_size") or 24) - 2)}px;
-}}
-.footer-row {{
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: 1.4fr auto;
- align-items: end;
- gap: 18px;
-}}
-.takeaway-card {{
- padding: 24px 28px;
-}}
-.takeaway-text {{
- margin: 0;
- font-size: {int(typography.get("body_size") or 24)}px;
- line-height: 1.4;
- color: {palette["text"]};
- white-space: pre-wrap;
- font-family: {typography.get("body_font_stack") or '"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'};
-}}
-.footer-tag {{
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 220px;
- padding: 14px 18px;
- border-radius: 999px;
- border: 1px solid {palette["accent"]}55;
- color: {palette["accent"]};
- font-size: {int(typography.get("eyebrow_size") or 18)}px;
- font-weight: 700;
- background: rgba(15, 23, 42, 0.45);
-}}
-""".strip()
-
- editable_fields = [
- {
- "key": "eyebrow",
- "label": "Eyebrow",
- "type": "text",
- "value": eyebrow,
- "items": [],
- },
- {
- "key": "title",
- "label": "Title",
- "type": "text",
- "value": str(outline_item.get("title") or f"Slide {slide_index + 1}"),
- "items": [],
- },
- {
- "key": "summary",
- "label": "Summary",
- "type": "textarea",
- "value": summary,
- "items": [],
- },
- {
- "key": "key_points",
- "label": "Key Points",
- "type": "list",
- "value": "",
- "items": key_points or ["Summarize the page content here"],
- },
- {
- "key": "takeaway_label",
- "label": "Takeaway Label",
- "type": "text",
- "value": "Takeaway",
- "items": [],
- },
- {
- "key": "takeaway",
- "label": "Takeaway",
- "type": "textarea",
- "value": takeaway,
- "items": [],
- },
- {
- "key": "footer",
- "label": "Footer",
- "type": "text",
- "value": str(theme.get("footer_text") or "Paper2Any Frontend PPT"),
- "items": [],
- },
- ]
- if not has_visual:
- editable_fields.insert(
- 3,
- {
- "key": "points_label",
- "label": "Points Label",
- "type": "text",
- "value": "Key Points",
- "items": [],
- },
- )
-
- return {
- "slide_id": str(slide_index + 1),
- "page_num": slide_index + 1,
+ layout_type = self._choose_fallback_layout_type(
+ outline_item=outline_item,
+ slide_index=slide_index,
+ theme=theme,
+ visual_assets=visual_assets,
+ )
+ content = {
+ "eyebrow": eyebrow,
"title": str(outline_item.get("title") or f"Slide {slide_index + 1}"),
- "html_template": html_template,
- "css_code": css_code,
- "editable_fields": editable_fields,
- "visual_assets": visual_assets,
- "generation_note": "Built-in fallback template",
- "status": "done",
+ "summary": summary,
+ "bullets": key_points or ["Summarize the page content here"],
+ "takeaway": takeaway,
+ "footer": str(theme.get("footer_text") or "Paper2Any Structured PPT"),
+ "visual_caption": "Supporting visual",
}
+ slide = self._build_structured_slide(
+ layout_type=layout_type,
+ content=content,
+ outline_item=outline_item,
+ slide_index=slide_index,
+ slide_count=slide_count,
+ theme=theme,
+ visual_assets=visual_assets,
+ generation_note="Built-in fallback structured slide",
+ )
+ slide["generation_note"] = "Built-in fallback structured slide"
+ return slide
def _sanitize_html_template(self, html_template: str) -> str:
cleaned = re.sub(r"<\s*/?\s*(html|head|body)\b[^>]*>", "", html_template, flags=re.IGNORECASE)
@@ -2567,13 +2687,9 @@ def _replace_field(token_match: re.Match[str]) -> str:
if field is None:
return ""
if str(field.get("type") or "") == "list":
- raw_value = " • ".join(
- str(item).strip()
- for item in (field.get("items") or [])
- if str(item).strip()
- )
+ raw_value = " • ".join(self._normalize_outline_points(field.get("items"), limit=12, item_limit=180))
else:
- raw_value = str(field.get("value") or "")
+ raw_value = self._extract_outline_text(field.get("value"))
return html.escape(" ".join(raw_value.split()), quote=True)
next_value = re.sub(r"\{\{field:([a-zA-Z0-9_]+)\}\}", _replace_field, next_value)
diff --git a/fastapi_app/services/paper2ppt_service.py b/fastapi_app/services/paper2ppt_service.py
index f2747960..086af256 100644
--- a/fastapi_app/services/paper2ppt_service.py
+++ b/fastapi_app/services/paper2ppt_service.py
@@ -98,13 +98,16 @@
import copy
import hashlib
+import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import HTTPException, Request, UploadFile
+import httpx
from PIL import Image, ImageOps, UnidentifiedImageError
+from fastapi_app.config import settings
from fastapi_app.schemas import (
FullPipelineRequest,
OutlineRefineRequest,
@@ -114,6 +117,7 @@
from fastapi_app.services.managed_api_service import (
resolve_image_generation_credentials,
resolve_llm_credentials,
+ resolve_model_name,
)
from fastapi_app.utils import (
_to_outputs_url,
@@ -122,12 +126,13 @@
)
from fastapi_app.workflow_adapters.wa_paper2ppt import (
run_paper2page_content_wf_api,
- run_paper2page_content_refine_wf_api,
run_paper2ppt_full_pipeline,
run_paper2ppt_wf_api,
)
+from dataflow_agent.promptstemplates import PromptsTemplateGenerator
from dataflow_agent.logger import get_logger
from dataflow_agent.utils import get_project_root
+from dataflow_agent.utils_common import robust_parse_json
log = get_logger(__name__)
@@ -138,6 +143,10 @@
_PREVIEW_JPEG_QUALITY = 82
_PIL_RESAMPLING = getattr(Image, "Resampling", Image)
_PIL_LANCZOS = _PIL_RESAMPLING.LANCZOS
+_OUTLINE_PATCH_CHUNK_SIZE = 4
+_OUTLINE_PLAN_SOURCE_CHAR_LIMIT = 12000
+_OUTLINE_PLAN_PAGE_DIGEST_LIMIT = 22000
+_OUTLINE_REWRITE_SOURCE_CHAR_LIMIT = 9000
class Paper2PPTService:
@@ -234,7 +243,11 @@ async def get_page_content(
credential_scope=credential_scope,
chat_api_key=resolved_api_key,
api_key=resolved_api_key,
- model=req.model,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_OUTLINE_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
gen_fig_model="",
input_type=wf_input_type,
input_content=wf_input_content,
@@ -249,6 +262,21 @@ async def get_page_content(
resp_model = await run_paper2page_content_wf_api(p2ppt_req, result_path=run_dir)
resp_dict = resp_model.model_dump()
+ resp_dict["pagecontent"] = self._normalize_pagecontent_items(resp_dict.get("pagecontent", []))
+ if not resp_dict["pagecontent"]:
+ backend_error = str(resp_dict.get("error") or "").strip()
+ if backend_error:
+ raise HTTPException(status_code=502, detail=backend_error)
+ raw_text = (req.text or "").strip()
+ if str(req.input_type).lower() == "text" and use_long_paper_bool and req.page_count > 20:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"当前为文本模式,输入内容仅 {len(raw_text)} 个字符,不足以稳定生成 {req.page_count} 页长文大纲。"
+ "请提供更完整的正文,或改用 Topic 模式。"
+ ),
+ )
+ raise HTTPException(status_code=502, detail="后端未生成有效大纲,请稍后重试")
if request is not None:
resp_dict["pagecontent"] = self._convert_pagecontent_paths_to_urls(
resp_dict.get("pagecontent", []), request, resp_model.result_path
@@ -286,7 +314,11 @@ async def refine_outline(
credential_scope=credential_scope,
chat_api_key=resolved_api_key,
api_key=resolved_api_key,
- model=req.model,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_OUTLINE_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
gen_fig_model="",
input_type="TEXT",
input_content="",
@@ -299,20 +331,25 @@ async def refine_outline(
if req.result_path:
result_root = self.resolve_result_path(req.result_path)
- resp_model = await run_paper2page_content_refine_wf_api(
- p2ppt_req,
+ source_text, mineru_output = self._load_outline_refine_source_context(result_root)
+ refined_pagecontent = await self._refine_outline_with_patch_plan(
+ req=p2ppt_req,
pagecontent=pc,
outline_feedback=req.outline_feedback,
- result_path=result_root,
+ source_text=source_text,
+ mineru_output=mineru_output,
)
-
- resp_dict = resp_model.model_dump()
+ resp_dict: Dict[str, Any] = {
+ "success": True,
+ "pagecontent": self._normalize_pagecontent_items(refined_pagecontent),
+ "result_path": str(result_root) if result_root is not None else (req.result_path or ""),
+ }
if request is not None:
resp_dict["pagecontent"] = self._convert_pagecontent_paths_to_urls(
- resp_dict.get("pagecontent", []), request, resp_model.result_path
+ resp_dict.get("pagecontent", []), request, resp_dict.get("result_path")
)
- if request is not None:
- resp_dict["all_output_files"] = self._collect_output_files_as_urls(resp_model.result_path, request)
+ if request is not None and resp_dict.get("result_path"):
+ resp_dict["all_output_files"] = self._collect_output_files_as_urls(resp_dict["result_path"], request)
else:
resp_dict["all_output_files"] = []
@@ -381,8 +418,16 @@ async def generate_ppt(
api_key=resolved_api_key,
image_api_url=resolved_image_api_url,
image_api_key=resolved_image_api_key,
- model=req.model,
- gen_fig_model=req.img_gen_model_name,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_CONTENT_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
+ gen_fig_model=resolve_model_name(
+ req.img_gen_model_name,
+ managed_default=settings.PAPER2PPT_IMAGE_GEN_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_IMAGE_MODEL,
+ ),
input_type="PDF",
input_content="",
aspect_ratio=req.aspect_ratio,
@@ -446,8 +491,16 @@ async def run_full_pipeline(
api_key=resolved_api_key,
image_api_url=resolved_image_api_url,
image_api_key=resolved_image_api_key,
- model=req.model,
- gen_fig_model=req.img_gen_model_name,
+ model=resolve_model_name(
+ req.model,
+ managed_default=settings.PAPER2PPT_CONTENT_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_MODEL,
+ ),
+ gen_fig_model=resolve_model_name(
+ req.img_gen_model_name,
+ managed_default=settings.PAPER2PPT_IMAGE_GEN_MODEL,
+ fallback_default=settings.PAPER2PPT_DEFAULT_IMAGE_MODEL,
+ ),
input_type=wf_input_type,
input_content=wf_input_content,
aspect_ratio=req.aspect_ratio,
@@ -833,8 +886,6 @@ def _collect_output_files_as_urls(self, result_path: str, request: Request) -> l
def _parse_pagecontent_json(self, pagecontent_json: str) -> List[Dict[str, Any]]:
try:
- import json
-
obj = json.loads(pagecontent_json)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"invalid pagecontent json: {e}") from e
@@ -845,4 +896,568 @@ def _parse_pagecontent_json(self, pagecontent_json: str) -> List[Dict[str, Any]]
for i, it in enumerate(obj):
if not isinstance(it, dict):
raise HTTPException(status_code=400, detail=f"pagecontent[{i}] must be an object(dict)")
- return obj
+ return self._normalize_pagecontent_items(obj)
+
+ def _extract_outline_text(self, value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return " ".join(value.strip().split())
+ if isinstance(value, (int, float, bool)):
+ return str(value)
+ if isinstance(value, dict):
+ preferred_keys = (
+ "text",
+ "value",
+ "content",
+ "summary",
+ "title",
+ "label",
+ "body",
+ "description",
+ "reason",
+ "point",
+ )
+ for key in preferred_keys:
+ text = self._extract_outline_text(value.get(key))
+ if text:
+ return text
+ for item in value.values():
+ text = self._extract_outline_text(item)
+ if text:
+ return text
+ return ""
+ if isinstance(value, list):
+ parts = [self._extract_outline_text(item) for item in value]
+ return " ".join(part for part in parts if part)
+ return " ".join(str(value).strip().split())
+
+ def _normalize_outline_points(self, value: Any) -> List[str]:
+ if isinstance(value, list):
+ items = [self._extract_outline_text(item) for item in value]
+ else:
+ items = [self._extract_outline_text(value)]
+ return [item for item in items if item]
+
+ def _normalize_pagecontent_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ normalized: List[Dict[str, Any]] = []
+ for raw in items:
+ item = copy.deepcopy(raw)
+ if "title" in item:
+ item["title"] = self._extract_outline_text(item.get("title"))
+ if "layout_description" in item:
+ item["layout_description"] = self._extract_outline_text(item.get("layout_description"))
+ if "key_points" in item:
+ item["key_points"] = self._normalize_outline_points(item.get("key_points"))
+ if "asset_ref" in item:
+ asset_ref = item.get("asset_ref")
+ if isinstance(asset_ref, list):
+ normalized_refs = self._normalize_outline_points(asset_ref)
+ item["asset_ref"] = normalized_refs[0] if normalized_refs else None
+ else:
+ normalized_ref = self._extract_outline_text(asset_ref)
+ item["asset_ref"] = normalized_ref or None
+ normalized.append(item)
+ return normalized
+
+ def _load_outline_refine_source_context(self, result_root: Path | None) -> tuple[str, str]:
+ if result_root is None:
+ return "", ""
+
+ input_dir = result_root / "input"
+ text_candidates = [
+ input_dir / "input.txt",
+ input_dir / "input_topic.txt",
+ ]
+ source_text = ""
+ for candidate in text_candidates:
+ if candidate.exists():
+ try:
+ source_text = candidate.read_text(encoding="utf-8")
+ if source_text.strip():
+ break
+ except Exception:
+ continue
+
+ mineru_output = ""
+ try:
+ markdown_candidates = list(result_root.glob("*/auto/*.md"))
+ except Exception:
+ markdown_candidates = []
+ for candidate in markdown_candidates:
+ try:
+ mineru_output = candidate.read_text(encoding="utf-8")
+ if mineru_output.strip():
+ break
+ except Exception:
+ continue
+
+ return source_text, mineru_output
+
+ async def _refine_outline_with_patch_plan(
+ self,
+ *,
+ req: Any,
+ pagecontent: List[Dict[str, Any]],
+ outline_feedback: str,
+ source_text: str,
+ mineru_output: str,
+ ) -> List[Dict[str, Any]]:
+ original_pages = self._normalize_pagecontent_items(pagecontent)
+ if not original_pages:
+ return []
+
+ plan = await self._plan_outline_refine_operations(
+ req=req,
+ pagecontent=original_pages,
+ outline_feedback=outline_feedback,
+ source_text=source_text,
+ mineru_output=mineru_output,
+ )
+ working_pages, rewrite_targets = self._apply_outline_patch_plan(original_pages, plan)
+ rewritten_pages = await self._rewrite_outline_pages_by_chunks(
+ req=req,
+ pagecontent=working_pages,
+ rewrite_targets=rewrite_targets,
+ global_instruction=str(plan.get("global_instruction") or "").strip(),
+ outline_feedback=outline_feedback,
+ source_text=source_text,
+ mineru_output=mineru_output,
+ )
+ return self._normalize_pagecontent_items(rewritten_pages)
+
+ async def _plan_outline_refine_operations(
+ self,
+ *,
+ req: Any,
+ pagecontent: List[Dict[str, Any]],
+ outline_feedback: str,
+ source_text: str,
+ mineru_output: str,
+ ) -> Dict[str, Any]:
+ prompt_generator = PromptsTemplateGenerator(output_language=req.language)
+ outline_digest = self._build_outline_digest(pagecontent, max_chars=_OUTLINE_PLAN_PAGE_DIGEST_LIMIT)
+ source_excerpt = self._build_source_excerpt(
+ source_text=source_text,
+ mineru_output=mineru_output,
+ max_chars=_OUTLINE_PLAN_SOURCE_CHAR_LIMIT,
+ )
+ system_prompt = prompt_generator.render(
+ "system_prompt_for_paper2ppt_outline_edit_planner_agent",
+ language=req.language,
+ )
+ task_prompt = prompt_generator.render(
+ "task_prompt_for_paper2ppt_outline_edit_planner_agent",
+ page_count=len(pagecontent),
+ outline_feedback=outline_feedback,
+ outline_digest=outline_digest,
+ source_excerpt=source_excerpt,
+ language=req.language,
+ )
+
+ try:
+ raw_plan = await self._invoke_json_llm(
+ chat_api_url=req.chat_api_url,
+ api_key=req.api_key,
+ model_name=req.model,
+ system_prompt=system_prompt,
+ task_prompt=task_prompt,
+ max_tokens=4096,
+ )
+ except Exception as exc:
+ log.warning("[paper2ppt][outline-refine] planner failed: %s", exc)
+ raw_plan = {}
+
+ return self._normalize_outline_patch_plan(
+ raw_plan,
+ page_count=len(pagecontent),
+ outline_feedback=outline_feedback,
+ )
+
+ async def _rewrite_outline_pages_by_chunks(
+ self,
+ *,
+ req: Any,
+ pagecontent: List[Dict[str, Any]],
+ rewrite_targets: set[int],
+ global_instruction: str,
+ outline_feedback: str,
+ source_text: str,
+ mineru_output: str,
+ ) -> List[Dict[str, Any]]:
+ normalized_pages = self._normalize_pagecontent_items(pagecontent)
+ if not normalized_pages:
+ return []
+
+ if not rewrite_targets:
+ return normalized_pages
+
+ prompt_generator = PromptsTemplateGenerator(output_language=req.language)
+ source_excerpt = self._build_source_excerpt(
+ source_text=source_text,
+ mineru_output=mineru_output,
+ max_chars=_OUTLINE_REWRITE_SOURCE_CHAR_LIMIT,
+ )
+ chunks = self._build_rewrite_chunks(sorted(rewrite_targets), len(normalized_pages), _OUTLINE_PATCH_CHUNK_SIZE)
+ rewritten_pages = copy.deepcopy(normalized_pages)
+
+ for chunk_start, chunk_end in chunks:
+ chunk_pages = copy.deepcopy(normalized_pages[chunk_start : chunk_end + 1])
+ page_instruction_lines: List[str] = []
+ for offset, page in enumerate(chunk_pages, start=chunk_start + 1):
+ specific_instruction = str(page.pop("_patch_instruction", "") or "").strip()
+ if specific_instruction:
+ page_instruction_lines.append(f"- Page {offset}: {specific_instruction}")
+ previous_title = rewritten_pages[chunk_start - 1]["title"] if chunk_start > 0 else ""
+ next_title = rewritten_pages[chunk_end + 1]["title"] if chunk_end + 1 < len(rewritten_pages) else ""
+
+ system_prompt = prompt_generator.render(
+ "system_prompt_for_paper2ppt_outline_patch_rewriter_agent",
+ language=req.language,
+ )
+ task_prompt = prompt_generator.render(
+ "task_prompt_for_paper2ppt_outline_patch_rewriter_agent",
+ page_count=len(chunk_pages),
+ chunk_start=chunk_start + 1,
+ chunk_end=chunk_end + 1,
+ outline_feedback=outline_feedback,
+ global_instruction=global_instruction or outline_feedback,
+ page_specific_instructions="\n".join(page_instruction_lines) or "- None",
+ previous_title=previous_title or "None",
+ next_title=next_title or "None",
+ source_excerpt=source_excerpt,
+ pagecontent=json.dumps(chunk_pages, ensure_ascii=False, indent=2),
+ language=req.language,
+ )
+
+ try:
+ raw_rewrite = await self._invoke_json_llm(
+ chat_api_url=req.chat_api_url,
+ api_key=req.api_key,
+ model_name=req.model,
+ system_prompt=system_prompt,
+ task_prompt=task_prompt,
+ max_tokens=4096,
+ )
+ except Exception as exc:
+ log.warning(
+ "[paper2ppt][outline-refine] chunk rewrite failed for %s-%s: %s",
+ chunk_start + 1,
+ chunk_end + 1,
+ exc,
+ )
+ continue
+
+ if not isinstance(raw_rewrite, list) or len(raw_rewrite) != len(chunk_pages):
+ log.warning(
+ "[paper2ppt][outline-refine] chunk rewrite length mismatch for %s-%s, keep original",
+ chunk_start + 1,
+ chunk_end + 1,
+ )
+ continue
+
+ normalized_chunk = self._normalize_pagecontent_items(
+ [item for item in raw_rewrite if isinstance(item, dict)]
+ )
+ if len(normalized_chunk) != len(chunk_pages):
+ log.warning(
+ "[paper2ppt][outline-refine] chunk rewrite invalid payload for %s-%s, keep original",
+ chunk_start + 1,
+ chunk_end + 1,
+ )
+ continue
+
+ merged_chunk: List[Dict[str, Any]] = []
+ for original_page, rewritten_page in zip(chunk_pages, normalized_chunk):
+ merged_page = copy.deepcopy(original_page)
+ merged_page["title"] = rewritten_page.get("title") or original_page.get("title") or ""
+ merged_page["layout_description"] = (
+ rewritten_page.get("layout_description")
+ or original_page.get("layout_description")
+ or ""
+ )
+ rewritten_points = self._normalize_outline_points(rewritten_page.get("key_points", []))
+ merged_page["key_points"] = rewritten_points or original_page.get("key_points") or []
+ if rewritten_page.get("asset_ref") is not None:
+ merged_page["asset_ref"] = rewritten_page.get("asset_ref")
+ merged_chunk.append(merged_page)
+
+ rewritten_pages[chunk_start : chunk_end + 1] = merged_chunk
+
+ for page in rewritten_pages:
+ if isinstance(page, dict):
+ page.pop("_origin_page_number", None)
+ page.pop("_patch_instruction", None)
+ return rewritten_pages
+
+ def _normalize_outline_patch_plan(
+ self,
+ raw_plan: Any,
+ *,
+ page_count: int,
+ outline_feedback: str,
+ ) -> Dict[str, Any]:
+ base_plan: Dict[str, Any] = {
+ "global_instruction": outline_feedback.strip(),
+ "apply_global_rewrite": True,
+ "operations": [],
+ }
+ if not isinstance(raw_plan, dict):
+ return base_plan
+
+ global_instruction = self._extract_outline_text(raw_plan.get("global_instruction")) or outline_feedback.strip()
+ apply_global_rewrite = bool(raw_plan.get("apply_global_rewrite"))
+ operations: List[Dict[str, Any]] = []
+ raw_operations = raw_plan.get("operations")
+ if isinstance(raw_operations, list):
+ for raw_operation in raw_operations:
+ if not isinstance(raw_operation, dict):
+ continue
+ op_type = str(raw_operation.get("type") or "").strip().lower()
+ if op_type not in {"update", "delete", "insert_after", "move"}:
+ continue
+ normalized_op: Dict[str, Any] = {"type": op_type}
+ if op_type in {"update", "delete", "move"}:
+ page_numbers = self._normalize_plan_page_numbers(raw_operation.get("page_numbers"), page_count)
+ if not page_numbers:
+ continue
+ normalized_op["page_numbers"] = page_numbers
+ if op_type == "update":
+ instruction = self._extract_outline_text(raw_operation.get("instruction"))
+ if not instruction:
+ continue
+ normalized_op["instruction"] = instruction
+ if op_type == "insert_after":
+ after_page = self._normalize_single_page_number(raw_operation.get("page_number"), page_count)
+ count = self._normalize_insert_count(raw_operation.get("count"))
+ instruction = self._extract_outline_text(raw_operation.get("instruction"))
+ if after_page is None or count <= 0 or not instruction:
+ continue
+ normalized_op["page_number"] = after_page
+ normalized_op["count"] = count
+ normalized_op["instruction"] = instruction
+ if op_type == "move":
+ after_page = self._normalize_single_page_number(raw_operation.get("after_page_number"), page_count)
+ if after_page is None:
+ continue
+ normalized_op["after_page_number"] = after_page
+ operations.append(normalized_op)
+
+ if operations:
+ explicit_restructure = any(op["type"] in {"insert_after", "delete", "move"} for op in operations)
+ apply_global_rewrite = bool(raw_plan.get("apply_global_rewrite")) if raw_plan.get("apply_global_rewrite") is not None else not explicit_restructure
+
+ return {
+ "global_instruction": global_instruction,
+ "apply_global_rewrite": apply_global_rewrite or not operations,
+ "operations": operations,
+ }
+
+ def _normalize_plan_page_numbers(self, value: Any, page_count: int) -> List[int]:
+ if not isinstance(value, list):
+ return []
+ numbers: set[int] = set()
+ for item in value:
+ normalized = self._normalize_single_page_number(item, page_count)
+ if normalized is not None:
+ numbers.add(normalized)
+ return sorted(numbers)
+
+ def _normalize_single_page_number(self, value: Any, page_count: int) -> Optional[int]:
+ try:
+ number = int(str(value).strip())
+ except Exception:
+ return None
+ if 1 <= number <= page_count:
+ return number
+ return None
+
+ def _normalize_insert_count(self, value: Any) -> int:
+ try:
+ count = int(str(value).strip())
+ except Exception:
+ return 0
+ return max(0, min(count, 8))
+
+ def _apply_outline_patch_plan(
+ self,
+ pagecontent: List[Dict[str, Any]],
+ plan: Dict[str, Any],
+ ) -> tuple[List[Dict[str, Any]], set[int]]:
+ working_pages: List[Dict[str, Any]] = []
+ for idx, page in enumerate(self._normalize_pagecontent_items(pagecontent), start=1):
+ page_copy = copy.deepcopy(page)
+ page_copy["_origin_page_number"] = idx
+ working_pages.append(page_copy)
+
+ rewrite_targets: set[int] = set()
+ for operation in plan.get("operations", []):
+ op_type = operation.get("type")
+ if op_type == "delete":
+ delete_numbers = set(operation.get("page_numbers", []))
+ working_pages = [
+ page for page in working_pages
+ if page.get("_origin_page_number") not in delete_numbers
+ ]
+ continue
+
+ if op_type == "move":
+ move_numbers = operation.get("page_numbers", [])
+ after_page_number = operation.get("after_page_number")
+ moving_pages = [
+ page for page in working_pages
+ if page.get("_origin_page_number") in move_numbers
+ ]
+ if not moving_pages:
+ continue
+ working_pages = [
+ page for page in working_pages
+ if page.get("_origin_page_number") not in move_numbers
+ ]
+ insert_index = self._find_insert_index_after_origin(working_pages, after_page_number)
+ working_pages[insert_index:insert_index] = moving_pages
+ continue
+
+ if op_type == "insert_after":
+ insert_after = operation.get("page_number")
+ insert_index = self._find_insert_index_after_origin(working_pages, insert_after)
+ for offset in range(operation.get("count", 0)):
+ placeholder = {
+ "title": "",
+ "layout_description": "",
+ "key_points": [],
+ "asset_ref": None,
+ "_origin_page_number": None,
+ "_patch_instruction": operation.get("instruction", ""),
+ }
+ working_pages.insert(insert_index + offset, placeholder)
+ rewrite_targets.add(insert_index + offset)
+ continue
+
+ if op_type == "update":
+ update_numbers = set(operation.get("page_numbers", []))
+ for idx, page in enumerate(working_pages):
+ if page.get("_origin_page_number") in update_numbers:
+ existing_instruction = str(page.get("_patch_instruction") or "").strip()
+ new_instruction = str(operation.get("instruction") or "").strip()
+ if existing_instruction and new_instruction:
+ page["_patch_instruction"] = f"{existing_instruction}\n{new_instruction}"
+ elif new_instruction:
+ page["_patch_instruction"] = new_instruction
+ rewrite_targets.add(idx)
+
+ if plan.get("apply_global_rewrite"):
+ rewrite_targets.update(range(len(working_pages)))
+
+ return working_pages, rewrite_targets
+
+ def _find_insert_index_after_origin(self, pages: List[Dict[str, Any]], after_page_number: Optional[int]) -> int:
+ if after_page_number is None:
+ return len(pages)
+ for idx, page in enumerate(pages):
+ if page.get("_origin_page_number") == after_page_number:
+ return idx + 1
+ return len(pages)
+
+ def _build_rewrite_chunks(self, target_indices: List[int], total_pages: int, max_chunk_size: int) -> List[tuple[int, int]]:
+ if not target_indices or total_pages <= 0:
+ return []
+
+ chunks: List[tuple[int, int]] = []
+ group_start = target_indices[0]
+ previous = target_indices[0]
+ for index in target_indices[1:]:
+ group_size = previous - group_start + 1
+ if index != previous + 1 or group_size >= max_chunk_size:
+ chunks.append((group_start, previous))
+ group_start = index
+ previous = index
+ chunks.append((group_start, previous))
+ return chunks
+
+ def _build_outline_digest(self, pagecontent: List[Dict[str, Any]], *, max_chars: int) -> str:
+ parts: List[str] = []
+ current_len = 0
+ for idx, page in enumerate(pagecontent, start=1):
+ bullet_preview = "; ".join((page.get("key_points") or [])[:3])
+ line = (
+ f"Page {idx}: title={page.get('title') or ''} | "
+ f"layout={page.get('layout_description') or ''} | "
+ f"bullets={bullet_preview}"
+ ).strip()
+ if not line:
+ continue
+ if current_len + len(line) > max_chars:
+ break
+ parts.append(line)
+ current_len += len(line) + 1
+ return "\n".join(parts)
+
+ def _build_source_excerpt(
+ self,
+ *,
+ source_text: str,
+ mineru_output: str,
+ max_chars: int,
+ ) -> str:
+ combined = (source_text or "").strip()
+ if mineru_output and mineru_output.strip():
+ combined = f"{combined}\n\n{mineru_output.strip()}".strip()
+ if len(combined) <= max_chars:
+ return combined
+ return combined[:max_chars]
+
+ async def _invoke_json_llm(
+ self,
+ *,
+ chat_api_url: str,
+ api_key: str,
+ model_name: str,
+ system_prompt: str,
+ task_prompt: str,
+ max_tokens: int = 4096,
+ ) -> Any:
+ base_url = str(chat_api_url or "").rstrip("/")
+ if not base_url:
+ raise ValueError("chat_api_url is required for outline refine")
+ endpoint = f"{base_url}/chat/completions"
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+ payload = {
+ "model": model_name,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": task_prompt},
+ ],
+ "temperature": 0.0,
+ "max_tokens": max_tokens,
+ }
+
+ proxy = None
+ for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"):
+ value = str(os.getenv(key) or "").strip()
+ if value.startswith(("http://", "https://")):
+ proxy = value
+ break
+
+ client_kwargs: Dict[str, Any] = {
+ "timeout": httpx.Timeout(300.0),
+ "trust_env": False,
+ }
+ if proxy:
+ client_kwargs["proxy"] = proxy
+
+ async with httpx.AsyncClient(**client_kwargs) as client:
+ response = await client.post(endpoint, headers=headers, json=payload)
+ response.raise_for_status()
+ body = response.json()
+
+ raw_text = self._extract_outline_text(
+ (((body.get("choices") or [{}])[0]).get("message") or {}).get("content")
+ )
+ if not raw_text.strip():
+ raise ValueError("LLM returned empty content for outline refine")
+ return robust_parse_json(raw_text, strip_double_braces=True)
diff --git a/fastapi_app/services/paper2video_service.py b/fastapi_app/services/paper2video_service.py
index 4d7c2182..fc8a9298 100644
--- a/fastapi_app/services/paper2video_service.py
+++ b/fastapi_app/services/paper2video_service.py
@@ -22,8 +22,9 @@
from fastapi import HTTPException, Request, UploadFile
+from fastapi_app.config import settings
from fastapi_app.schemas import GenerateSubtitleResponse, GenerateVideoResponse
-from fastapi_app.services.managed_api_service import resolve_llm_credentials
+from fastapi_app.services.managed_api_service import resolve_llm_credentials, resolve_model_name
from fastapi_app.utils import _to_outputs_url, get_outputs_root, resolve_outputs_path
from fastapi_app.workflow_adapters.wa_paper2video import (
run_paper2video_generate_subtitle_wf_api,
@@ -141,6 +142,16 @@ async def run_generate_subtitle(
api_key,
scope="paper2video",
)
+ model = resolve_model_name(
+ model,
+ managed_default=settings.PAPER2VIDEO_DEFAULT_MODEL,
+ fallback_default="gpt-4o",
+ )
+ tts_model = resolve_model_name(
+ tts_model,
+ managed_default=settings.PAPER2VIDEO_TTS_MODEL,
+ fallback_default="cosyvoice-v3-flash",
+ )
run_dir = self._create_timestamp_run_dir(email)
input_dir = run_dir / "input"
@@ -175,6 +186,11 @@ async def run_generate_subtitle(
else:
pdf_path = input_path
log.info("[Paper2VideoService] using PDF for workflow: %s", pdf_path)
+ talking_model = resolve_model_name(
+ talking_model,
+ managed_default=settings.PAPER2VIDEO_TALKING_MODEL,
+ fallback_default="liveportrait",
+ ) or "liveportrait"
talking_model = self._normalize_talking_model(talking_model)
# 可选:数字人头像(上传文件优先;否则使用系统预设 avatar_preset)
diff --git a/fastapi_app/services/pdf2ppt_service.py b/fastapi_app/services/pdf2ppt_service.py
index fb689ba8..c38448c5 100644
--- a/fastapi_app/services/pdf2ppt_service.py
+++ b/fastapi_app/services/pdf2ppt_service.py
@@ -6,11 +6,13 @@
import fitz
from fastapi import File, UploadFile, HTTPException
+from fastapi_app.config import settings
from fastapi_app.schemas import Paper2PPTRequest
from fastapi_app.interprocess_lock import AsyncInterProcessSemaphore
from fastapi_app.services.managed_api_service import (
resolve_image_generation_credentials,
resolve_llm_credentials,
+ resolve_model_name,
)
from fastapi_app.workflow_adapters.wa_pdf2ppt import run_pdf2ppt_wf_api
from dataflow_agent.utils import get_project_root
@@ -73,6 +75,14 @@ async def generate_ppt(
api_key,
scope="pdf2ppt",
)
+ model = resolve_model_name(
+ model,
+ managed_default=settings.PDF2PPT_DEFAULT_MODEL,
+ )
+ gen_fig_model = resolve_model_name(
+ gen_fig_model,
+ managed_default=settings.PDF2PPT_DEFAULT_IMAGE_MODEL,
+ )
# 0.5 如果启用 AI 增强,必须校验 API 配置
if use_ai_edit:
if not resolved_chat_api_url or not resolved_api_key:
diff --git a/fastapi_app/services/rebuttal_service.py b/fastapi_app/services/rebuttal_service.py
index 547691b0..d9b64acb 100644
--- a/fastapi_app/services/rebuttal_service.py
+++ b/fastapi_app/services/rebuttal_service.py
@@ -19,7 +19,8 @@
_fix_json_escapes,
)
from dataflow_agent.logger import get_logger
-from fastapi_app.services.managed_api_service import resolve_llm_credentials
+from fastapi_app.config import settings
+from fastapi_app.services.managed_api_service import resolve_llm_credentials, resolve_model_name
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -54,6 +55,11 @@ def init_llm_client(api_key: str, chat_api_url: str = None, provider: str = None
"""
global llm_client
chat_api_url, api_key = resolve_llm_credentials(chat_api_url, api_key, scope="paper2rebuttal")
+ model = resolve_model_name(
+ model,
+ managed_default=settings.PAPER2REBUTTAL_DEFAULT_MODEL,
+ fallback_default="gpt-4o",
+ )
if not chat_api_url:
raise ValueError("chat_api_url is required; URL and API key are passed from frontend.")
diff --git a/fastapi_app/workflow_adapters/wa_paper2ppt.py b/fastapi_app/workflow_adapters/wa_paper2ppt.py
index f9254195..ebbf0595 100644
--- a/fastapi_app/workflow_adapters/wa_paper2ppt.py
+++ b/fastapi_app/workflow_adapters/wa_paper2ppt.py
@@ -248,6 +248,15 @@ async def run_paper2page_content_wf_api(req: Paper2PPTRequest, result_path: Path
final_state: Paper2FigureState = await run_workflow(workflow_name, state)
# 提取结果
pagecontent = _state_get(final_state, "pagecontent", []) or []
+ outline_generation_error = _state_get(final_state, "outline_generation_error", "") or ""
+ if not isinstance(pagecontent, list):
+ log.warning(
+ "[paper2page_content_wf_api] invalid pagecontent payload type=%s, coercing to empty list",
+ type(pagecontent).__name__,
+ )
+ if not outline_generation_error and isinstance(pagecontent, dict):
+ outline_generation_error = _state_get(final_state, "error", "") or pagecontent.get("error", "")
+ pagecontent = []
log.critical(f"[paper2page_content_wf_api] pagecontent={pagecontent}")
result_path = _state_get(final_state, "result_path", "") or str(result_root)
@@ -256,6 +265,7 @@ async def run_paper2page_content_wf_api(req: Paper2PPTRequest, result_path: Path
"success": True,
"pagecontent": pagecontent,
"result_path": result_path,
+ "error": outline_generation_error,
}
return Paper2PPTResponse(**resp_data)
diff --git a/fastapi_app/workflow_adapters/wa_pdf2ppt.py b/fastapi_app/workflow_adapters/wa_pdf2ppt.py
index 771ce59c..1659d1d6 100644
--- a/fastapi_app/workflow_adapters/wa_pdf2ppt.py
+++ b/fastapi_app/workflow_adapters/wa_pdf2ppt.py
@@ -7,8 +7,7 @@
- 调用 dataflow_agent.workflow.run_workflow("pdf2ppt_with_sam", state)
- 输出:生成的 PPT 路径
-当前直接复用 Paper2FigureState / Paper2FigureRequest,
-逻辑与 tests/test_pdf2ppt.py 中保持一致。
+当前直接复用 Paper2FigureState / Paper2FigureRequest。
"""
from pathlib import Path
diff --git a/frontend-workflow/.env.example b/frontend-workflow/.env.example
index eec91647..0215334c 100644
--- a/frontend-workflow/.env.example
+++ b/frontend-workflow/.env.example
@@ -1,6 +1,9 @@
# ===========================================
-# Internal API Configuration
+# Frontend Example (Advanced / Fine-grained)
# ===========================================
+# 如果你只想要少量默认源和少量模型,请优先使用:
+# frontend-workflow/.env.simple.example
+# 当前这个 .env.example 是“细粒度 / 高级模式”示例。
# 本文件只负责前端可见配置。
# 不要把后端业务 key(例如 OCR / MinerU / Supabase service role / 各类第三方服务密钥)
# 再重复写进 deploy profile。
diff --git a/frontend-workflow/.env.simple.example b/frontend-workflow/.env.simple.example
new file mode 100644
index 00000000..e02a6876
--- /dev/null
+++ b/frontend-workflow/.env.simple.example
@@ -0,0 +1,40 @@
+# ===========================================
+# Paper2Any Frontend - Simple Mode Example
+# ===========================================
+# 目标:
+# - 前端只展示少量默认源和少量默认模型
+# - 用户不需要理解几十个模型名
+
+VITE_API_KEY=your-backend-api-key
+VITE_API_BASE_URL=
+
+# 前端默认展示的主源
+VITE_DEFAULT_LLM_API_URL=http://123.129.219.111:3000/v1
+VITE_LLM_API_URLS=http://123.129.219.111:3000/v1,https://api.ikuncode.cc/v1
+VITE_DEFAULT_LLM_MODEL=gpt-4o
+VITE_LLM_VERIFY_TIMEOUT_MS=30000
+
+# 各页面只保留少量推荐值
+VITE_PAPER2FIGURE_MODEL_MODEL_ARCH=gemini-3-pro-image-preview
+VITE_PAPER2FIGURE_MODEL_EXP_DATA=gemini-3-pro-image-preview
+VITE_PAPER2FIGURE_MODEL_TECH_ROUTE=gpt-4o
+
+VITE_PAPER2PPT_MODEL=gpt-4o,deepseek-v3.2
+VITE_PAPER2PPT_GEN_FIG_MODEL=gemini-3-pro-image-preview
+
+VITE_PDF2PPT_GEN_FIG_MODEL=gemini-3-pro-image-preview
+VITE_IMAGE2PPT_GEN_FIG_MODEL=gemini-3-pro-image-preview
+VITE_IMAGE2PPT_USE_AI_EDIT_DEFAULT=false
+
+VITE_PPT2POLISH_MODEL=gpt-4o,deepseek-v3.2
+VITE_PPT2POLISH_GEN_FIG_MODEL=gemini-3-pro-image-preview
+
+VITE_PAPER2DRAWIO_MODEL=gpt-4o
+VITE_PAPER2DRAWIO_IMAGE_MODEL=gemini-3-pro-image-preview
+VITE_IMAGE2DRAWIO_GEN_FIG_MODEL=gemini-3-pro-image-preview
+VITE_IMAGE2DRAWIO_VLM_MODEL=qwen-vl-ocr-2025-11-20
+
+VITE_PAPER2REBUTTAL_MODEL=gpt-4o
+
+VITE_SUPABASE_URL=https://your-project.supabase.co
+VITE_SUPABASE_ANON_KEY=your-anon-key
diff --git a/frontend-workflow/Dockerfile b/frontend-workflow/Dockerfile
index 49acbd98..e1f2236c 100644
--- a/frontend-workflow/Dockerfile
+++ b/frontend-workflow/Dockerfile
@@ -1,18 +1,40 @@
-FROM node:20-alpine AS build
+ARG NODE_BASE_IMAGE=node:20-alpine
+FROM ${NODE_BASE_IMAGE} AS build
+
+ARG VITE_API_KEY=
+ARG VITE_API_BASE_URL=
+ARG VITE_DEFAULT_LLM_API_URL=
+ARG VITE_LLM_API_URLS=
+ARG VITE_SUPABASE_URL=
+ARG VITE_SUPABASE_ANON_KEY=
WORKDIR /app
COPY frontend-workflow/package.json frontend-workflow/package-lock.json ./
RUN npm ci
-# 复制前端源码(包含 .env 文件,Vite build 时会自动读取)
+# 复制前端源码;若存在 frontend-workflow/.env,Vite build 也会读取。
COPY frontend-workflow/ ./
+RUN cp -f .env.example .env.production || true && \
+ rm -f .env.production.local && \
+ touch .env.production.local && \
+ if [ -n "$VITE_API_KEY" ]; then echo "VITE_API_KEY=$VITE_API_KEY" >> .env.production.local; fi && \
+ if [ -n "$VITE_API_BASE_URL" ]; then echo "VITE_API_BASE_URL=$VITE_API_BASE_URL" >> .env.production.local; fi && \
+ if [ -n "$VITE_DEFAULT_LLM_API_URL" ]; then echo "VITE_DEFAULT_LLM_API_URL=$VITE_DEFAULT_LLM_API_URL" >> .env.production.local; fi && \
+ if [ -n "$VITE_LLM_API_URLS" ]; then echo "VITE_LLM_API_URLS=$VITE_LLM_API_URLS" >> .env.production.local; fi && \
+ if [ -n "$VITE_SUPABASE_URL" ]; then echo "VITE_SUPABASE_URL=$VITE_SUPABASE_URL" >> .env.production.local; fi && \
+ if [ -n "$VITE_SUPABASE_ANON_KEY" ]; then echo "VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY" >> .env.production.local; fi
+
RUN npm run build
-FROM nginx:alpine
+ARG NGINX_BASE_IMAGE=nginx:alpine
+FROM ${NGINX_BASE_IMAGE}
-COPY frontend-workflow/nginx.conf /etc/nginx/conf.d/default.conf
+COPY frontend-workflow/nginx.conf.template /etc/nginx/templates/default.conf.template
+COPY frontend-workflow/docker-nginx-start.sh /docker-nginx-start.sh
+RUN chmod +x /docker-nginx-start.sh
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+CMD ["/docker-nginx-start.sh"]
diff --git a/frontend-workflow/docker-nginx-start.sh b/frontend-workflow/docker-nginx-start.sh
new file mode 100644
index 00000000..9b845d09
--- /dev/null
+++ b/frontend-workflow/docker-nginx-start.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+
+LISTEN_PORT="${NGINX_LISTEN_PORT:-80}"
+BACKEND_URL="${BACKEND_UPSTREAM_URL:-http://paper2any-backend:8000}"
+
+sed \
+ -e "s|__NGINX_LISTEN_PORT__|${LISTEN_PORT}|g" \
+ -e "s|__BACKEND_UPSTREAM_URL__|${BACKEND_URL}|g" \
+ /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
+
+exec nginx -g 'daemon off;'
diff --git a/frontend-workflow/index.html b/frontend-workflow/index.html
index 3da8207b..f3bf7a22 100644
--- a/frontend-workflow/index.html
+++ b/frontend-workflow/index.html
@@ -2,7 +2,9 @@
-
+
+
+
Paper2Any
diff --git a/frontend-workflow/nginx.conf b/frontend-workflow/nginx.conf.template
similarity index 87%
rename from frontend-workflow/nginx.conf
rename to frontend-workflow/nginx.conf.template
index 86646a2e..c95d7e3f 100644
--- a/frontend-workflow/nginx.conf
+++ b/frontend-workflow/nginx.conf.template
@@ -1,10 +1,10 @@
server {
- listen 80;
+ listen __NGINX_LISTEN_PORT__;
server_name _;
client_max_body_size 200m;
location /api/ {
- proxy_pass http://paper2any-backend:8000;
+ proxy_pass __BACKEND_UPSTREAM_URL__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -16,7 +16,7 @@ server {
}
location /paper2video/ {
- proxy_pass http://paper2any-backend:8000;
+ proxy_pass __BACKEND_UPSTREAM_URL__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -25,7 +25,7 @@ server {
}
location /outputs/ {
- proxy_pass http://paper2any-backend:8000;
+ proxy_pass __BACKEND_UPSTREAM_URL__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
diff --git a/frontend-workflow/package-lock.json b/frontend-workflow/package-lock.json
index 2589b264..acf7682d 100644
--- a/frontend-workflow/package-lock.json
+++ b/frontend-workflow/package-lock.json
@@ -15,6 +15,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.294.0",
"mermaid": "^10.9.5",
+ "pptxgenjs": "^4.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-drawio": "^1.0.7",
@@ -30,6 +31,7 @@
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
+ "tsx": "^4.20.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
@@ -591,6 +593,22 @@
"node": ">=12"
}
},
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -608,6 +626,22 @@
"node": ">=12"
}
},
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -625,6 +659,22 @@
"node": ">=12"
}
},
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -2262,6 +2312,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+ },
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -3002,6 +3057,18 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-tsconfig": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+ "dev": true,
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
@@ -3089,6 +3156,11 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/https": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
+ "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="
+ },
"node_modules/i18next": {
"version": "25.7.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
@@ -3150,6 +3222,30 @@
"node": ">=0.10.0"
}
},
+ "node_modules/image-size": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
+ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
+ "dependencies": {
+ "queue": "6.0.2"
+ },
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -3273,6 +3369,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
@@ -3307,6 +3408,17 @@
"node": ">=6"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
"node_modules/katex": {
"version": "0.16.28",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz",
@@ -3352,6 +3464,14 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
@@ -5905,6 +6025,11 @@
"node": ">= 6"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -6110,6 +6235,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pptxgenjs": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
+ "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
+ "dependencies": {
+ "@types/node": "^22.8.1",
+ "https": "^1.0.0",
+ "image-size": "^1.2.1",
+ "jszip": "^3.10.1"
+ }
+ },
+ "node_modules/pptxgenjs/node_modules/@types/node": {
+ "version": "22.19.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/pptxgenjs/node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -6120,6 +6274,14 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "dependencies": {
+ "inherits": "~2.0.3"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
@@ -6264,6 +6426,20 @@
"pify": "^2.3.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
@@ -6843,6 +7019,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/reusify": {
"version": "1.1.0",
"dev": true,
@@ -6938,6 +7123,11 @@
"node": ">=6"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -6959,6 +7149,11 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
@@ -6977,6 +7172,14 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -7195,6 +7398,434 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
@@ -7368,7 +7999,6 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
- "dev": true,
"license": "MIT"
},
"node_modules/uuid": {
diff --git a/frontend-workflow/package.json b/frontend-workflow/package.json
index a1b65fce..cbc19892 100644
--- a/frontend-workflow/package.json
+++ b/frontend-workflow/package.json
@@ -5,7 +5,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "export:structured-ppt": "tsx scripts/run_paper2ppt_structured_export_cli.ts"
},
"dependencies": {
"@supabase/supabase-js": "^2.89.0",
@@ -15,6 +16,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.294.0",
"mermaid": "^10.9.5",
+ "pptxgenjs": "^4.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-drawio": "^1.0.7",
@@ -30,6 +32,7 @@
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
+ "tsx": "^4.20.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
diff --git a/frontend-workflow/public/paper2any-favicon.png b/frontend-workflow/public/paper2any-favicon.png
new file mode 100644
index 00000000..a7876acf
Binary files /dev/null and b/frontend-workflow/public/paper2any-favicon.png differ
diff --git a/frontend-workflow/scripts/run_paper2ppt_structured_export_cli.ts b/frontend-workflow/scripts/run_paper2ppt_structured_export_cli.ts
new file mode 100644
index 00000000..8c5e294d
--- /dev/null
+++ b/frontend-workflow/scripts/run_paper2ppt_structured_export_cli.ts
@@ -0,0 +1,206 @@
+#!/usr/bin/env node
+
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+import { exportStructuredSlidesToPptx } from '../src/components/paper2ppt/exportStructuredSlides.ts';
+import type { FrontendDeckTheme, FrontendSlide } from '../src/components/paper2ppt/types.ts';
+
+interface CliArgs {
+ slidesJson: string;
+ themeJson?: string;
+ output: string;
+ assetBaseUrl?: string;
+}
+
+const normalizeLayoutData = (layoutData: any) => {
+ if (!layoutData || typeof layoutData !== 'object') {
+ return { type: 'bullets', titleKey: 'title' };
+ }
+ return {
+ ...layoutData,
+ eyebrowKey: layoutData.eyebrow_key || layoutData.eyebrowKey,
+ titleKey: layoutData.title_key || layoutData.titleKey,
+ footerKey: layoutData.footer_key || layoutData.footerKey,
+ summaryKey: layoutData.summary_key || layoutData.summaryKey,
+ subtitleKey: layoutData.subtitle_key || layoutData.subtitleKey,
+ presenterKey: layoutData.presenter_key || layoutData.presenterKey,
+ quoteKey: layoutData.quote_key || layoutData.quoteKey,
+ bulletsKey: layoutData.bullets_key || layoutData.bulletsKey,
+ takeawayKey: layoutData.takeaway_key || layoutData.takeawayKey,
+ leftHeadingKey: layoutData.left_heading_key || layoutData.leftHeadingKey,
+ leftBodyKey: layoutData.left_body_key || layoutData.leftBodyKey,
+ leftPointsKey: layoutData.left_points_key || layoutData.leftPointsKey,
+ rightHeadingKey: layoutData.right_heading_key || layoutData.rightHeadingKey,
+ rightBodyKey: layoutData.right_body_key || layoutData.rightBodyKey,
+ rightPointsKey: layoutData.right_points_key || layoutData.rightPointsKey,
+ visualKey: layoutData.visual_key || layoutData.visualKey,
+ visualCaptionKey: layoutData.visual_caption_key || layoutData.visualCaptionKey,
+ leftTitleKey: layoutData.left_title_key || layoutData.leftTitleKey,
+ rightTitleKey: layoutData.right_title_key || layoutData.rightTitleKey,
+ cards: Array.isArray(layoutData.cards)
+ ? layoutData.cards.map((card: any) => ({
+ titleKey: card.title_key || card.titleKey,
+ bodyKey: card.body_key || card.bodyKey,
+ }))
+ : [],
+ timeline: Array.isArray(layoutData.timeline)
+ ? layoutData.timeline.map((item: any) => ({
+ labelKey: item.label_key || item.labelKey,
+ bodyKey: item.body_key || item.bodyKey,
+ }))
+ : [],
+ };
+};
+
+const normalizeThemeLock = (themeLock: any) => ({
+ mustKeep: Array.isArray(themeLock?.must_keep || themeLock?.mustKeep)
+ ? (themeLock.must_keep || themeLock.mustKeep).map((item: unknown) => String(item || '')).filter(Boolean)
+ : [],
+ preferredLayoutPatterns: Array.isArray(themeLock?.preferred_layout_patterns || themeLock?.preferredLayoutPatterns)
+ ? (themeLock.preferred_layout_patterns || themeLock.preferredLayoutPatterns)
+ .map((item: unknown) => String(item || ''))
+ .filter(Boolean)
+ : [],
+ componentSignature: String(themeLock?.component_signature || themeLock?.componentSignature || ''),
+ avoid: Array.isArray(themeLock?.avoid)
+ ? themeLock.avoid.map((item: unknown) => String(item || '')).filter(Boolean)
+ : [],
+});
+
+const normalizeTypography = (typography: any) => ({
+ titleFontStack: String(typography?.title_font_stack || typography?.titleFontStack || ''),
+ bodyFontStack: String(typography?.body_font_stack || typography?.bodyFontStack || ''),
+ eyebrowSize: Number(typography?.eyebrow_size || typography?.eyebrowSize || 18),
+ titleSize: Number(typography?.title_size || typography?.titleSize || 56),
+ summarySize: Number(typography?.summary_size || typography?.summarySize || 26),
+ bodySize: Number(typography?.body_size || typography?.bodySize || 24),
+});
+
+const normalizeFrontendSlides = (slides: any[]): FrontendSlide[] =>
+ slides.map((slide: any, index: number) => ({
+ slideId: String(slide.slide_id || slide.slideId || index + 1),
+ pageNum: Number(slide.page_num || slide.pageNum || index + 1),
+ title: slide.title || `Slide ${index + 1}`,
+ layoutType: slide.layout_type || slide.layoutType || 'bullets',
+ layoutData: normalizeLayoutData(slide.layout_data || slide.layoutData || {}),
+ editableFields: Array.isArray(slide.editable_fields || slide.editableFields)
+ ? (slide.editable_fields || slide.editableFields).map((field: any) => ({
+ key: String(field.key || ''),
+ label: String(field.label || field.key || ''),
+ type: field.type === 'list' || field.type === 'textarea' ? field.type : 'text',
+ value: String(field.value || ''),
+ items: Array.isArray(field.items) ? field.items.map((item: any) => String(item || '')) : [],
+ }))
+ : [],
+ visualAssets: Array.isArray(slide.visual_assets || slide.visualAssets)
+ ? (slide.visual_assets || slide.visualAssets).map((asset: any, assetIndex: number) => ({
+ key: String(asset.key || `main_visual_${assetIndex + 1}`),
+ label: String(asset.label || asset.key || `Image ${assetIndex + 1}`),
+ src: String(asset.src || ''),
+ previewSrc: String(asset.preview_src || asset.previewSrc || asset.src || ''),
+ originalSrc: String(asset.original_src || asset.originalSrc || asset.storage_path || asset.storagePath || asset.src || ''),
+ alt: String(asset.alt || asset.label || asset.key || ''),
+ sourceType: asset.source_type === 'paper_asset' || asset.sourceType === 'paper_asset'
+ ? 'paper_asset'
+ : asset.source_type === 'upload' || asset.sourceType === 'upload'
+ ? 'upload'
+ : 'generated',
+ storagePath: asset.storage_path || asset.storagePath || undefined,
+ previewStoragePath: asset.preview_storage_path || asset.previewStoragePath || undefined,
+ prompt: asset.prompt || undefined,
+ style: asset.style || undefined,
+ }))
+ : [],
+ generationNote: slide.generation_note || slide.generationNote || '',
+ status: slide.status === 'processing' || slide.status === 'pending' ? slide.status : 'done',
+ review: {
+ status: 'idle',
+ summary: '',
+ issues: [],
+ },
+ }));
+
+const normalizeFrontendDeckTheme = (theme: any): FrontendDeckTheme | undefined => {
+ if (!theme || typeof theme !== 'object') return undefined;
+ const themeLock = theme.theme_lock || theme.themeLock || {};
+ return {
+ themeName: String(theme.theme_name || theme.themeName || 'locked_deck_theme'),
+ visualMood: String(theme.visual_mood || theme.visualMood || ''),
+ styleFamily: String(theme.style_family || theme.styleFamily || 'modern') as FrontendDeckTheme['styleFamily'],
+ footerText: String(theme.footer_text || theme.footerText || ''),
+ sectionLabelTemplate: String(theme.section_label_template || theme.sectionLabelTemplate || ''),
+ palette: {
+ bg: String(theme.palette?.bg || '#0b1020'),
+ panel: String(theme.palette?.panel || 'rgba(15, 23, 42, 0.92)'),
+ primary: String(theme.palette?.primary || '#7dd3fc'),
+ secondary: String(theme.palette?.secondary || '#38bdf8'),
+ accent: String(theme.palette?.accent || '#f59e0b'),
+ text: String(theme.palette?.text || '#e2e8f0'),
+ muted: String(theme.palette?.muted || '#94a3b8'),
+ },
+ typography: normalizeTypography(theme.typography || {}),
+ themeLock: normalizeThemeLock(themeLock),
+ };
+};
+
+const usage = () => {
+ console.log(`Usage:
+ npm run export:structured-ppt -- --slides-json /path/to/frontend_slides.json --theme-json /path/to/frontend_theme.json --output /path/to/out.pptx [--asset-base-url http://127.0.0.1:8000]
+`);
+};
+
+const parseArgs = (): CliArgs => {
+ const args = process.argv.slice(2);
+ const read = (name: string) => {
+ const index = args.indexOf(name);
+ if (index === -1 || index + 1 >= args.length) return '';
+ return args[index + 1];
+ };
+
+ const slidesJson = read('--slides-json');
+ const output = read('--output');
+ const themeJson = read('--theme-json') || undefined;
+ const assetBaseUrl = read('--asset-base-url') || undefined;
+
+ if (!slidesJson || !output) {
+ usage();
+ throw new Error('Missing required --slides-json or --output');
+ }
+
+ return { slidesJson, themeJson, output, assetBaseUrl };
+};
+
+const main = async () => {
+ const args = parseArgs();
+ const slides = normalizeFrontendSlides(JSON.parse(await readFile(args.slidesJson, 'utf-8')));
+ const theme = args.themeJson
+ ? normalizeFrontendDeckTheme(JSON.parse(await readFile(args.themeJson, 'utf-8')))
+ : undefined;
+
+ const result = await exportStructuredSlidesToPptx({
+ slides,
+ deckTheme: theme,
+ fileName: path.basename(args.output),
+ assetBaseUrl: args.assetBaseUrl,
+ outputType: 'nodebuffer',
+ });
+
+ if (!('buffer' in result)) {
+ throw new Error('Expected nodebuffer export result');
+ }
+
+ await mkdir(path.dirname(args.output), { recursive: true });
+ await writeFile(args.output, Buffer.from(result.buffer));
+ console.log(JSON.stringify({
+ success: true,
+ output: path.resolve(args.output),
+ slide_count: slides.length,
+ asset_base_url: args.assetBaseUrl || '',
+ }));
+};
+
+main().catch((error) => {
+ console.error(String(error?.stack || error?.message || error));
+ process.exit(1);
+});
diff --git a/frontend-workflow/src/components/Image2DrawioPage.tsx b/frontend-workflow/src/components/Image2DrawioPage.tsx
index 02ff7c38..be021343 100644
--- a/frontend-workflow/src/components/Image2DrawioPage.tsx
+++ b/frontend-workflow/src/components/Image2DrawioPage.tsx
@@ -204,9 +204,9 @@ const Image2DrawioPage = () => {
if (userApiConfigRequired) {
formData.append('chat_api_url', apiUrl.trim());
formData.append('api_key', apiKey.trim());
+ formData.append('gen_fig_model', genFigModel);
+ formData.append('vlm_model', vlmModel);
}
- formData.append('gen_fig_model', genFigModel);
- formData.append('vlm_model', vlmModel);
formData.append('email', user?.id || user?.email || '');
setStatusMessage(t('status.processing'));
@@ -611,12 +611,16 @@ const Image2DrawioPage = () => {
+ {!userApiConfigRequired && (
+ Free 模式下由后端统一选择 DrawIO 转换使用的视觉模型。
+ )}
diff --git a/frontend-workflow/src/components/Image2PptPage.tsx b/frontend-workflow/src/components/Image2PptPage.tsx
index 1230b844..47762405 100644
--- a/frontend-workflow/src/components/Image2PptPage.tsx
+++ b/frontend-workflow/src/components/Image2PptPage.tsx
@@ -221,7 +221,9 @@ const Image2PptPage = () => {
try {
setIsValidating(true);
setError(null);
- await verifyLlmConnection(llmApiUrl, apiKey, import.meta.env.VITE_DEFAULT_LLM_MODEL || 'deepseek-v3.2');
+ if (userApiConfigRequired) {
+ await verifyLlmConnection(llmApiUrl, apiKey, import.meta.env.VITE_DEFAULT_LLM_MODEL || 'deepseek-v3.2');
+ }
setIsValidating(false);
} catch (err) {
setIsValidating(false);
@@ -268,8 +270,8 @@ const Image2PptPage = () => {
if (userApiConfigRequired) {
formData.append('chat_api_url', llmApiUrl.trim());
formData.append('api_key', apiKey.trim());
+ formData.append('gen_fig_model', genFigModel);
}
- formData.append('gen_fig_model', genFigModel);
} else {
formData.append('use_ai_edit', 'false');
}
@@ -553,7 +555,8 @@ const Image2PptPage = () => {
+ {!userApiConfigRequired ? (
+ Free 模式下由后端统一选择思维导图模型。
+ ) : null}
diff --git a/frontend-workflow/src/components/Pdf2PptPage.tsx b/frontend-workflow/src/components/Pdf2PptPage.tsx
index fe819f0a..abec48b5 100644
--- a/frontend-workflow/src/components/Pdf2PptPage.tsx
+++ b/frontend-workflow/src/components/Pdf2PptPage.tsx
@@ -222,7 +222,9 @@ const Pdf2PptPage = () => {
try {
setIsValidating(true);
setError(null);
- await verifyLlmConnection(llmApiUrl, apiKey, import.meta.env.VITE_DEFAULT_LLM_MODEL || 'deepseek-v3.2');
+ if (userApiConfigRequired) {
+ await verifyLlmConnection(llmApiUrl, apiKey, import.meta.env.VITE_DEFAULT_LLM_MODEL || 'deepseek-v3.2');
+ }
setIsValidating(false);
} catch (err) {
setIsValidating(false);
@@ -266,11 +268,11 @@ const Pdf2PptPage = () => {
if (useAiEdit) {
formData.append('use_ai_edit', 'true');
- if (userApiConfigRequired) {
- formData.append('chat_api_url', llmApiUrl.trim());
- formData.append('api_key', apiKey.trim());
- }
- formData.append('gen_fig_model', genFigModel);
+ if (userApiConfigRequired) {
+ formData.append('chat_api_url', llmApiUrl.trim());
+ formData.append('api_key', apiKey.trim());
+ formData.append('gen_fig_model', genFigModel);
+ }
} else {
formData.append('use_ai_edit', 'false');
}
@@ -579,7 +581,8 @@ const Pdf2PptPage = () => {
+ {!userApiConfigRequired && (
+ Free 模式下由后端统一选择文本模型。
+ )}
@@ -1706,7 +1696,7 @@ const Ppt2PolishPage = () => {
-
-
-
-
+ {userApiConfigRequired ? (
+ <>
+
+
+
+
-
-
- setApiKey(e.target.value)}
- placeholder="sk-..."
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-emerald-500 font-mono"
- />
-
+
+
+ setApiKey(e.target.value)}
+ placeholder="sk-..."
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-emerald-500 font-mono"
+ />
+
-
-
- setModel(e.target.value)}
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-emerald-500"
- />
-
+
+
+ setModel(e.target.value)}
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-emerald-500"
+ />
+
+ >
+ ) : (
+
+ Free 模式下由后端统一选择深度研究模型、搜索凭证与接口配置。
+
+ )}
-
-
- setSearchApiKey(e.target.value)}
- placeholder="search_api_key"
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-emerald-500 font-mono"
- />
-
- {searchProvider === 'google_cse' && (
+ {userApiConfigRequired && (
+
+
+ setSearchApiKey(e.target.value)}
+ placeholder="search_api_key"
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-emerald-500 font-mono"
+ />
+
+ )}
+ {userApiConfigRequired && searchProvider === 'google_cse' && (
{
const { user } = useAuthStore();
+ const { userApiConfigRequired } = useRuntimeBilling();
const [mindmapGenerating, setMindmapGenerating] = useState(false);
const [generatedMermaidCode, setGeneratedMermaidCode] = useState('');
const [showPreview, setShowPreview] = useState(false);
@@ -49,7 +51,7 @@ export const MindMapTool = ({ files = [], selectedIds, onGenerateSuccess }: Mind
return;
}
- if (!mindmapParams.api_key) {
+ if (userApiConfigRequired && !mindmapParams.api_key) {
alert('请输入 API Key');
return;
}
@@ -75,12 +77,16 @@ export const MindMapTool = ({ files = [], selectedIds, onGenerateSuccess }: Mind
file_paths: filePaths,
user_id: user.id,
email: user.email,
- api_url: mindmapParams.api_url,
- api_key: mindmapParams.api_key,
- model: mindmapParams.model,
mindmap_style: mindmapParams.mindmap_style,
max_depth: mindmapParams.max_depth,
- language: mindmapParams.language
+ language: mindmapParams.language,
+ ...(userApiConfigRequired
+ ? {
+ api_url: mindmapParams.api_url,
+ api_key: mindmapParams.api_key,
+ model: mindmapParams.model,
+ }
+ : {})
})
});
@@ -148,29 +154,37 @@ export const MindMapTool = ({ files = [], selectedIds, onGenerateSuccess }: Mind
{/* Configuration */}
-
-
- setMindmapParams({...mindmapParams, api_key: e.target.value})}
- placeholder="sk-..."
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-cyan-500 font-mono"
- />
-
+ {userApiConfigRequired ? (
+ <>
+
+
+ setMindmapParams({...mindmapParams, api_key: e.target.value})}
+ placeholder="sk-..."
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-cyan-500 font-mono"
+ />
+
-
-
-
-
+
+
+
+
+ >
+ ) : (
+
+ Free 模式下由后端统一选择思维导图模型与接口配置。
+
+ )}
@@ -178,7 +192,8 @@ export const MindMapTool = ({ files = [], selectedIds, onGenerateSuccess }: Mind
diff --git a/frontend-workflow/src/components/knowledge-base/tools/PodcastTool.tsx b/frontend-workflow/src/components/knowledge-base/tools/PodcastTool.tsx
index dfdb9f18..718d153c 100644
--- a/frontend-workflow/src/components/knowledge-base/tools/PodcastTool.tsx
+++ b/frontend-workflow/src/components/knowledge-base/tools/PodcastTool.tsx
@@ -5,6 +5,7 @@ import { KnowledgeFile } from '../types';
import { getApiSettings } from '../../../services/apiSettingsService';
import { backendFetch } from '../../../services/backendClient';
import { useAuthStore } from '../../../stores/authStore';
+import { useRuntimeBilling } from '../../../hooks/useRuntimeBilling';
const COSYVOICE_TTS_MODELS = ['cosyvoice-v3-flash', 'cosyvoice-v3-plus', 'cosyvoice-v2'];
const COSYVOICE_VOICE_LABEL = '默认';
@@ -75,6 +76,7 @@ interface PodcastToolProps {
export const PodcastTool = ({ files = [], selectedIds, onGenerateSuccess }: PodcastToolProps) => {
const { user } = useAuthStore();
+ const { userApiConfigRequired } = useRuntimeBilling();
const [podcastGenerating, setPodcastGenerating] = useState(false);
const [podcastParams, setPodcastParams] = useState({
api_key: '',
@@ -126,7 +128,7 @@ export const PodcastTool = ({ files = [], selectedIds, onGenerateSuccess }: Podc
return;
}
- if (!podcastParams.api_key) {
+ if (userApiConfigRequired && !podcastParams.api_key) {
alert('请输入 API Key');
return;
}
@@ -151,15 +153,19 @@ export const PodcastTool = ({ files = [], selectedIds, onGenerateSuccess }: Podc
file_paths: filePaths,
user_id: user.id,
email: user.email,
- api_url: podcastParams.api_url,
- api_key: podcastParams.api_key,
- model: podcastParams.model,
- tts_model: podcastParams.tts_model,
voice_name: podcastParams.voice_name,
voice_name_b: podcastParams.voice_name_b,
podcast_mode: podcastParams.podcast_mode,
podcast_length: podcastParams.podcast_length,
- language: podcastParams.language
+ language: podcastParams.language,
+ ...(userApiConfigRequired
+ ? {
+ api_url: podcastParams.api_url,
+ api_key: podcastParams.api_key,
+ model: podcastParams.model,
+ tts_model: podcastParams.tts_model,
+ }
+ : {})
})
});
@@ -223,29 +229,37 @@ export const PodcastTool = ({ files = [], selectedIds, onGenerateSuccess }: Podc
{/* Configuration */}
-
-
- setPodcastParams({...podcastParams, api_key: e.target.value})}
- placeholder="sk-..."
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-green-500 font-mono"
- />
-
-
-
-
-
-
+ {userApiConfigRequired ? (
+ <>
+
+
+ setPodcastParams({...podcastParams, api_key: e.target.value})}
+ placeholder="sk-..."
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-green-500 font-mono"
+ />
+
+
+
+
+
+
+ >
+ ) : (
+
+ Free 模式下由后端统一选择播客脚本模型、TTS 模型和接口配置。
+
+ )}
@@ -253,7 +267,8 @@ export const PodcastTool = ({ files = [], selectedIds, onGenerateSuccess }: Podc
@@ -285,7 +301,8 @@ export const PodcastTool = ({ files = [], selectedIds, onGenerateSuccess }: Podc
voice_name_b: normalizeVoice(prev.voice_name_b, nextOptions)
}));
}}
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-green-500"
+ disabled={!userApiConfigRequired}
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-green-500 disabled:opacity-50"
>
{COSYVOICE_TTS_MODELS.map(m => (
{m}
diff --git a/frontend-workflow/src/components/knowledge-base/tools/PptTool.tsx b/frontend-workflow/src/components/knowledge-base/tools/PptTool.tsx
index 2a3d64af..3df4bd81 100644
--- a/frontend-workflow/src/components/knowledge-base/tools/PptTool.tsx
+++ b/frontend-workflow/src/components/knowledge-base/tools/PptTool.tsx
@@ -5,6 +5,7 @@ import { KnowledgeFile } from '../types';
import { getApiSettings } from '../../../services/apiSettingsService';
import { backendFetch } from '../../../services/backendClient';
import { useAuthStore } from '../../../stores/authStore';
+import { useRuntimeBilling } from '../../../hooks/useRuntimeBilling';
interface PptToolProps {
files: KnowledgeFile[];
@@ -14,6 +15,7 @@ interface PptToolProps {
export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps) => {
const { user } = useAuthStore();
+ const { userApiConfigRequired } = useRuntimeBilling();
const [pptGenerating, setPptGenerating] = useState(false);
const [pptParams, setPptParams] = useState({
api_key: '',
@@ -62,7 +64,7 @@ export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps)
return;
}
- if (!pptParams.api_key) {
+ if (userApiConfigRequired && !pptParams.api_key) {
alert('请输入 API Key');
return;
}
@@ -102,13 +104,17 @@ export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps)
need_embedding: needEmbedding,
user_id: user.id,
email: user.email,
- api_url: pptParams.api_url,
- api_key: pptParams.api_key,
style: getStyleDescription(pptParams.style_preset),
language: pptParams.language,
page_count: pptParams.page_count,
- model: pptParams.model,
- gen_fig_model: pptParams.gen_fig_model
+ ...(userApiConfigRequired
+ ? {
+ api_url: pptParams.api_url,
+ api_key: pptParams.api_key,
+ model: pptParams.model,
+ gen_fig_model: pptParams.gen_fig_model,
+ }
+ : {})
})
});
@@ -166,16 +172,43 @@ export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps)
{/* Configuration */}
-
-
- setPptParams({...pptParams, api_key: e.target.value})}
- placeholder="sk-..."
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-purple-500 font-mono"
- />
-
+ {userApiConfigRequired ? (
+ <>
+
+
+ setPptParams({...pptParams, api_key: e.target.value})}
+ placeholder="sk-..."
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-purple-500 font-mono"
+ />
+
+
+
+
+
+ >
+ ) : (
+
+ Free 模式下由后端统一选择 PPT 生成使用的文本与生图模型。
+
+ )}
@@ -198,27 +231,6 @@ export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps)
需要向量入库并基于检索生成大纲
-
-
-
-
-
@@ -226,7 +238,8 @@ export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps)
@@ -246,7 +260,7 @@ export const PptTool = ({ files, selectedIds, onGenerateSuccess }: PptToolProps)
-
-
-
-
+ {userApiConfigRequired ? (
+ <>
+
+
+
+
-
-
- setApiKey(e.target.value)}
- placeholder="sk-..."
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-fuchsia-500 font-mono"
- />
-
+
+
+ setApiKey(e.target.value)}
+ placeholder="sk-..."
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-fuchsia-500 font-mono"
+ />
+
-
-
- setModel(e.target.value)}
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-fuchsia-500"
- />
-
+
+
+ setModel(e.target.value)}
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-fuchsia-500"
+ />
+
+ >
+ ) : (
+
+ Free 模式下由后端统一选择报告生成模型与接口配置。
+
+ )}
@@ -236,29 +243,37 @@ export const SearchTool = ({ files = [], selectedIds = new Set(), knowledgeBases
-
-
-
-
-
-
-
- setApiKey(e.target.value)}
- placeholder="sk-..."
- className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-blue-500 font-mono"
- />
-
+ {userApiConfigRequired ? (
+ <>
+
+
+
+
+
+
+
+ setApiKey(e.target.value)}
+ placeholder="sk-..."
+ className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-200 outline-none focus:border-blue-500 font-mono"
+ />
+
+ >
+ ) : (
+
+ Free 模式下由后端统一选择向量检索使用的嵌入模型与接口配置。
+
+ )}
diff --git a/frontend-workflow/src/components/paper2drawio/index.tsx b/frontend-workflow/src/components/paper2drawio/index.tsx
index 0d20914f..ef3d75f0 100644
--- a/frontend-workflow/src/components/paper2drawio/index.tsx
+++ b/frontend-workflow/src/components/paper2drawio/index.tsx
@@ -18,6 +18,7 @@ import Banner from './Banner';
import QRCodeTooltip from '../QRCodeTooltip';
import ManagedApiNotice from '../ManagedApiNotice';
import { useRuntimeBilling } from '../../hooks/useRuntimeBilling';
+import { appendManagedApiConfig, appendManagedModel } from '../../utils/runtimeBillingForm';
const DRAWIO_ORIGINS = new Set(['https://embed.diagrams.net', 'https://app.diagrams.net']);
const STORAGE_KEY = 'paper2drawio_settings';
@@ -297,17 +298,19 @@ export default function Paper2DrawioPage({
const handleGenerate = useCallback(async () => {
if (!textContent && !file) return;
- // Step 0: Verify LLM Connection first
- try {
- setIsValidating(true);
- setError(null);
- await verifyLlmConnection(apiUrl, apiKey, model);
- setIsValidating(false);
- } catch (err) {
- setIsValidating(false);
- const errorMsg = err instanceof Error ? err.message : '验证 LLM 连接失败';
- setError(errorMsg);
- return;
+ if (userApiConfigRequired) {
+ // Step 0: Verify LLM Connection first
+ try {
+ setIsValidating(true);
+ setError(null);
+ await verifyLlmConnection(apiUrl, apiKey, model);
+ setIsValidating(false);
+ } catch (err) {
+ setIsValidating(false);
+ const errorMsg = err instanceof Error ? err.message : '验证 LLM 连接失败';
+ setError(errorMsg);
+ return;
+ }
}
setIsLoading(true);
@@ -315,11 +318,8 @@ export default function Paper2DrawioPage({
try {
if (generationMode === 'paper2drawio') {
const formData = new FormData();
- formData.append('img_gen_model_name', p2dImageModel);
- if (userApiConfigRequired) {
- formData.append('chat_api_url', apiUrl);
- formData.append('api_key', apiKey);
- }
+ appendManagedModel(formData, userApiConfigRequired, 'img_gen_model_name', p2dImageModel);
+ appendManagedApiConfig(formData, userApiConfigRequired, apiUrl, apiKey);
formData.append('input_type', uploadMode);
formData.append('graph_type', 'model_arch');
formData.append('style', p2dStyle);
@@ -368,12 +368,9 @@ export default function Paper2DrawioPage({
}
const formData = new FormData();
- if (userApiConfigRequired) {
- formData.append('chat_api_url', apiUrl);
- formData.append('api_key', apiKey);
- }
+ appendManagedApiConfig(formData, userApiConfigRequired, apiUrl, apiKey);
const modelToSend = enableModelRace ? withModelOptions(PAPER2DRAWIO_MODELS, model).join(',') : model;
- formData.append('model', modelToSend);
+ appendManagedModel(formData, userApiConfigRequired, 'model', modelToSend);
formData.append('input_type', uploadMode === 'file' ? 'PDF' : 'TEXT');
formData.append('diagram_type', diagramType);
formData.append('diagram_style', diagramStyle);
@@ -927,7 +924,8 @@ export default function Paper2DrawioPage({
- {modelOptions.length > 1 && (
+ {!userApiConfigRequired && (
+
Free 模式下由后端统一选择 DrawIO 生成模型。
+ )}
+ {userApiConfigRequired && modelOptions.length > 1 && (
{graphType === 'model_arch' ? (
diff --git a/frontend-workflow/src/components/paper2graph/index.tsx b/frontend-workflow/src/components/paper2graph/index.tsx
index 62021729..bf3f57f2 100644
--- a/frontend-workflow/src/components/paper2graph/index.tsx
+++ b/frontend-workflow/src/components/paper2graph/index.tsx
@@ -405,9 +405,9 @@ const Paper2FigurePage: React.FC = ({
if (userApiConfigRequired) {
formData.append('chat_api_url', llmApiUrl.trim());
formData.append('api_key', apiKey.trim());
+ formData.append('gen_fig_model', DEFAULT_IMAGE2DRAWIO_GEN_FIG_MODEL);
+ formData.append('vlm_model', DEFAULT_IMAGE2DRAWIO_VLM_MODEL);
}
- formData.append('gen_fig_model', DEFAULT_IMAGE2DRAWIO_GEN_FIG_MODEL);
- formData.append('vlm_model', DEFAULT_IMAGE2DRAWIO_VLM_MODEL);
formData.append('email', user?.id || user?.email || '');
const res = await backendFetch('/api/v1/image2drawio/generate', {
@@ -616,7 +616,9 @@ const Paper2FigurePage: React.FC = ({
}
const formData = new FormData();
- formData.append('img_gen_model_name', model);
+ if (userApiConfigRequired) {
+ formData.append('img_gen_model_name', model);
+ }
if (userApiConfigRequired) {
formData.append('chat_api_url', llmApiUrl.trim());
formData.append('api_key', apiKey.trim());
@@ -648,7 +650,9 @@ const Paper2FigurePage: React.FC = ({
try {
setIsValidating(true);
setError(null);
- await verifyLlmConnection(llmApiUrl, apiKey, import.meta.env.VITE_DEFAULT_LLM_MODEL || "deepseek-v3.2");
+ if (userApiConfigRequired) {
+ await verifyLlmConnection(llmApiUrl, apiKey, import.meta.env.VITE_DEFAULT_LLM_MODEL || "deepseek-v3.2");
+ }
setIsValidating(false);
setIsLoading(true);
@@ -774,7 +778,9 @@ const Paper2FigurePage: React.FC = ({
// 当前 UploadMode 仅支持 'file' | 'text',无需图片输入
const formData = new FormData();
- formData.append('img_gen_model_name', model);
+ if (userApiConfigRequired) {
+ formData.append('img_gen_model_name', model);
+ }
if (userApiConfigRequired) {
formData.append('chat_api_url', llmApiUrl.trim());
formData.append('api_key', apiKey.trim());
diff --git a/frontend-workflow/src/components/paper2poster/UploadStep.tsx b/frontend-workflow/src/components/paper2poster/UploadStep.tsx
index d4a9b96a..feed3312 100644
--- a/frontend-workflow/src/components/paper2poster/UploadStep.tsx
+++ b/frontend-workflow/src/components/paper2poster/UploadStep.tsx
@@ -162,7 +162,8 @@ const UploadStep: React.FC = ({