DrawAI Beautify Example Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a self-contained example (examples/panels/ai/drawai_beautify.py) that lets a user upload a .drawio or .drawio.png, chat a beautification intent, see a before/after visual comparison rendered via the drawio web viewer, and download the beautified result — with Claude Opus 4.7 called through the anthropic SDK (with prompt caching) from inside a LangChain tool plugged into the existing AiChat panel.
Architecture: One example file contains all helpers, the DrawAiState (param.Parameterized), the BeautifyDrawioTool (LangChain BaseTool that internally calls the anthropic SDK), and a build_app() that wires a custom main-area layout (chat left, upload + top-pane + bottom-pane + download-button right) while reusing AiChat.chat_interface and AiChat.sidebar_objects unchanged. The drawio web viewer is embedded via an iframe whose src carries the XML in the URL fragment (#R<url-encoded-xml>). Unit tests cover pure helpers + the tool (with the anthropic SDK mocked); Playwright UI tests cover layout + upload handling.
Tech Stack: Python 3.10+, panel, param, langchain-core (via existing [ai] extra), new deps anthropic>=0.39 and pillow>=10.0 under a new [ai-drawio] extra; pytest + pytest-playwright for tests.
Spec: docs/superpowers/specs/2026-04-17-drawai-beautify-example-design.md
File Structure¶
File |
Responsibility |
Status |
|---|---|---|
|
Add |
modify |
|
All example code: helpers, state, tool, app |
create |
|
Add run instructions for DrawAI example |
modify (create if missing) |
|
Deterministically regenerate binary fixtures |
create |
|
Happy-path drawio PNG fixture |
generate |
|
Happy-path |
generate |
|
PNG without |
generate |
|
Invalid XML |
generate |
|
Unit tests for helpers, |
create |
|
Playwright UI tests |
create |
|
Add |
modify |
All helper/tool code lives in the single example file so tests can from examples.panels.ai.drawai_beautify import ... (works because pyproject.toml has pytest.pythonpath = ["."]).
Task 1: Add [ai-drawio] optional extra¶
Files:
Modify:
pyproject.toml(the[project.optional-dependencies]block)Step 1: Add the extra
Open pyproject.toml and, inside the [project.optional-dependencies] block (currently ends after the ai = [...] array around line 43), add a new extra below ai = [...]:
ai-drawio = [
"anthropic>=0.39",
"pillow>=10.0",
]
Step 2: Install the new deps into the working environment
Run:
uv sync --extra ai --extra ai-drawio --group dev
Expected: both anthropic and pillow resolve and install without errors.
Step 3: Verify imports work
Run:
uv run python -c "import anthropic; import PIL; print(anthropic.__version__, PIL.__version__)"
Expected: prints both version strings without ImportError.
Step 4: Commit
git add pyproject.toml uv.lock
git commit -m "deps: add ai-drawio optional extra (anthropic, pillow)"
Task 2: Write the fixture generator script¶
Files:
Create:
tests/panels/ai/fixtures/drawai/_make_fixtures.pyStep 1: Create fixture directory
Run:
mkdir -p tests/panels/ai/fixtures/drawai
Step 2: Write the generator script
Create tests/panels/ai/fixtures/drawai/_make_fixtures.py with this exact content:
"""Deterministically regenerate drawai test fixtures.
Run from anywhere:
python tests/panels/ai/fixtures/drawai/_make_fixtures.py
Generates four files alongside this script:
diagram.drawio.png - valid drawio PNG (uncompressed XML in mxfile tEXt)
diagram.drawio - plain XML matching the PNG's embedded XML
corrupt.drawio.png - plain PNG, no mxfile chunk (upload-error fixture)
malformed.drawio - invalid XML (parse-error fixture)
"""
from pathlib import Path
from PIL import Image, PngImagePlugin
HERE = Path(__file__).parent
DIAGRAM_XML = (
'<mxfile host="app.diagrams.net" version="26.0.0">\n'
' <diagram id="test" name="Page-1">\n'
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
'page="1" pageWidth="850" pageHeight="1100">\n'
" <root>\n"
' <mxCell id="0"/>\n'
' <mxCell id="1" parent="0"/>\n'
' <mxCell id="2" value="Hello" '
'style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">\n'
' <mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>\n'
" </mxCell>\n"
" </root>\n"
" </mxGraphModel>\n"
" </diagram>\n"
"</mxfile>\n"
)
def make_drawio_png(xml: str, out_path: Path, size: tuple[int, int] = (200, 100)) -> None:
img = Image.new("RGB", size, color="white")
meta = PngImagePlugin.PngInfo()
meta.add_text("mxfile", xml)
img.save(out_path, "PNG", pnginfo=meta)
def make_plain_png(out_path: Path, size: tuple[int, int] = (200, 100)) -> None:
img = Image.new("RGB", size, color=(128, 128, 128))
img.save(out_path, "PNG")
def main() -> None:
make_drawio_png(DIAGRAM_XML, HERE / "diagram.drawio.png")
(HERE / "diagram.drawio").write_text(DIAGRAM_XML, encoding="utf-8")
make_plain_png(HERE / "corrupt.drawio.png")
(HERE / "malformed.drawio").write_text("<not>valid</xml>", encoding="utf-8")
print(f"Fixtures written to {HERE}")
if __name__ == "__main__":
main()
Step 3: Run the generator
Run:
uv run python tests/panels/ai/fixtures/drawai/_make_fixtures.py
Expected: prints Fixtures written to .../tests/panels/ai/fixtures/drawai. Four files now exist in that directory.
Step 4: Verify the generated files
Run:
ls tests/panels/ai/fixtures/drawai/
Expected output includes: _make_fixtures.py, corrupt.drawio.png, diagram.drawio, diagram.drawio.png, malformed.drawio.
Step 5: Commit
git add tests/panels/ai/fixtures/drawai/
git commit -m "test: add drawai fixture generator and generated fixtures"
Task 3: TDD extract_xml_from_drawio_png¶
Files:
Create:
examples/panels/ai/drawai_beautify.pyCreate:
tests/panels/ai/test_drawai_helpers.pyStep 1: Write the failing test
Create tests/panels/ai/test_drawai_helpers.py:
"""Unit tests for DrawAI example helpers, state, and tool."""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("anthropic")
pytest.importorskip("PIL")
from examples.panels.ai.drawai_beautify import extract_xml_from_drawio_png # noqa: E402
FIXTURES = Path(__file__).parent / "fixtures" / "drawai"
def test_extract_xml_from_valid_drawio_png_returns_mxfile_xml():
data = (FIXTURES / "diagram.drawio.png").read_bytes()
xml = extract_xml_from_drawio_png(data)
assert xml.lstrip().startswith("<mxfile")
assert "Hello" in xml
def test_extract_xml_from_plain_png_raises():
data = (FIXTURES / "corrupt.drawio.png").read_bytes()
with pytest.raises(ValueError, match="mxfile"):
extract_xml_from_drawio_png(data)
Step 2: Run test to verify it fails with ImportError
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: collection error or fail — examples/panels/ai/drawai_beautify.py doesn’t exist yet.
Step 3: Create the example file with just this helper
Create examples/panels/ai/drawai_beautify.py:
"""DrawAI — AI-assisted drawio beautifier example.
Upload a .drawio or .drawio.png, chat a beautification intent,
see a before/after compare rendered via the drawio web viewer,
and download the beautified result. Uses Claude Opus 4.7
through the anthropic SDK with prompt caching on the XML.
Prerequisites:
pip install panelini[ai,ai-drawio]
export ANTHROPIC_API_KEY=...
Run:
python examples/panels/ai/drawai_beautify.py
"""
from __future__ import annotations
from io import BytesIO
from PIL import Image
def extract_xml_from_drawio_png(data: bytes) -> str:
"""Return the XML stored in a drawio PNG's ``mxfile`` tEXt chunk.
Raises:
ValueError: if the PNG has no ``mxfile`` chunk.
"""
with Image.open(BytesIO(data)) as img:
img.load()
text = getattr(img, "text", {}) or {}
if "mxfile" not in text:
raise ValueError("No 'mxfile' tEXt chunk found — not a drawio PNG.")
return text["mxfile"]
Step 4: Run test to verify it passes
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: both tests PASS.
Step 5: Commit
git add examples/panels/ai/drawai_beautify.py tests/panels/ai/test_drawai_helpers.py
git commit -m "feat(drawai): extract_xml_from_drawio_png helper + tests"
Task 4: TDD embed_xml_into_drawio_png¶
Files:
Modify:
examples/panels/ai/drawai_beautify.pyModify:
tests/panels/ai/test_drawai_helpers.pyStep 1: Write the failing test
Append to tests/panels/ai/test_drawai_helpers.py:
from examples.panels.ai.drawai_beautify import embed_xml_into_drawio_png # noqa: E402
def test_embed_then_extract_roundtrip():
original = (FIXTURES / "diagram.drawio.png").read_bytes()
new_xml = "<mxfile><diagram id='new'>modified</diagram></mxfile>"
out_bytes = embed_xml_into_drawio_png(original, new_xml)
assert extract_xml_from_drawio_png(out_bytes) == new_xml
def test_embed_produces_valid_png():
original = (FIXTURES / "diagram.drawio.png").read_bytes()
out_bytes = embed_xml_into_drawio_png(original, "<mxfile/>")
assert out_bytes[:8] == b"\x89PNG\r\n\x1a\n"
Step 2: Run tests to verify the new ones fail
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: test_embed_then_extract_roundtrip and test_embed_produces_valid_png FAIL with ImportError.
Step 3: Add the helper
Append to examples/panels/ai/drawai_beautify.py (after extract_xml_from_drawio_png and before any other code):
from PIL import PngImagePlugin # noqa: E402
def embed_xml_into_drawio_png(original: bytes, new_xml: str) -> bytes:
"""Return a new PNG identical to ``original`` except the ``mxfile``
tEXt chunk is replaced with ``new_xml``.
"""
with Image.open(BytesIO(original)) as img:
img.load()
meta = PngImagePlugin.PngInfo()
meta.add_text("mxfile", new_xml)
out = BytesIO()
img.save(out, "PNG", pnginfo=meta)
return out.getvalue()
Step 4: Run tests to verify they pass
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: all four tests PASS.
Step 5: Commit
git add examples/panels/ai/drawai_beautify.py tests/panels/ai/test_drawai_helpers.py
git commit -m "feat(drawai): embed_xml_into_drawio_png helper + tests"
Task 5: TDD validate_drawio_xml¶
Files:
Modify:
examples/panels/ai/drawai_beautify.pyModify:
tests/panels/ai/test_drawai_helpers.pyStep 1: Write the failing test
Append to tests/panels/ai/test_drawai_helpers.py:
import xml.etree.ElementTree as ET # noqa: E402
from examples.panels.ai.drawai_beautify import validate_drawio_xml # noqa: E402
def test_validate_drawio_xml_passes_valid():
validate_drawio_xml("<mxfile><diagram/></mxfile>") # no exception
def test_validate_drawio_xml_raises_on_malformed():
malformed = (FIXTURES / "malformed.drawio").read_text()
with pytest.raises(ET.ParseError):
validate_drawio_xml(malformed)
Step 2: Run tests to verify new ones fail
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: test_validate_drawio_xml_* FAIL with ImportError.
Step 3: Add the helper
Append to examples/panels/ai/drawai_beautify.py:
import xml.etree.ElementTree as ET # noqa: E402
def validate_drawio_xml(xml: str) -> None:
"""Raise ``xml.etree.ElementTree.ParseError`` if ``xml`` is not parseable."""
ET.fromstring(xml)
Step 4: Run tests to verify they pass
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: all six tests PASS.
Step 5: Commit
git add examples/panels/ai/drawai_beautify.py tests/panels/ai/test_drawai_helpers.py
git commit -m "feat(drawai): validate_drawio_xml helper + tests"
Task 6: TDD make_viewer_html¶
Files:
Modify:
examples/panels/ai/drawai_beautify.pyModify:
tests/panels/ai/test_drawai_helpers.pyStep 1: Write the failing test
Append to tests/panels/ai/test_drawai_helpers.py:
from examples.panels.ai.drawai_beautify import make_viewer_html # noqa: E402
def test_make_viewer_html_returns_iframe_with_encoded_xml():
html = make_viewer_html("<mxfile/>")
assert "<iframe" in html
assert "viewer.diagrams.net" in html
# URL-encoded "<mxfile/>"
assert "%3Cmxfile%2F%3E" in html
def test_make_viewer_html_empty_xml_returns_empty_iframe_src():
html = make_viewer_html("")
assert "<iframe" in html
assert "#R" in html # fragment present but empty
Step 2: Run tests to verify new ones fail
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: new tests FAIL with ImportError.
Step 3: Add the helper
Append to examples/panels/ai/drawai_beautify.py:
import urllib.parse # noqa: E402
def make_viewer_html(xml: str) -> str:
"""Return an iframe HTML snippet rendering ``xml`` via the drawio web viewer.
The XML is URL-encoded into the URL fragment (``#R<encoded>``).
"""
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>'
Step 4: Run tests to verify they pass
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: all eight tests PASS.
Step 5: Commit
git add examples/panels/ai/drawai_beautify.py tests/panels/ai/test_drawai_helpers.py
git commit -m "feat(drawai): make_viewer_html helper + tests"
Task 7: TDD DrawAiState param model¶
Files:
Modify:
examples/panels/ai/drawai_beautify.pyModify:
tests/panels/ai/test_drawai_helpers.pyStep 1: Write the failing test
Append to tests/panels/ai/test_drawai_helpers.py:
from examples.panels.ai.drawai_beautify import DrawAiState # noqa: E402
def test_drawai_state_defaults():
state = DrawAiState()
assert state.current_bytes == b""
assert state.current_xml == ""
assert state.current_format is None
assert state.current_filename == ""
assert state.beautified_xml == ""
def test_drawai_state_beautified_xml_triggers_watcher():
state = DrawAiState()
seen = []
state.param.watch(lambda e: seen.append(e.new), "beautified_xml")
state.beautified_xml = "<mxfile/>"
assert seen == ["<mxfile/>"]
def test_drawai_state_format_selector_rejects_invalid():
state = DrawAiState()
with pytest.raises(ValueError):
state.current_format = "bmp"
Step 2: Run tests to verify new ones fail
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: new tests FAIL with ImportError.
Step 3: Add the state class
Append to examples/panels/ai/drawai_beautify.py:
import param # noqa: E402
class DrawAiState(param.Parameterized):
"""Reactive state shared by the upload handler, the beautify tool,
and the compare-column widgets.
"""
current_bytes = param.Bytes(default=b"")
current_xml = param.String(default="")
current_format = param.Selector(objects=["png", "drawio", None], default=None)
current_filename = param.String(default="")
beautified_xml = param.String(default="")
Step 4: Run tests to verify they pass
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: all eleven tests PASS.
Step 5: Commit
git add examples/panels/ai/drawai_beautify.py tests/panels/ai/test_drawai_helpers.py
git commit -m "feat(drawai): DrawAiState param model + tests"
Task 8: TDD BeautifyDrawioTool — happy path¶
Files:
Modify:
examples/panels/ai/drawai_beautify.pyModify:
tests/panels/ai/test_drawai_helpers.pyStep 1: Write the failing test
Append to tests/panels/ai/test_drawai_helpers.py:
from unittest.mock import AsyncMock, MagicMock # noqa: E402
from examples.panels.ai.drawai_beautify import BeautifyDrawioTool # noqa: E402
def _make_mock_anthropic_client(text: str) -> MagicMock:
"""Build a mock ``anthropic.AsyncAnthropic`` instance whose
``messages.create`` returns one text block with the given content.
"""
block = MagicMock()
block.text = text
response = MagicMock()
response.content = [block]
client = MagicMock()
client.messages = MagicMock()
client.messages.create = AsyncMock(return_value=response)
return client
@pytest.mark.asyncio
async def test_beautify_drawio_tool_updates_state_on_success(monkeypatch):
state = DrawAiState(current_xml="<mxfile><diagram/></mxfile>")
canned = "<mxfile><diagram id='new'/></mxfile>"
mock_client = _make_mock_anthropic_client(canned)
monkeypatch.setattr(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
lambda **kwargs: mock_client,
)
tool = BeautifyDrawioTool(state=state, api_key="test-key", base_url="https://localhost")
result = await tool._arun(intent="tighter spacing")
assert "Beautified" in result
assert state.beautified_xml == canned
mock_client.messages.create.assert_awaited_once()
Step 2: Ensure
pytest-asynciois available
The project uses pytest and langchain async tests already exist (test_backend.py — check it uses @pytest.mark.asyncio). Run:
uv run python -c "import pytest_asyncio; print(pytest_asyncio.__version__)"
If this fails with ImportError, add pytest-asyncio to the dev group: open pyproject.toml, find the dev = [ ... ] list under [dependency-groups], and append "pytest-asyncio>=0.23". Then run uv sync --group dev.
If it succeeds, no action needed.
Step 3: Run test to verify it fails
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: test_beautify_drawio_tool_updates_state_on_success FAILS with ImportError (BeautifyDrawioTool not defined).
Step 4: Add the tool
Append to examples/panels/ai/drawai_beautify.py:
import anthropic # noqa: E402
from langchain_core.tools import BaseTool # noqa: E402
from pydantic import BaseModel, Field # noqa: E402
class BeautifyDrawioInput(BaseModel):
"""Input schema for ``BeautifyDrawioTool``."""
intent: str = Field(
description=(
"Free-form description of how the user wants the currently loaded "
"drawio diagram beautified (e.g. 'tighter spacing', 'align on grid', "
"'recolor to blue theme')."
)
)
_BEAUTIFY_SYSTEM_PROMPT = (
"You beautify drawio XML. Output valid drawio XML only, no prose, "
"no code fences. Preserve node IDs where possible so diffs stay meaningful."
)
def _strip_fences(text: str) -> str:
"""Remove ```...``` code fences if the model wrapped its output anyway."""
stripped = text.strip()
if stripped.startswith("```"):
lines = stripped.splitlines()
# drop first and last fence line
lines = lines[1:-1] if len(lines) >= 2 else lines
stripped = "\n".join(lines)
return stripped.strip()
class BeautifyDrawioTool(BaseTool):
"""Beautify the currently loaded drawio diagram using Claude Opus 4.7.
Reads XML from ``state.current_xml``, calls the ``anthropic`` SDK directly
(with prompt caching on the system prompt and the XML block), validates
the returned XML, and writes ``state.beautified_xml``.
Credentials (``api_key``, ``base_url``) come from the ``anthropic`` provider
block in ``config.yml`` — the same source the existing ``AiChat`` backend
reads. This keeps DrawAI consistent with whatever endpoint / key the rest
of the app is already using (official API, a proxy, an internal gateway).
"""
name: str = "beautify_drawio"
description: str = (
"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: type[BaseModel] = BeautifyDrawioInput
state: DrawAiState
api_key: str = ""
base_url: str = ""
model_name: str = "claude-opus-4-7"
model_config = {"arbitrary_types_allowed": True}
def _run(self, intent: str) -> str:
raise NotImplementedError("Use the async _arun (this tool is async-only).")
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."
try:
client_kwargs: dict[str, str] = {"api_key": self.api_key}
if self.base_url:
client_kwargs["base_url"] = self.base_url
client = anthropic.AsyncAnthropic(**client_kwargs)
resp = await client.messages.create(
model=self.model_name,
max_tokens=8192,
system=[
{
"type": "text",
"text": _BEAUTIFY_SYSTEM_PROMPT,
"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 Exception as e: # noqa: BLE001 - surface all SDK errors (auth, rate limit, network) to the chat model
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."
Step 5: Run test to verify it passes
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: all twelve tests PASS.
Step 6: Commit
git add examples/panels/ai/drawai_beautify.py tests/panels/ai/test_drawai_helpers.py pyproject.toml uv.lock
git commit -m "feat(drawai): BeautifyDrawioTool happy path + test"
Task 9: TDD BeautifyDrawioTool — error paths¶
Files:
Modify:
tests/panels/ai/test_drawai_helpers.py
No production changes — the error paths are already implemented in Task 8. This task adds regression tests for them.
Step 1: Write the failing tests
Append to tests/panels/ai/test_drawai_helpers.py:
@pytest.mark.asyncio
async def test_beautify_drawio_tool_no_file_loaded(monkeypatch):
state = DrawAiState() # current_xml is ""
# anthropic client should never be called
mock_client = _make_mock_anthropic_client("<mxfile/>")
monkeypatch.setattr(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
lambda **kwargs: mock_client,
)
tool = BeautifyDrawioTool(state=state, api_key="test-key")
result = await tool._arun(intent="whatever")
assert "No file loaded" in result
assert state.beautified_xml == ""
mock_client.messages.create.assert_not_awaited()
@pytest.mark.asyncio
async def test_beautify_drawio_tool_invalid_xml_response(monkeypatch):
state = DrawAiState(current_xml="<mxfile/>")
mock_client = _make_mock_anthropic_client("not valid xml at all")
monkeypatch.setattr(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
lambda **kwargs: mock_client,
)
tool = BeautifyDrawioTool(state=state, api_key="test-key")
result = await tool._arun(intent="fix it")
assert "did not parse as XML" in result
assert state.beautified_xml == "" # unchanged
@pytest.mark.asyncio
async def test_beautify_drawio_tool_anthropic_error(monkeypatch):
state = DrawAiState(current_xml="<mxfile/>")
failing_client = MagicMock()
failing_client.messages = MagicMock()
failing_client.messages.create = AsyncMock(side_effect=RuntimeError("rate limit exceeded"))
monkeypatch.setattr(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
lambda **kwargs: failing_client,
)
tool = BeautifyDrawioTool(state=state, api_key="test-key")
result = await tool._arun(intent="fix it")
assert "Anthropic API error" in result
assert "rate limit exceeded" in result
assert state.beautified_xml == ""
@pytest.mark.asyncio
async def test_beautify_drawio_tool_strips_code_fences(monkeypatch):
state = DrawAiState(current_xml="<mxfile/>")
canned = "```xml\n<mxfile><diagram id='x'/></mxfile>\n```"
mock_client = _make_mock_anthropic_client(canned)
monkeypatch.setattr(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
lambda **kwargs: mock_client,
)
tool = BeautifyDrawioTool(state=state, api_key="test-key")
await tool._arun(intent="whatever")
assert state.beautified_xml == "<mxfile><diagram id='x'/></mxfile>"
@pytest.mark.asyncio
async def test_beautify_drawio_tool_passes_credentials(monkeypatch):
"""Tool forwards api_key and base_url from config into AsyncAnthropic."""
state = DrawAiState(current_xml="<mxfile/>")
mock_client = _make_mock_anthropic_client("<mxfile><diagram id='x'/></mxfile>")
captured_kwargs: dict = {}
def _capture(**kwargs):
captured_kwargs.update(kwargs)
return mock_client
monkeypatch.setattr(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
_capture,
)
tool = BeautifyDrawioTool(
state=state,
api_key="my-key",
base_url="https://proxy.example.com",
)
await tool._arun(intent="whatever")
assert captured_kwargs == {"api_key": "my-key", "base_url": "https://proxy.example.com"}
Step 2: Run tests
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: all seventeen tests PASS.
Step 3: Commit
git add tests/panels/ai/test_drawai_helpers.py
git commit -m "test(drawai): regression tests for BeautifyDrawioTool error paths"
Task 11: Add mock_anthropic_sdk fixture for UI tests¶
Files:
Modify:
tests/panels/ai/examples/conftest.pyStep 1: Add the fixture
Open tests/panels/ai/examples/conftest.py and append:
@pytest.fixture(scope="module")
def mock_anthropic_sdk():
"""Patch the Anthropic SDK + the config lookup used by drawai_beautify.
Returns a tuple of (config_patch, anthropic_patch, canned_xml). The UI test
applies both patches under ``with`` before reloading the example module.
The config patch supplies an ``anthropic`` provider (the drawai example
looks it up by key in its ``_anthropic_credentials_from_config`` helper).
This is separate from ``mock_langchain``'s patch, which targets the
backend's ``load_config`` reference with a generic "test" provider.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from panelini.panels.ai.utils.config import AppConfig, ModelConfig, ProviderConfig
canned_xml = "<mxfile><diagram id='beautified'/></mxfile>"
block = MagicMock()
block.text = canned_xml
response = MagicMock()
response.content = [block]
anthropic_client = MagicMock()
anthropic_client.messages = MagicMock()
anthropic_client.messages.create = AsyncMock(return_value=response)
fake_provider = ProviderConfig(
key="anthropic",
display_name="Anthropic",
client_type="anthropic",
env_vars={"api_key": "fake-key", "endpoint": "https://localhost"},
models=(ModelConfig(name="Claude Opus 4.7", value="anthropic/claude-opus-4-7"),),
)
fake_config = AppConfig(providers={"anthropic": fake_provider})
config_patch = patch(
"examples.panels.ai.drawai_beautify.load_config",
return_value=fake_config,
)
anthropic_patch = patch(
"examples.panels.ai.drawai_beautify.anthropic.AsyncAnthropic",
return_value=anthropic_client,
)
return config_patch, anthropic_patch, canned_xml
At the top of the same file, ensure the imports include pytest (already present) — no new imports needed.
Step 2: Verify existing UI example tests still pass (no change expected)
Run:
uv run pytest tests/panels/ai/examples/ -v -m ui
Expected: existing UI tests pass (or skip cleanly if playwright is missing — in which case you can skip this step).
Step 3: Commit
git add tests/panels/ai/examples/conftest.py
git commit -m "test(drawai): add mock_anthropic_sdk fixture"
Task 12: Playwright UI tests¶
Files:
Create:
tests/panels/ai/examples/test_drawai_ui.pyStep 1: Write the UI tests
Create tests/panels/ai/examples/test_drawai_ui.py:
"""Playwright UI tests for examples/panels/ai/drawai_beautify.py."""
from __future__ import annotations
import importlib
import time
from pathlib import Path
import panel as pn
import pytest
from playwright.sync_api import Page
pytest.importorskip("anthropic")
pytest.importorskip("PIL")
_PORT = 6330
_FIXTURES = Path(__file__).parent.parent / "fixtures" / "drawai"
@pytest.fixture(scope="module")
def panel_server(mock_langchain, mock_anthropic_sdk):
"""Serve the drawai example with LangChain + Anthropic SDK + config mocked."""
lc1, lc2 = mock_langchain
cfg_patch, anth_patch, _canned = mock_anthropic_sdk
with lc1, lc2, cfg_patch, anth_patch:
module = importlib.reload(importlib.import_module("examples.panels.ai.drawai_beautify"))
app = module.build_app() # build_app is NOT called at module level anymore
server = pn.serve(app.servable(), port=_PORT, threaded=True, show=False)
time.sleep(0.5)
yield server, _PORT, module
server.stop()
@pytest.fixture(scope="module")
def ready_page(browser, panel_server):
_, port, _ = panel_server
context = browser.new_context()
page = context.new_page()
page.goto(f"http://localhost:{port}")
page.locator("text=Original").first.wait_for()
yield page
page.goto("about:blank")
context.close()
def test_drawai_renders_layout(ready_page: Page):
"""Chat card + Original + Beautified labels are all visible."""
page = ready_page
assert page.locator("text=Chat").first.is_visible()
assert page.locator("text=Original").first.is_visible()
assert page.locator("text=Beautified").first.is_visible()
# Download button disabled until a beautification happens
download_btn = page.locator("button", has_text="Download beautified").first
assert download_btn.is_disabled()
def test_drawai_upload_valid_png_renders_top_pane(ready_page: Page):
"""Uploading diagram.drawio.png shows it in the top pane as an <img>."""
page = ready_page
file_input = page.locator("input[type=file]").first
file_input.set_input_files(str(_FIXTURES / "diagram.drawio.png"))
# Wait for the reactive update: top pane should contain an <img> data URL
page.locator("img[src^='data:image/png;base64']").first.wait_for(timeout=5000)
# And no error alert
assert not page.locator(".alert-danger").first.is_visible()
def test_drawai_upload_corrupt_png_shows_alert(ready_page: Page):
"""Uploading corrupt.drawio.png surfaces the error alert, leaves state intact."""
page = ready_page
file_input = page.locator("input[type=file]").first
file_input.set_input_files(str(_FIXTURES / "corrupt.drawio.png"))
page.locator("text=Could not read file").first.wait_for(timeout=5000)
assert page.locator("text=mxfile").first.is_visible()
Step 2: Run UI tests
Run:
uv run pytest tests/panels/ai/examples/test_drawai_ui.py -v -m ui
Expected: three tests PASS. Combined runtime should be well under 10 seconds.
If Playwright complains about a missing browser, install it:
uv run playwright install chromium
and re-run.
Step 3: Run the full test suite to check for regressions
Run:
uv run pytest tests/ -v
Expected: all tests PASS (or skip cleanly when their markers are filtered out).
Step 4: Commit
git add tests/panels/ai/examples/test_drawai_ui.py
git commit -m "test(drawai): Playwright UI tests for layout + upload paths"
Task 13: README entry¶
Files:
Modify or create:
examples/panels/ai/README.md(if missing, checkexamples/README.mdfor the canonical location)Step 1: Find or create the examples README
Run:
ls examples/panels/ai/README.md examples/README.md 2>/dev/null || true
If neither exists, create examples/panels/ai/README.md with a minimal frame:
# AI panel examples
- `chat_min.py` — minimal AI chat inside Panelini.
- `chat_custom_tool.py` — AI chat wired with a custom in-memory storage tool.
- `chat_multi_tab.py` — multiple AI chats in separate tabs, with config switching.
Step 2: Add the DrawAI section
Append to the chosen README file:
## DrawAI — AI-assisted drawio beautifier
`drawai_beautify.py` is a self-contained example that:
1. Accepts a `.drawio` or `.drawio.png` upload.
2. Lets the user describe the desired beautification in chat.
3. Calls Claude Opus 4.7 (via the `anthropic` SDK, with prompt caching) inside
a LangChain tool to return new drawio XML.
4. Renders a before/after visual comparison using the drawio web viewer
(`viewer.diagrams.net`) embedded in an iframe.
5. Lets the user download the beautified file — original pixels reused, new
XML embedded in the PNG `tEXt` chunk (for `.drawio.png` inputs) or raw
XML bytes (for `.drawio` inputs).
### Install
```bash
pip install "panelini[ai,ai-drawio]"
export ANTHROPIC_API_KEY=sk-...
Run¶
python examples/panels/ai/drawai_beautify.py
Open the URL it prints, upload a drawio file, type a beautification intent (e.g. “make spacing tighter and align nodes on a grid”), and watch the bottom pane update. Click Download beautified to save.
Test fixtures¶
The repo includes test fixtures at
tests/panels/ai/fixtures/drawai/ (a minimal 1-node diagram) generated by
_make_fixtures.py. For visual demos, the local/fig*.drawio.png files
work well too.
Known trade-offs¶
The downloaded
.drawio.pngreuses the original pixels with the new XML embedded — drawio opens it correctly, but file-manager thumbnails will still show the old pixels. Re-rasterization would require a drawio CLI dependency, deliberately out of scope for v1..drawio.svginput/output is not supported in v1.
- [ ] **Step 3: Commit**
```bash
git add examples/panels/ai/README.md examples/README.md 2>/dev/null
git commit -m "docs(drawai): add README section for drawai_beautify example"
Task 14: Final verification¶
Files: (none modified)
Step 1: Run the full unit test file
Run:
uv run pytest tests/panels/ai/test_drawai_helpers.py -v
Expected: 17 PASSED.
Step 2: Run the UI tests
Run:
uv run pytest tests/panels/ai/examples/test_drawai_ui.py -v -m ui
Expected: 3 PASSED.
Step 3: Confirm no core
src/panelini/panels/ai/changes
Run:
git diff --stat main -- src/panelini/panels/ai/
Expected: no output (no files listed) — core panel code is untouched.
Step 4: Confirm the commit graph looks clean
Run:
git log --oneline main..HEAD
Expected: commits from tasks 1–13, in order. No fixup commits, no reverts.
Step 5: Done — ready for review.
Open follow-ups (explicitly out of scope for this plan)¶
Promote to a real panel module at
src/panelini/panels/drawai/..drawio.svgsupport.Drag-and-drop upload.
Preset intent buttons (
polish,realign,recolor, …).Optional third “XML diff” pane.
Drawio CLI re-rasterization so downloaded PNG pixels match the new XML.
postMessage-based viewer integration (removes URL-length cap on huge diagrams).Sphinx docs page in
docs/panels/reusing the layout diagram from the spec.