feat(editor): add slide edit agent primitives

This commit is contained in:
sligter
2026-07-05 19:36:00 +08:00
parent 7fbd18090a
commit 2152d49442
2 changed files with 370 additions and 0 deletions
@@ -0,0 +1,289 @@
from __future__ import annotations
import hashlib
import json
import re
import time
import uuid
from dataclasses import dataclass, field
from html.parser import HTMLParser
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional
from bs4 import BeautifulSoup
from pydantic import BaseModel
AgentEditMode = Literal["slide", "element"]
class SlideEditAgentRequest(BaseModel):
projectId: str
slideIndex: int
userRequest: str
chatHistory: Optional[List[Dict[str, Any]]] = None
mode: AgentEditMode = "slide"
slideTitle: Optional[str] = None
slideContent: Optional[str] = None
slideOutline: Optional[Dict[str, Any]] = None
projectInfo: Optional[Dict[str, Any]] = None
selectedElementHtml: Optional[str] = None
selectedElementId: Optional[str] = None
slideScreenshot: Optional[str] = None
elementScreenshot: Optional[str] = None
images: Optional[List[Dict[str, Any]]] = None
visionEnabled: bool = False
maxIterations: Optional[int] = None
class SlideEditAgentApplyRequest(BaseModel):
proposalId: str
projectId: str
slideIndex: int
expectedBaseHash: str
htmlContent: str
slideData: Optional[Dict[str, Any]] = None
@dataclass
class SlideEditAction:
thought: str
action: str
action_input: Dict[str, Any] = field(default_factory=dict)
raw_response: str = ""
@dataclass
class SlideEditValidationResult:
valid: bool
errors: List[str]
warnings: List[str]
sanitized_html: str
@dataclass
class SlideEditProposal:
proposal_id: str
base_hash: str
summary: str
changed_slide_indices: List[int]
html_content: str
validation: SlideEditValidationResult
tool_transcript: List[Dict[str, Any]]
slide_data: Dict[str, Any]
created_at: float
def to_public_dict(self) -> Dict[str, Any]:
return {
"proposalId": self.proposal_id,
"baseHash": self.base_hash,
"summary": self.summary,
"changedSlideIndices": self.changed_slide_indices,
"htmlContent": self.html_content,
"validation": {
"valid": self.validation.valid,
"errors": self.validation.errors,
"warnings": self.validation.warnings,
},
"toolTranscript": self.tool_transcript,
"slideData": self.slide_data,
"createdAt": self.created_at,
}
def compute_slide_html_hash(html: str) -> str:
normalized = (html or "").strip().encode("utf-8")
return hashlib.sha256(normalized).hexdigest()
def coerce_agent_max_iterations(raw_value: Any) -> int:
try:
if raw_value is not None:
return max(2, min(12, int(raw_value)))
except (TypeError, ValueError):
pass
return 6
def _extract_json_payload(text: str) -> Optional[Dict[str, Any]]:
if not text:
return None
cleaned = text.strip()
fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, re.IGNORECASE)
if fenced:
cleaned = fenced.group(1).strip()
try:
parsed = json.loads(cleaned)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
pass
object_match = re.search(r"\{[\s\S]*\}", cleaned)
if object_match:
try:
parsed = json.loads(object_match.group(0))
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
return None
def parse_agent_action(text: str) -> SlideEditAction:
payload = _extract_json_payload(text or "")
if not payload:
summary = (text or "").strip() or "Agent returned an empty response."
return SlideEditAction(
thought="Model response was not structured; treating it as final summary.",
action="final",
action_input={"summary": summary},
raw_response=text or "",
)
action_input = payload.get("action_input") or payload.get("input") or {}
if not isinstance(action_input, dict):
action_input = {"value": action_input}
action_name = str(payload.get("action") or payload.get("tool") or "final")
return SlideEditAction(
thought=str(payload.get("thought") or payload.get("reasoning") or "").strip(),
action=action_name.strip().lower().replace("-", "_"),
action_input=action_input,
raw_response=text or "",
)
def strip_agent_ids(html: str) -> str:
soup = BeautifulSoup(html or "", "html.parser")
for node in soup.find_all(True):
for attr in ("data-agent-id", "data-quick-ai-id"):
if attr in node.attrs:
del node.attrs[attr]
return str(soup).strip()
def sanitize_slide_html(html: str) -> str:
soup = BeautifulSoup(html or "", "html.parser")
for script in soup.find_all("script"):
script.decompose()
for node in soup.find_all(True):
for attr in list(getattr(node, "attrs", {}).keys()):
attr_lower = (attr or "").lower()
value = node.attrs.get(attr)
value_text = " ".join(value) if isinstance(value, list) else str(value or "")
value_lower = value_text.strip().lower()
if attr_lower.startswith("on"):
del node.attrs[attr]
continue
if "javascript:" in value_lower:
del node.attrs[attr]
continue
if attr_lower == "data-agent-id":
del node.attrs[attr]
return str(soup).strip()
class _SlideHtmlStructureParser(HTMLParser):
_VOID_ELEMENTS = {
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.stack: List[str] = []
self.errors: List[str] = []
def handle_starttag(self, tag: str, attrs: List[tuple[str, Optional[str]]]) -> None:
tag_name = tag.lower()
if tag_name not in self._VOID_ELEMENTS:
self.stack.append(tag_name)
def handle_startendtag(self, tag: str, attrs: List[tuple[str, Optional[str]]]) -> None:
return None
def handle_endtag(self, tag: str) -> None:
tag_name = tag.lower()
if tag_name in self._VOID_ELEMENTS:
return
if not self.stack:
self.errors.append("html is malformed")
return
if self.stack[-1] == tag_name:
self.stack.pop()
return
self.errors.append("html is malformed")
def close(self) -> None:
super().close()
if self.stack:
self.errors.append("html is malformed")
def _find_html_structure_errors(html: str) -> List[str]:
parser = _SlideHtmlStructureParser()
try:
parser.feed(html)
parser.close()
except Exception:
return ["html is malformed"]
return list(dict.fromkeys(parser.errors))
def validate_slide_html(html: str) -> SlideEditValidationResult:
errors: List[str] = []
warnings: List[str] = []
original = html or ""
original_lower = original.lower()
if not original.strip():
errors.append("html content is required")
return SlideEditValidationResult(False, errors, warnings, "")
if "<script" in original_lower:
errors.append("script tags are not allowed")
original_soup = BeautifulSoup(original, "html.parser")
if any(attr.lower().startswith("on") for tag in original_soup.find_all(True) for attr in tag.attrs):
errors.append("inline event handlers are not allowed")
if "javascript:" in original_lower:
errors.append("javascript urls are not allowed")
errors.extend(_find_html_structure_errors(original))
sanitized = sanitize_slide_html(original)
soup = BeautifulSoup(sanitized, "html.parser")
if not soup.find(True):
errors.append("html must contain at least one element")
root_text = soup.get_text(" ", strip=True)
has_media = bool(soup.find(["img", "svg", "canvas", "video", "picture"]))
if not root_text and not has_media:
warnings.append("slide has no visible text or media")
return SlideEditValidationResult(
valid=not errors,
errors=list(dict.fromkeys(errors)),
warnings=warnings,
sanitized_html=sanitized,
)
EventEmitter = Callable[[Dict[str, Any]], Awaitable[None]]
+81
View File
@@ -0,0 +1,81 @@
import asyncio
import json
from landppt.services.slide.slide_edit_agent_service import (
compute_slide_html_hash,
coerce_agent_max_iterations,
parse_agent_action,
sanitize_slide_html,
strip_agent_ids,
validate_slide_html,
)
def test_parse_agent_action_supports_json_code_fence():
action = parse_agent_action(
"```json\n"
+ json.dumps(
{
"thought": "Inspect the slide before editing.",
"action": "inspect_slide_html",
"action_input": {"slide_index": 1},
}
)
+ "\n```"
)
assert action.thought == "Inspect the slide before editing."
assert action.action == "inspect_slide_html"
assert action.action_input == {"slide_index": 1}
def test_parse_agent_action_falls_back_to_final_for_unparseable_text():
action = parse_agent_action("I cannot parse this as JSON.")
assert action.action == "final"
assert action.action_input["summary"] == "I cannot parse this as JSON."
def test_coerce_agent_max_iterations_defaults_and_clamps():
assert coerce_agent_max_iterations(None) == 6
assert coerce_agent_max_iterations(1) == 2
assert coerce_agent_max_iterations(8) == 8
assert coerce_agent_max_iterations(99) == 12
assert coerce_agent_max_iterations("bad") == 6
def test_compute_slide_html_hash_is_stable_for_equivalent_text():
assert compute_slide_html_hash(" <div>A</div>\n") == compute_slide_html_hash("<div>A</div>")
assert compute_slide_html_hash("<div>A</div>") != compute_slide_html_hash("<div>B</div>")
def test_sanitize_slide_html_removes_scripts_event_handlers_and_agent_ids():
html = (
'<div data-agent-id="a1" onclick="bad()" style="width:1280px;height:720px">'
'<a href="javascript:bad()">x</a><script>alert(1)</script>'
"</div>"
)
sanitized = sanitize_slide_html(html)
assert "<script" not in sanitized.lower()
assert "onclick" not in sanitized.lower()
assert "javascript:" not in sanitized.lower()
assert "data-agent-id" not in strip_agent_ids(sanitized)
def test_validate_slide_html_reports_unsafe_original_html():
result = validate_slide_html('<div><script>alert(1)</script><p onclick="x()">Hi</p></div>')
assert result.valid is False
assert "script tags are not allowed" in result.errors
assert "inline event handlers are not allowed" in result.errors
assert "<script" not in result.sanitized_html.lower()
def test_validate_slide_html_accepts_clean_slide_html():
result = validate_slide_html('<div style="width:1280px;height:720px"><h1>Hello</h1></div>')
assert result.valid is True
assert result.errors == []
assert "Hello" in result.sanitized_html