mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 18:15:33 +00:00
Add an inline-rendering, versionable html artifact kind
Add an html kind to the artifact generator: it renders a styled HTML report from a stored JSON spec (title + heading/paragraph/list/table/code blocks), producing text/html bytes the sidebar already previews inline in the sandboxed iframe, while keeping the spec as the source of truth so edit and rewrite make real versions. So a single artifact both renders inline and versions conversationally. The renderer is a fixed program fed the spec as data, and every spec text value is HTML-escaped into the output.
This commit is contained in:
1 parent
d573bab4a0
commit
6c8a72c836
3 files changed
+286
-7
No files matched your search
@@ -1,7 +1,7 @@
|
||||
"""Artifact Generator tool: render editable documents from a JSON spec and version them append-only.
|
||||
|
||||
The ``artifact_versions.spec`` JSONB is the source of truth; the rendered
|
||||
``.pptx``/``.docx``/``.xlsx``/``.pdf`` is derived. ``create_artifact`` stores
|
||||
``.pptx``/``.docx``/``.xlsx``/``.pdf``/``.html`` is derived. ``create_artifact`` stores
|
||||
v1, ``edit_artifact`` applies an RFC 7386 merge-patch to the current spec and
|
||||
appends a version, ``rewrite_artifact`` replaces the spec wholesale and appends
|
||||
a version. Rendering runs a FIXED program in the sandbox that reads the spec as
|
||||
@@ -58,6 +58,11 @@ _KIND_INFO: Dict[str, Dict[str, str]] = {
|
||||
"ext": "pdf",
|
||||
"mime": "application/pdf",
|
||||
},
|
||||
"html": {
|
||||
"kind": "html",
|
||||
"ext": "html",
|
||||
"mime": "text/html",
|
||||
},
|
||||
}
|
||||
|
||||
# Tight per-kind JSON schemas. ``additionalProperties: false`` keeps specs minimal
|
||||
@@ -144,6 +149,72 @@ _SCHEMAS: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
"required": ["blocks"],
|
||||
},
|
||||
"html": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"type": {"const": "heading"},
|
||||
"text": {"type": "string"},
|
||||
"level": {"type": "integer", "minimum": 1, "maximum": 3},
|
||||
},
|
||||
"required": ["type", "text"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"type": {"const": "paragraph"},
|
||||
"text": {"type": "string"},
|
||||
},
|
||||
"required": ["type", "text"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"type": {"const": "list"},
|
||||
"ordered": {"type": "boolean"},
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["type", "items"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"type": {"const": "table"},
|
||||
"headers": {"type": "array", "items": {"type": "string"}},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
"required": ["type", "rows"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"type": {"const": "code"},
|
||||
"text": {"type": "string"},
|
||||
},
|
||||
"required": ["type", "text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
# FIXED renderer programs. Each reads ``spec.json`` from the workspace as DATA
|
||||
@@ -227,6 +298,66 @@ _RENDERERS: Dict[str, str] = {
|
||||
" story.append(Spacer(1, 6))\n"
|
||||
"SimpleDocTemplate({out_path!r}, pagesize=letter).build(story)\n"
|
||||
),
|
||||
"html": (
|
||||
"import json\n"
|
||||
"import html\n"
|
||||
"spec = json.load(open({spec_path!r}))\n"
|
||||
"def esc(value):\n"
|
||||
" return html.escape('' if value is None else str(value))\n"
|
||||
"parts = []\n"
|
||||
"title = spec.get('title')\n"
|
||||
"if title:\n"
|
||||
" parts.append('<h1>' + esc(title) + '</h1>')\n"
|
||||
"for block in spec.get('blocks', []):\n"
|
||||
" kind = block.get('type')\n"
|
||||
" if kind == 'heading':\n"
|
||||
" level = block.get('level') or 2\n"
|
||||
" try:\n"
|
||||
" level = int(level)\n"
|
||||
" except (TypeError, ValueError):\n"
|
||||
" level = 2\n"
|
||||
" level = min(max(level, 1), 3) + 1\n"
|
||||
" parts.append('<h%d>%s</h%d>' % (level, esc(block.get('text', '')), level))\n"
|
||||
" elif kind == 'paragraph':\n"
|
||||
" parts.append('<p>' + esc(block.get('text', '')) + '</p>')\n"
|
||||
" elif kind == 'list':\n"
|
||||
" tag = 'ol' if block.get('ordered') else 'ul'\n"
|
||||
" items = ''.join('<li>' + esc(i) + '</li>' for i in (block.get('items') or []))\n"
|
||||
" parts.append('<%s>%s</%s>' % (tag, items, tag))\n"
|
||||
" elif kind == 'table':\n"
|
||||
" rows_html = []\n"
|
||||
" headers = block.get('headers')\n"
|
||||
" if headers:\n"
|
||||
" cells = ''.join('<th>' + esc(h) + '</th>' for h in headers)\n"
|
||||
" rows_html.append('<thead><tr>' + cells + '</tr></thead>')\n"
|
||||
" body = []\n"
|
||||
" for row in (block.get('rows') or []):\n"
|
||||
" cells = ''.join('<td>' + esc(c) + '</td>' for c in row)\n"
|
||||
" body.append('<tr>' + cells + '</tr>')\n"
|
||||
" rows_html.append('<tbody>' + ''.join(body) + '</tbody>')\n"
|
||||
" parts.append('<table>' + ''.join(rows_html) + '</table>')\n"
|
||||
" elif kind == 'code':\n"
|
||||
" parts.append('<pre><code>' + esc(block.get('text', '')) + '</code></pre>')\n"
|
||||
# CSS braces are doubled so the outer ``_RENDERERS[kind].format(...)`` leaves them literal.
|
||||
"css = (\n"
|
||||
" 'body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;'\n"
|
||||
" 'line-height:1.6;color:#1a1a1a;max-width:800px;margin:0 auto;padding:24px}}'\n"
|
||||
" 'h1,h2,h3,h4{{line-height:1.25;margin:1.2em 0 0.5em}}'\n"
|
||||
" 'table{{border-collapse:collapse;width:100%;margin:1em 0}}'\n"
|
||||
" 'th,td{{border:1px solid #d0d0d0;padding:6px 10px;text-align:left}}'\n"
|
||||
" 'th{{background:#f5f5f5}}'\n"
|
||||
" 'pre{{background:#f5f5f5;padding:12px;border-radius:6px;overflow:auto}}'\n"
|
||||
" 'code{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}}'\n"
|
||||
")\n"
|
||||
"doc = (\n"
|
||||
" '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">'\n"
|
||||
" '<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">'\n"
|
||||
" '<title>' + esc(title or 'Report') + '</title>'\n"
|
||||
" '<style>' + css + '</style></head><body>'\n"
|
||||
" + ''.join(parts) + '</body></html>'\n"
|
||||
")\n"
|
||||
"open({out_path!r}, 'w', encoding='utf-8').write(doc)\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +378,7 @@ def merge_patch(target: Any, patch: Any) -> Any:
|
||||
|
||||
class ArtifactGeneratorTool(Tool):
|
||||
"""Artifact Generator
|
||||
Render editable documents (presentation/document/spreadsheet/pdf) from a JSON spec and version them.
|
||||
Render editable documents (presentation/document/spreadsheet/pdf/html) from a JSON spec and version them.
|
||||
"""
|
||||
|
||||
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
|
||||
@@ -277,7 +408,13 @@ class ArtifactGeneratorTool(Tool):
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": kinds, "description": "Document kind to render."},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": kinds,
|
||||
"description": (
|
||||
"Document kind to render; `html` is an inline-rendered, versionable HTML report."
|
||||
),
|
||||
},
|
||||
"title": {"type": "string", "description": "Optional artifact title."},
|
||||
"spec": {"type": "object", "description": "Document spec matching the kind's schema."},
|
||||
},
|
||||
@@ -373,7 +510,7 @@ class ArtifactGeneratorTool(Tool):
|
||||
return {"status": "error", "error": rendered["error"]}
|
||||
|
||||
info = _KIND_INFO[kind]
|
||||
filename = self._filename(title, info["ext"])
|
||||
filename = self._filename(title, kind)
|
||||
try:
|
||||
ref = persist_new_artifact(
|
||||
user_id=self.user_id,
|
||||
@@ -422,7 +559,7 @@ class ArtifactGeneratorTool(Tool):
|
||||
if rendered.get("error"):
|
||||
return {"status": "error", "error": rendered["error"]}
|
||||
info = _KIND_INFO[kind]
|
||||
filename = self._filename(None, info["ext"])
|
||||
filename = self._filename(None, kind)
|
||||
try:
|
||||
ref = append_artifact_version(
|
||||
user_id=self.user_id,
|
||||
@@ -565,10 +702,12 @@ class ArtifactGeneratorTool(Tool):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _filename(title: Optional[str], ext: str) -> str:
|
||||
def _filename(title: Optional[str], kind: str) -> str:
|
||||
"""Derive a download filename from a title (or a generic stem) plus the kind extension."""
|
||||
if kind == "html":
|
||||
return "report.html"
|
||||
stem = (title or "artifact").strip() or "artifact"
|
||||
return f"{stem}.{ext}"
|
||||
return f"{stem}.{_KIND_INFO[kind]['ext']}"
|
||||
|
||||
def _resolve_session_id(self) -> Optional[str]:
|
||||
"""Derive the sandbox session id from the bound conversation/run; sanitize to the gateway charset."""
|
||||
|
||||
@@ -158,3 +158,84 @@ def test_pdf_renderer_escapes_markup_and_does_not_execute():
|
||||
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
|
||||
out_path = _render_in_process("pdf", spec)
|
||||
assert os.path.getsize(out_path) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# html kind: schema, renderer injection safety, output escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_accepts_html_spec():
|
||||
spec = {
|
||||
"title": "Report",
|
||||
"blocks": [
|
||||
{"type": "heading", "text": "Intro", "level": 2},
|
||||
{"type": "paragraph", "text": "Body."},
|
||||
{"type": "list", "ordered": True, "items": ["a", "b"]},
|
||||
{"type": "table", "headers": ["h"], "rows": [["c"]]},
|
||||
{"type": "code", "text": "print(1)"},
|
||||
],
|
||||
}
|
||||
assert _tool()._validate("html", spec) is None
|
||||
|
||||
|
||||
def test_validate_rejects_html_unknown_block_key():
|
||||
err = _tool()._validate("html", {"blocks": [{"type": "paragraph", "text": "x", "bogus": 1}]})
|
||||
assert err["status"] == "error"
|
||||
|
||||
|
||||
def test_validate_rejects_html_bad_heading_level():
|
||||
err = _tool()._validate("html", {"blocks": [{"type": "heading", "text": "x", "level": 9}]})
|
||||
assert err["status"] == "error"
|
||||
|
||||
|
||||
def test_html_renderer_does_not_execute_spec_code(capfd, tmp_path):
|
||||
# The renderer json.loads the spec as data; a value that is Python source
|
||||
# must never run. If the program string-interpolated the spec it would.
|
||||
sentinel = tmp_path / "pwned.txt"
|
||||
payload = (
|
||||
f"'''__import__('os').system('echo PWNED > {sentinel}')'''; "
|
||||
"print('SHOULD_NOT_PRINT')"
|
||||
)
|
||||
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
|
||||
out_path = _render_in_process("html", spec)
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "SHOULD_NOT_PRINT" not in captured.out
|
||||
assert "PWNED" not in captured.out
|
||||
assert not sentinel.exists()
|
||||
assert os.path.getsize(out_path) > 0
|
||||
|
||||
|
||||
def test_html_renderer_escapes_spec_markup():
|
||||
# Spec text carrying live markup must appear HTML-ESCAPED in the output, so
|
||||
# no raw <script>/<img onerror> tag from spec content reaches the document.
|
||||
script = "<script>alert(1)</script>"
|
||||
img = '<img src=x onerror="alert(2)">'
|
||||
spec = {
|
||||
"title": script,
|
||||
"blocks": [
|
||||
{"type": "paragraph", "text": img},
|
||||
{"type": "heading", "text": "<b>h</b>", "level": 1},
|
||||
{"type": "list", "items": ["<i>x</i>"]},
|
||||
{"type": "table", "headers": ["<u>th</u>"], "rows": [["<em>td</em>"]]},
|
||||
{"type": "code", "text": "</code><script>x</script>"},
|
||||
],
|
||||
}
|
||||
with open(_render_in_process("html", spec), encoding="utf-8") as handle:
|
||||
html_doc = handle.read()
|
||||
|
||||
# No live tag from spec content survives.
|
||||
assert "<script>alert(1)</script>" not in html_doc
|
||||
assert "<img src=x onerror=" not in html_doc
|
||||
assert "<b>h</b>" not in html_doc
|
||||
assert "<i>x</i>" not in html_doc
|
||||
assert "<u>th</u>" not in html_doc
|
||||
assert "<em>td</em>" not in html_doc
|
||||
# The escaped forms are present instead.
|
||||
assert "<script>alert(1)</script>" in html_doc
|
||||
assert "<img src=x onerror=" in html_doc
|
||||
# The fixed structural HTML the renderer emits is intact.
|
||||
assert "<!doctype html>" in html_doc
|
||||
assert "<p>" in html_doc and "</p>" in html_doc
|
||||
assert "<table>" in html_doc and "<pre><code>" in html_doc
|
||||
@@ -211,6 +211,65 @@ def test_create_spreadsheet_xlsx(wired_tool):
|
||||
assert ws.cell(row=2, column=2).value == 2
|
||||
|
||||
|
||||
def test_create_edit_html_versions(wired_tool):
|
||||
tool, conversation_id, pg_engine, storage = wired_tool
|
||||
|
||||
# create -> v1: an html report rendered to report.html (kind html, text/html).
|
||||
spec = {
|
||||
"title": "Findings <hi>",
|
||||
"blocks": [
|
||||
{"type": "heading", "text": "Overview", "level": 2},
|
||||
{"type": "paragraph", "text": "Body <script>alert(1)</script>."},
|
||||
{"type": "list", "ordered": False, "items": ["one", "two"]},
|
||||
{"type": "table", "headers": ["k", "v"], "rows": [["a", "1"]]},
|
||||
],
|
||||
}
|
||||
created = tool.execute_action("create_artifact", kind="html", title="Report", spec=spec)
|
||||
assert created["status"] == "ok", created
|
||||
assert created["version"] == 1
|
||||
assert created["mime_type"] == "text/html"
|
||||
assert created["filename"] == "report.html"
|
||||
assert created["ref"] == "A1"
|
||||
artifact_id = created["artifact_id"]
|
||||
|
||||
with pg_engine.connect() as conn:
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
|
||||
v1 = repo.get_version(artifact_id, 1)
|
||||
assert artifact["kind"] == "html"
|
||||
assert artifact["current_version"] == 1
|
||||
assert v1["spec"] == spec
|
||||
assert v1["produced_by"]["spec_kind"] == "html"
|
||||
|
||||
# The stored bytes are a valid HTML doc with escaped title/blocks (no live tag).
|
||||
html_doc = storage.get_file(v1["storage_path"]).read().decode("utf-8")
|
||||
assert html_doc.startswith("<!doctype html>")
|
||||
assert "Findings <hi>" in html_doc
|
||||
assert "<script>alert(1)</script>" in html_doc
|
||||
assert "<script>alert(1)</script>" not in html_doc
|
||||
assert "<li>one</li>" in html_doc and "<td>a</td>" in html_doc
|
||||
|
||||
# edit (merge-patch) -> v2: replace the title, keep v1 intact.
|
||||
edited = tool.execute_action("edit_artifact", id="A1", spec_patch={"title": "Updated"})
|
||||
assert edited["status"] == "ok", edited
|
||||
assert edited["version"] == 2
|
||||
assert edited["artifact_id"] == artifact_id
|
||||
assert edited["ref"] == "A1"
|
||||
|
||||
with pg_engine.connect() as conn:
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
|
||||
v1_again = repo.get_version(artifact_id, 1)
|
||||
v2 = repo.get_version(artifact_id, 2)
|
||||
assert artifact["current_version"] == 2
|
||||
# Append-only: v1 unchanged; v2 carries the merged spec re-rendered.
|
||||
assert v1_again["spec"] == spec
|
||||
assert v2["spec"]["title"] == "Updated"
|
||||
assert len(v2["spec"]["blocks"]) == 4
|
||||
v2_doc = storage.get_file(v2["storage_path"]).read().decode("utf-8")
|
||||
assert "<h1>Updated</h1>" in v2_doc
|
||||
|
||||
|
||||
def test_invalid_spec_creates_no_artifact(wired_tool):
|
||||
tool, conversation_id, pg_engine, _storage = wired_tool
|
||||
|
||||
|
||||
Reference in new issue
Block a user