DrawAI — drawio beautifier example (v1 design)¶
Status: design approved, not yet implemented
Date: 2026-04-17
Scope: a single example file that demonstrates an AI-assisted drawio-XML beautifier layered on top of the existing AiChat panel, with no core changes to src/panelini/panels/ai/.
Goals¶
Ship a self-contained example at examples/panels/ai/drawai_beautify.py that a user can run and drive end-to-end.
Reuse
AiChat(sidebar, provider config, chat loop, history, streaming) unchanged; extend via a custom tool and a custom main-area layout only.Let a user upload a drawio file (
.drawioor.drawio.png), chat an “intent” for beautification, and get a visual before/after comparison in the browser.Non-destructive: the original file is never modified. Output is a download (browser Downloads folder), never a server-side write.
Use Claude Opus 4.7 (
claude-opus-4-7) for the beautification itself, called directly through the officialanthropicSDK with prompt caching on the system prompt and the XML input.The layout is reusable — the ASCII diagram in this doc should lift cleanly into docs/panels/ when the example graduates to a panel module.
Non-goals (v1)¶
Not a new panel module. That is a natural follow-up once the example’s UX is validated.
No
.drawio.svgsupport. Next iteration.No re-rasterization of beautified diagrams. The bottom pane renders XML via the drawio web viewer; the downloaded
.drawio.pngreuses the original pixels with the new XML embedded in thetEXtchunk (so drawio opens it correctly; file-manager thumbnails still show the old pixels — documented trade-off).No server-side filesystem I/O (no reading from
local/, no writing todata/beautified/).No preset prompt templates (
polish,realign, etc.). Free-form intent only. Presets can come later as canned system prompts or sidebar buttons.No multi-file batch beautification. One file at a time.
No drawio-side validation beyond XML parseability (
xml.etree.ElementTree.fromstring).
User flow¶
User runs
python examples/panels/ai/drawai_beautify.py.Browser opens the Panelini page. Sidebar shows the standard
AiChatcontrols (provider, model, tools, chat management).User clicks the Upload widget above the compare column, picks a
.drawioor.drawio.pngfile.Top pane renders the original:
.drawio.png→pn.pane.Imageshowing the uploaded PNG..drawio→ embedded drawio viewer iframe rendering the uploaded XML.
User types in the chat: e.g. “make spacing tighter and align nodes on a grid”.
Chat model (whatever the sidebar selects — Sonnet, Haiku, …) receives the message, decides to call the
beautify_drawiotool withintent="make spacing tighter and align nodes on a grid".The tool calls Opus 4.7 directly, receives new XML, validates it parses, writes it to
state.beautified_xml.Bottom pane’s iframe rebuilds with the new XML — visual compare complete.
User iterates in chat (“now recolor to match a blue theme”) — each successful iteration overwrites
state.beautified_xml, bottom pane updates.User clicks Download beautified — browser downloads
<stem>_beautified.drawio.png(or.drawio).
Architecture¶
Layout¶
┌─ Panelini header ─────────────────────────────────────────────────┐
├─ Sidebar ──────┬─ Main ────────────────────────────────────────── ┤
│ (from AiChat: │ ┌─ Chat (left) ──┐ ┌─ Compare (right) ─────────┐ │
│ provider, │ │ │ │ ┌ FileInput ────────────┐ │ │
│ model, │ │ ChatInterface │ │ │ Original (top) │ │ │
│ tools, │ │ (streaming, │ │ │ pn.pane.Image or │ │ │
│ chat mgmt) │ │ history, │ │ │ viewer iframe │ │ │
│ │ │ upload chat) │ │ └───────────────────────┘ │ │
│ │ │ │ │ ┌ Beautified (bottom) ──┐ │ │
│ │ │ │ │ │ viewer iframe │ │ │
│ │ │ │ │ └───────────────────────┘ │ │
│ │ │ │ │ [ Download beautified ] │ │
│ │ └────────────────┘ └───────────────────────────┘ │
└────────────────┴──────────────────────────────────────────────────┘
Outer:
Panelini(title="Panelini DrawAI", sidebar_enabled=True).Sidebar:
AiChat.sidebar_objectspassed through as-is.Main:
pn.Row(chat_card, compare_column, sizing_mode="stretch_both").chat_card— reusesAiChat.chat_interfaceinside the samepn.Cardpattern as frontend.py:288-295.compare_column—pn.Column(file_input, alert_pane, top_pane, bottom_pane, download_button).alert_paneis a hidden-by-defaultpn.pane.Alertabove the top pane, surfaced only on upload errors.
Component boundaries¶
Every unit has a single purpose, a clear interface, and lives in the example file (v1 keeps the module count low — promotion to a package can split it later).
Unit |
Purpose |
Interface |
Depends on |
|---|---|---|---|
|
Holds the uploaded file + beautification result. Reactive; UI binds via |
|
|
|
Read the |
pure function |
|
|
Take an original drawio PNG and new XML; write a new PNG with updated |
pure function |
|
|
Raise on unparseable XML; noop otherwise. |
pure function |
stdlib |
|
LangChain tool the chat model calls with an |
|
|
|
Build the HTML for a drawio web-viewer iframe that renders a given XML string (URL-encoded into |
pure function |
stdlib |
|
File-input callback: detect format, run extractor if PNG, update |
closure |
|
|
Button callback: produce a data-URL download link for |
closure |
|
|
Wires everything and returns the servable app. |
pure function |
all of the above |
The tool¶
class BeautifyDrawioInput(BaseModel):
intent: str = Field(description="Free-form description of how the user wants the diagram beautified.")
class BeautifyDrawioTool(BaseTool):
name = "beautify_drawio"
description = (
"Beautify the currently loaded drawio diagram's XML. "
"Call this when the user asks to clean up, realign, restyle, or otherwise "
"improve the visual quality of the diagram they uploaded. "
"The uploaded file's XML is already available to the tool — do not pass it."
)
args_schema = BeautifyDrawioInput
state: DrawAiState
model_name: str = "claude-opus-4-7"
async def _arun(self, intent: str) -> str:
if not self.state.current_xml:
return "No file loaded. Ask the user to upload a .drawio or .drawio.png first."
client = anthropic.AsyncAnthropic() # reads ANTHROPIC_API_KEY
try:
resp = await client.messages.create(
model=self.model_name,
max_tokens=8192,
system=[{
"type": "text",
"text": "You beautify drawio XML. Output valid drawio XML only, "
"no prose, no code fences. Preserve node IDs where possible "
"so diffs stay meaningful.",
"cache_control": {"type": "ephemeral"},
}],
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": f"<drawio-xml>\n{self.state.current_xml}\n</drawio-xml>",
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": f"Intent: {intent}",
},
],
}],
)
except anthropic.APIError as e:
return f"Anthropic API error: {e}"
new_xml = _strip_fences(resp.content[0].text)
try:
validate_drawio_xml(new_xml)
except ET.ParseError as e:
return f"Returned content did not parse as XML. Parse error: {e}. Please try again."
self.state.beautified_xml = new_xml
return "Beautified — see the bottom pane. Click Download to save."
Prompt caching: both the system prompt and the <drawio-xml>…</drawio-xml> user-content block carry cache_control: ephemeral. The intent block is fresh per call, so iterations on the same file reuse the cached XML — matching the “XML is large and often reused across iterations” constraint.
Upload handler¶
def on_upload(event):
filename = file_input.filename or ""
data = event.new
if not data:
return
try:
if filename.endswith(".drawio.png"):
xml = extract_xml_from_drawio_png(data)
fmt = "png"
elif filename.endswith(".drawio"):
xml = data.decode("utf-8")
fmt = "drawio"
else:
alert_pane.object = "Unsupported extension. Use .drawio or .drawio.png."
alert_pane.visible = True
return
validate_drawio_xml(xml)
except Exception as e:
alert_pane.object = f"Could not read file: {e}"
alert_pane.visible = True
return
alert_pane.visible = False
state.param.update(
current_bytes=data,
current_xml=xml,
current_format=fmt,
current_filename=filename,
beautified_xml="", # reset previous beautification
)
Download handler¶
def on_download(event):
stem = state.current_filename.removesuffix(".drawio.png").removesuffix(".drawio")
if state.current_format == "png":
out_bytes = embed_xml_into_drawio_png(state.current_bytes, state.beautified_xml)
out_name = f"{stem}_beautified.drawio.png"
mime = "image/png"
else:
out_bytes = state.beautified_xml.encode("utf-8")
out_name = f"{stem}_beautified.drawio"
mime = "application/xml"
b64 = base64.b64encode(out_bytes).decode()
download_html.object = (
f'<a href="data:{mime};base64,{b64}" '
f'download="{out_name}">Click to download {out_name}</a>'
)
(Same base64-data-URL pattern the existing chat export already uses — see frontend.py:428-460.)
Viewer iframe¶
def make_viewer_html(xml: str) -> str:
# viewer.diagrams.net reads the XML from the URL fragment
encoded = urllib.parse.quote(xml)
src = f"https://viewer.diagrams.net/?lightbox=1&highlight=0000ff&edit=_blank#R{encoded}"
return f'<iframe src="{src}" width="100%" height="100%" frameborder="0"></iframe>'
If the URL fragment approach proves unreliable for large XML (browser URL length limits), fall back to postMessage integration with viewer.diagrams.net — documented but not implemented in v1.
Dependencies¶
Two new packages, added under an optional extra so the core ai extra stays lean:
[project.optional-dependencies]
ai-drawio = [
"anthropic>=0.39", # SDK with prompt caching support
"pillow>=10.0", # PNG tEXt chunk I/O
]
Document in examples/panels/ai/README.md: “DrawAI example requires pip install panelini[ai,ai-drawio].”
If sentiment during implementation favors folding into [ai], that’s a trivial change — the extra split is a YAGNI hedge, not a load-bearing decision.
Error handling¶
All user-facing; either surfaced in the chat log (model-driven) or inline in alert_pane (handler-driven). No silent retries, no partial state mutations.
Failure |
Where caught |
Handling |
|---|---|---|
PNG without |
|
|
|
|
|
Unknown extension |
|
|
|
|
Return “Anthropic API error: missing ANTHROPIC_API_KEY.” to the chat model. |
Anthropic API error (network, rate limit, auth, timeout) |
|
Return the error string verbatim to the chat model; it can retry or surface it to the user. |
Model output is non-XML |
|
Return “Returned content did not parse as XML. Parse error: {e}. Please try again.” — chat model naturally retries. |
Beautify called with no file loaded |
|
Return “No file loaded. Ask the user to upload a .drawio or .drawio.png first.”. |
Download clicked with no beautification |
Disabled via |
N/A |
A failed beautify leaves state.beautified_xml untouched — the bottom pane continues showing the last successful result (or stays empty if none).
Testing¶
Fixtures live at tests/panels/ai/fixtures/drawai/ and describe the scenario, not the source project’s demo files:
diagram.drawio.png— a minimal valid drawio PNG (a single<mxCell>with a rectangle). Used by happy-path extract/embed/UI tests.diagram.drawio— raw XML matching the PNG’s embedded XML. Used for the.drawioinput path test.corrupt.drawio.png— a plain PNG with nomxfilechunk. Used for the upload-error test.malformed.drawio— invalid XML. Used for the parse-failure test.
Fixtures are generated deterministically by a committed script at tests/panels/ai/fixtures/drawai/_make_fixtures.py. The script is runnable (python _make_fixtures.py) to regenerate the binary assets; CI does not re-run it — the generated files are committed directly.
The local/fig*.drawio.png files remain dev-only demo material; the test suite never reads them.
Unit tests — tests/panels/ai/test_drawai_helpers.py:
extract_xml_from_drawio_png(diagram.drawio.png)→ XML whose root tag ismxfileormxGraphModel.Round-trip:
extract(embed(orig, extract(orig))) == extract(orig). Byte-equality of the PNG is not asserted (chunk ordering may differ across Pillow versions).validate_drawio_xml— one pass, one fail.extract_xml_from_drawio_png(corrupt.drawio.png)→ raises; assert error type.
UI tests — tests/panels/ai/examples/test_drawai_ui.py, marked ui + ai, following the Playwright pattern from commit a391172:
Add a
_mock_anthropic_sdkfixture that stubsanthropic.AsyncAnthropic.messages.createto return a canned “beautified” XML. Reuse the existing_mock_langchainfixture for the chat model. Both live in tests/conftest.py.Upload
diagram.drawio.pngvia theFileInput→ assert top pane renders (image or iframe, by locator).Send chat message “make it cleaner” → assert bottom pane iframe
srccontains the base64-/url-encoded canned XML.Assert download button transitions
disabled→enabledafter the beautification step.Upload
corrupt.drawio.png→ assertalert_paneis visible with the expected message.
Run target: keep the new UI tests under ~5 s combined (current AI UI tests run in ~6 s total).
File layout summary¶
New files:
examples/panels/ai/drawai_beautify.py— the example (~250-350 LOC expected).tests/panels/ai/test_drawai_helpers.pytests/panels/ai/test_drawai_ui.pytests/panels/ai/fixtures/drawai/diagram.drawio.pngtests/panels/ai/fixtures/drawai/diagram.drawiotests/panels/ai/fixtures/drawai/corrupt.drawio.pngtests/panels/ai/fixtures/drawai/malformed.drawio
Modified files:
pyproject.toml— add[ai-drawio]optional extra (anthropic>=0.39,pillow>=10.0).tests/conftest.py— add_mock_anthropic_sdkfixture.examples/panels/ai/README.md(orexamples/README.md, whichever is canonical) — add a “DrawAI example” section with install + run instructions.
No changes to src/panelini/panels/ai/.
Follow-ups (explicitly out of scope for v1)¶
Promote to a panel module at
src/panelini/panels/drawai/with its own frontend/backend split..drawio.svginput/output.Drag-and-drop upload (Panel’s
FileInputsupports it natively; v1 uses the default click-to-upload to keep the example short).Preset intent buttons (
polish,realign,recolor,restructure).Side-by-side XML diff view as an optional third pane.
Re-rasterization via a drawio CLI so downloaded PNGs have matching pixels.
postMessage-based viewer integration to remove the URL-length limit on very large diagrams.Sphinx docs page reusing the layout diagram above, under docs/panels/.