feat: pura linea

This commit is contained in:
Timothy
2026-05-01 14:57:06 -07:00
parent b939a875a7
commit 6540f7b31e
14 changed files with 3706 additions and 1 deletions
@@ -1,3 +1,3 @@
{ {
"include": ["gcu-tools", "hive_tools", "terminal-tools"] "include": ["gcu-tools", "hive_tools", "terminal-tools", "chart-tools"]
} }
@@ -150,6 +150,11 @@ _TOOL_CATEGORIES: dict[str, list[str]] = {
"get_current_time", "get_current_time",
"get_account_info", "get_account_info",
], ],
# BI / financial chart + diagram rendering. Calling chart_render
# both embeds the chart live in chat and produces a downloadable PNG.
"charts": [
"@server:chart-tools",
],
} }
@@ -178,6 +183,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
"browser_interaction", "browser_interaction",
"research", "research",
"time_context", "time_context",
"charts",
], ],
# Head of Growth — data, experiments, competitor research; no security. # Head of Growth — data, experiments, competitor research; no security.
"queen_growth": [ "queen_growth": [
@@ -187,6 +193,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
"browser_interaction", "browser_interaction",
"research", "research",
"time_context", "time_context",
"charts",
], ],
# Head of Product Strategy — user research + roadmaps; no security. # Head of Product Strategy — user research + roadmaps; no security.
"queen_product_strategy": [ "queen_product_strategy": [
@@ -196,6 +203,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
"browser_interaction", "browser_interaction",
"research", "research",
"time_context", "time_context",
"charts",
], ],
# Head of Finance — financial models (CSV/Excel heavy), market research. # Head of Finance — financial models (CSV/Excel heavy), market research.
"queen_finance_fundraising": [ "queen_finance_fundraising": [
@@ -206,6 +214,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
"browser_interaction", "browser_interaction",
"research", "research",
"time_context", "time_context",
"charts",
], ],
# Head of Legal — reads contracts/PDFs, researches; no data/security. # Head of Legal — reads contracts/PDFs, researches; no data/security.
"queen_legal": [ "queen_legal": [
@@ -243,6 +252,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
"browser_basic", "browser_basic",
"browser_interaction", "browser_interaction",
"time_context", "time_context",
"charts",
], ],
} }
+4
View File
@@ -55,6 +55,10 @@ _DEFAULT_LOCAL_SERVERS: dict[str, dict[str, Any]] = {
"description": "Terminal capabilities: process exec, background jobs, PTY sessions, fs search. Bash-only on POSIX.", "description": "Terminal capabilities: process exec, background jobs, PTY sessions, fs search. Bash-only on POSIX.",
"args": ["run", "python", "terminal_tools_server.py", "--stdio"], "args": ["run", "python", "terminal_tools_server.py", "--stdio"],
}, },
"chart-tools": {
"description": "BI/financial chart + diagram rendering: ECharts, Mermaid. Returns spec + downloadable PNG; chat embeds live.",
"args": ["run", "python", "chart_tools_server.py", "--stdio"],
},
} }
# Aliases that earlier versions of ensure_defaults wrote under the wrong name. # Aliases that earlier versions of ensure_defaults wrote under the wrong name.
@@ -0,0 +1,160 @@
---
name: hive.chart-creation-foundations
description: Required reading whenever any chart_* tool is available. Teaches the one-tool embedding contract (call chart_render → live chart appears in chat AND a downloadable PNG lands in the queen session dir), the ECharts (data viz) vs Mermaid (structural diagrams) decision, the BI/financial-grade aesthetic baseline (no chartjunk, restrained palette, proper typography, single message per chart), and the canonical spec patterns for the 12 most-common chart types. Skipping this leads to 1990s-Excel charts, missing downloads, and the agent writing markdown image links by hand instead of letting chart_render drive the UI.
metadata:
author: hive
type: preset-skill
version: "1.0"
---
# Chart creation foundations
These tools render BI/financial-analyst-grade charts and diagrams that show up live in the chat AND save as high-DPI PNGs in the user's queen session dir.
## The embedding contract — one rule
> **To put a chart in chat, call `chart_render`. The chat reads `result.spec` and renders the chart live in the message bubble. The download link is `result.file_url`. Do not write `![chart](...)` image markdown by hand — the tool's result drives the UI.**
That's it. One tool call, one chart in chat, one file on disk. No two-step "remember to also save it" pattern. The chat's chart-rendering UI is fed by the tool result envelope automatically.
## When to chart at all
Chart when the data is **visual at heart**: trends over time, distributions, comparisons across categories, hierarchies, flows, geo. Skip the chart when:
- The point is one number → just say it. ("Revenue was $4.2M, up 12% YoY.")
- The point is a ranking of 5 things → use a markdown table with bold and emoji indicators.
- The data is so noisy a chart would mislead → describe the takeaway in prose.
A chart costs the user attention. It must repay that cost with a takeaway they couldn't get from prose.
## ECharts vs Mermaid — the picking rule
| Use ECharts (`kind: "echarts"`) when... | Use Mermaid (`kind: "mermaid"`) when... |
|---|---|
| You're plotting **numbers over categories or time** | You're showing **structure, not data** |
| Bar / line / area / scatter / candlestick / heatmap / treemap / sankey / parallel coordinates / calendar / gauge / pie / sunburst / geo map | Flowchart / sequence / gantt / ERD / state diagram / mindmap / class diagram / C4 architecture |
| The viewer's question is "how much / how many / what's the trend" | The viewer's question is "what calls what / what depends on what / what happens after what" |
If both fit (rare), prefer ECharts — its rasterized output is a proper data chart for slides; Mermaid's diagrams are for technical docs.
## The aesthetic baseline (non-negotiable)
These are the rules that turn an Excel-default chart into a Tableau-grade one. Every chart you produce must follow them.
### 1. Theme & background
- `chart_render` has **no `theme` parameter**. The renderer reads the user's UI theme from the desktop env (`HIVE_DESKTOP_THEME`) so the saved PNG matches what the user is actually looking at. You don't pick; the system does.
- Title goes in `option.title.text`, NOT in the message body. The chart is self-contained.
### 2. Palette discipline — DO NOT set `color` on series
The OpenHive ECharts theme is auto-applied to every `chart_render` call. It defines:
- An 8-hue **categorical palette** for multi-series charts (honey orange, slate blue, sage, terracotta, bronze, indigo, olive, rust)
- Cozy spacing (`grid.top: 90`, `grid.bottom: 56`, etc.)
- Brand typography (Inter Tight)
- Tasteful axis lines + dashed gridlines
**Do not set `option.color`, `option.title.textStyle`, `option.grid`, or `option.itemStyle.color` on series.** The theme covers it. If you do override, you'll fight the brand palette and the chart will look generic.
When you need data-encoded color (NOT category color):
- **Sequential** (magnitude): use `visualMap` with `inRange.color: ['#fff7e0', '#db6f02']` (light-to-honey)
- **Diverging** (positive/negative): use `visualMap` with `inRange.color: ['#a8453d', '#f5f5f5', '#3d7a4a']` (terracotta/neutral/sage)
- **Semantic up/down** (candlestick is auto-themed): for explicit gain/loss bars use `#3d7a4a` (gain) and `#a8453d` (loss), NOT `#27ae60` / `#e74c3c`.
### 3. Typography
The default font (`-apple-system, "Inter Tight", system-ui`) is already wired in the renderer — don't override unless the user asked. Set `option.textStyle.fontSize: 13` for body labels, `16` for axis names, `18` bold for the title.
### 4. No chartjunk
- **No 3D**. Ever. 3D pie charts and 3D bar charts are visual lies.
- **No drop shadows** on bars or lines. The default flat ECharts look is correct.
- **No gradient fills** unless the gradient encodes data (e.g. heatmap fill).
- **No neon colors**. Saturation belongs on highlighted bars, not on every series.
- **No more than 5 stacked colors** in a stacked bar — past that the eye can't separate them.
### 5. Axis hygiene
- X-axis labels rotate 45° only when they overflow. Otherwise horizontal.
- Y-axis starts at 0 for bar/area charts (truncating misleads). Line charts can start at min - 5%.
- Use `option.yAxis.axisLabel.formatter: '{value} M'` to add units, NOT a separate "USD millions" subtitle.
- Date axes: pass ISO strings (`"2024-01-15"`) and ECharts handles the layout. Use `xAxis.type: "time"`.
### 6. One message per chart
Every chart goes in its own assistant message (or its own `chart_render` call). Do not pile 4 charts into one wall of tool calls — the user can't focus and the chat gets noisy.
## Calling `chart_render` — the canonical pattern
```
chart_render(
kind="echarts",
spec={
"title": {"text": "Q4 revenue by region", "left": "center"},
"tooltip": {"trigger": "axis"},
"xAxis": {"type": "category", "data": ["NA", "EU", "APAC", "LATAM"]},
"yAxis": {"type": "value", "axisLabel": {"formatter": "${value}M"}},
"series": [{"type": "bar", "data": [12.4, 8.7, 5.3, 2.1], "itemStyle": {"color": "#db6f02"}}]
},
title="q4-revenue-by-region",
width=1600, height=900, dpi=300
)
```
Returns:
```
{
"kind": "echarts",
"spec": {...echoed...},
"file_path": "/.../charts/2026-04-30T...q4-revenue-by-region.png",
"file_url": "file:///.../q4-revenue-by-region.png",
"width": 1600, "height": 900, "dpi": 300, "bytes": 142318,
"title": "q4-revenue-by-region", "runtime_ms": 287
}
```
The chat panel reads `result.spec` and mounts ECharts in the message bubble. The user sees the chart immediately. The PNG is on disk and the chat shows a download link from `result.file_url`. **You don't write that link — it appears automatically.**
## The 12 chart types you'll use 95% of the time
| When | ECharts type | Notes |
|---|---|---|
| Trend over time | `series.type: "line"` | Smooth = `smooth: true` only when data is noisy |
| Multi-metric trend | Two `line` series with `yAxis: [{}, {}]` | Separate scales when units differ |
| Category comparison | `series.type: "bar"` | Sort by value descending, not alphabetically |
| Stacked composition | `bar` with `stack: "total"` | Cap at 5 categories |
| Distribution | `series.type: "boxplot"` or `bar` of bins | Boxplot for ≥3 groups; histogram for one |
| Two-variable correlation | `series.type: "scatter"` | Add `regression` markline if relevant |
| Candlestick / OHLC | `series.type: "candlestick"` | Date axis + `dataZoom` range slider |
| Geo distribution | `series.type: "map"` | Bundled `world` and country GeoJSONs |
| Hierarchy / share | `series.type: "treemap"` or `sunburst` | Use treemap for >12 leaves; pie only for 2-5 |
| Flow | `series.type: "sankey"` | Names matter — keep them short |
| Calendar density | `series.type: "heatmap"` + `calendar` | Daily metrics over a year |
| KPI scorecard | `series.type: "gauge"` | Set `min`, `max`, threshold band |
Worked specs for each are in `references/` — paste, modify, render.
## Mermaid quick rules
```
chart_render(
kind="mermaid",
spec="""
flowchart LR
A[Customer signs up] --> B{Onboarded?}
B -- yes --> C[Activate trial]
B -- no --> D[Email reminder]
""",
title="signup-flow"
)
```
- One diagram per chart_render call.
- Keep node labels short (≤20 chars).
- Use `flowchart LR` for left-to-right; `TD` for top-down. LR reads better in a chat bubble.
- For sequence diagrams, indicate async with `->>` (open arrow) and sync return with `-->>` (dashed).
- Don't try to encode data in mermaid (no widths, no quantities) — that's an ECharts job.
## Common mistakes the agent makes
1. **Writing `![chart](file://...)` markdown by hand.** Don't. The chat renders from the tool result automatically. Manual image markdown will display nothing (file:// is blocked from arbitrary chat content).
2. **Calling chart_render twice for the same chart "to embed and to save".** Only one call. The single call does both.
3. **Overriding fonts to fancy display faces.** Stay with the default; the agent's job is data, not typography.
4. **Pie charts with 12 slices.** Use a horizontal bar chart sorted by value. Pie is only for 2-5 mutually-exclusive shares.
5. **Forgetting `axisLabel.formatter` for currency / percentage.** A y-axis showing "12000000" is unreadable; "12M" is correct.
6. **Putting a chart's title in the message body.** Set `option.title.text` instead so the title is part of the saved PNG.
+1
View File
@@ -34,6 +34,7 @@ _BUNDLED_DIRS: tuple[Path, ...] = (
_TOOL_GATED_SKILLS: list[tuple[str, str, str]] = [ _TOOL_GATED_SKILLS: list[tuple[str, str, str]] = [
("browser_", "browser-automation", "hive.browser-automation"), ("browser_", "browser-automation", "hive.browser-automation"),
("terminal_", "terminal-tools-foundations", "hive.terminal-tools-foundations"), ("terminal_", "terminal-tools-foundations", "hive.terminal-tools-foundations"),
("chart_", "chart-creation-foundations", "hive.chart-creation-foundations"),
] ]
_BODY_CACHE: dict[str, str] = {} _BODY_CACHE: dict[str, str] = {}
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""chart-tools MCP server entry point.
Wired into _DEFAULT_LOCAL_SERVERS in core/framework/loader/mcp_registry.py
so that running ``uv run python chart_tools_server.py --stdio`` from this
directory starts the server. The cwd of ``tools/`` puts ``src/chart_tools``
on the import path via uv's workspace setup.
"""
from __future__ import annotations
from chart_tools.server import main
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""chart-tools — MCP server that renders BI/financial-grade charts and
diagrams to PNG via headless Chromium.
Exposes a single tool: ``chart_render``. Calling it both produces a
downloadable PNG file and returns the spec back to the caller, so the
desktop chat can render the chart live in the message bubble (using the
same ECharts/Mermaid spec the server rendered).
Bash-only? No this is the cross-platform charting surface, complementary
to terminal-tools. Identical pipeline-integration shape: auto-seeded into
``_DEFAULT_LOCAL_SERVERS``, paired with a tool-gated foundational skill.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastmcp import FastMCP
def register_chart_tools(mcp: FastMCP) -> list[str]:
"""Register all chart-tools with the FastMCP server.
Returns the list of registered tool names so the caller can log /
smoke-test how many landed.
"""
from chart_tools.tools import register_tools
register_tools(mcp)
return [
name
for name in mcp._tool_manager._tools.keys()
if name.startswith("chart_")
]
__all__ = ["register_chart_tools"]
+341
View File
@@ -0,0 +1,341 @@
"""Playwright-driven chart renderer.
Why Playwright (and not pyecharts / matplotlib / a Node subprocess):
the desktop chat renders ECharts/Mermaid in a real browser. To
guarantee that the *downloaded* PNG looks pixel-equivalent to what
the user saw live in chat, we use the same engine Chromium to
do the server-side render. Playwright is already a runtime dep
(used by gcu-tools), so this adds zero new Python deps.
Design:
- One persistent Chromium browser per process. First chart spawns
it (~1.5s); subsequent charts reuse the same browser context for
~200-300ms total render time per chart.
- Each render gets a fresh ``Page`` (its own DOM, no leakage between
charts), but the underlying browser process is shared.
- The ``shutdown()`` function is wired into the FastMCP lifespan
hook so the browser dies cleanly on server exit.
- Static JS assets (``echarts.min.js``, ``mermaid.min.js``) are
bundled with chart-tools and loaded into the page via
``page.add_script_tag`` no network access at render time.
Concurrency: one browser-wide lock around ``render()`` so concurrent
agent calls don't race on the same page. Chrome handles parallel
pages fine internally, but the simpler serial model is plenty fast
for v1 (200-300ms per chart, <5 charts/turn typical).
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Static JS lives next to this module under ./static/. The files are
# downloaded at install time by ``scripts/fetch_chart_assets.py`` (see
# the chart-tools README). When missing we fall back to CDN URLs so dev
# environments aren't blocked on the install step.
_STATIC_DIR = Path(__file__).parent / "static"
_ECHARTS_JS = _STATIC_DIR / "echarts.min.js"
_MERMAID_JS = _STATIC_DIR / "mermaid.min.js"
_ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"
_MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11.4.0/dist/mermaid.min.js"
class RendererError(RuntimeError):
"""Raised when a chart fails to render (invalid spec, browser death, etc.)."""
class ChartRenderer:
"""One-per-process Chromium pool for chart rendering."""
def __init__(self):
self._browser = None
self._playwright = None
self._lock = asyncio.Lock()
self._closed = False
async def _ensure_browser(self) -> Any:
if self._browser is not None and self._browser.is_connected():
return self._browser
from playwright.async_api import async_playwright
if self._playwright is None:
self._playwright = await async_playwright().start()
# --no-sandbox is required when running as root inside containers;
# harmless on a desktop dev box and accepted by Playwright.
self._browser = await self._playwright.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"],
)
logger.info("chart-tools: launched headless Chromium")
return self._browser
async def shutdown(self) -> None:
if self._closed:
return
self._closed = True
try:
if self._browser is not None:
await self._browser.close()
except Exception as exc:
logger.warning("chart-tools: browser close error: %s", exc)
try:
if self._playwright is not None:
await self._playwright.stop()
except Exception as exc:
logger.warning("chart-tools: playwright stop error: %s", exc)
async def render(
self,
*,
kind: str,
spec: Any,
width: int,
height: int,
dpi: int,
theme: str,
) -> bytes:
"""Render a single chart and return PNG bytes.
``dpi`` controls the device-pixel-ratio of the screenshot (300 dpi
gives crisp output in print/slide decks). The on-screen logical
size remains ``width × height`` CSS pixels; the screenshot is
scaled to ``width * dpi/96 × height * dpi/96`` actual pixels.
"""
if kind not in ("echarts", "mermaid"):
raise RendererError(f"unknown kind {kind!r}; expected 'echarts' or 'mermaid'")
async with self._lock:
browser = await self._ensure_browser()
scale = max(1.0, dpi / 96.0)
context = await browser.new_context(
viewport={"width": width, "height": height},
device_scale_factor=scale,
)
try:
page = await context.new_page()
html = _build_html(kind=kind, spec=spec, width=width, height=height, theme=theme)
await page.set_content(html, wait_until="load")
# Inject the chart library. Prefer bundled static; fall
# back to CDN. Mermaid takes a beat to typeset; ECharts
# renders synchronously once setOption is called.
await _inject_lib(page, kind)
await _render_in_page(page, kind=kind, spec=spec, theme=theme, width=width, height=height)
# Make sure fonts are loaded before screenshotting so
# rotated text doesn't shift after the snapshot.
await page.wait_for_function("() => document.fonts.ready.then(() => true)")
# ECharts and Mermaid both animate by default. _render_in_page
# disables animations, but on the off chance they're still
# progressing we wait for the chart's 'finished' signal
# (set by _render_in_page on window.__chartReady).
# Without this, screenshots capture mid-animation frames
# where most data points haven't been drawn yet (the
# 2026-05-01 "all data points are gone" bug).
await page.wait_for_function(
"() => window.__chartReady === true",
timeout=10000,
)
target = page.locator("#chart")
png = await target.screenshot(type="png", omit_background=False)
return png
finally:
await context.close()
# ── module-level singleton ─────────────────────────────────────────
_INSTANCE: ChartRenderer | None = None
def get_renderer() -> ChartRenderer:
global _INSTANCE
if _INSTANCE is None:
_INSTANCE = ChartRenderer()
return _INSTANCE
# ── HTML / library injection ───────────────────────────────────────
def _build_html(*, kind: str, spec: Any, width: int, height: int, theme: str) -> str:
"""Build the HTML shell containing a sized #chart div.
Single-layer #chart at the full requested width × height — same
structure as the LIVE in-chat EChartsBlock, which the user
confirmed renders correctly. An earlier two-layer #wrap+#chart
design (with 24px outer padding) clipped rotated axis names like
"$ Billions" because ECharts rendered axis-name SVG outside the
inner #chart bounds and the wrapper padding cropped it (feedback
2026-05-01). Cozy spacing now comes purely from the ECharts theme
grid (top:130, left:56, right:56, bottom:80) agent doesn't
have to think about it; theme defaults handle it.
Background is solid (white / near-black) so the downloaded PNG
works on any embed surface (light slack, dark slack, slide deck).
"""
bg = "#ffffff" if theme == "light" else "#0e0e0d"
fg = "#1a1a1a" if theme == "light" else "#e8e6e0"
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body {{
margin: 0; padding: 0;
background: {bg};
color: {fg};
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}}
#chart {{
width: {width}px;
height: {height}px;
background: {bg};
}}
/* Mermaid centres its SVG inside the container by default */
.mermaid {{ display: flex; justify-content: center; align-items: center; }}
</style>
</head>
<body>
<div id="chart"></div>
</body>
</html>
"""
async def _inject_lib(page: Any, kind: str) -> None:
"""Add the appropriate chart library to the page.
Prefers bundled static; falls back to CDN. Errors here surface as
RendererError because nothing else will render without the library.
"""
if kind == "echarts":
target = _ECHARTS_JS if _ECHARTS_JS.exists() else None
cdn = _ECHARTS_CDN
else:
target = _MERMAID_JS if _MERMAID_JS.exists() else None
cdn = _MERMAID_CDN
try:
if target is not None:
await page.add_script_tag(path=str(target))
else:
logger.info("chart-tools: bundled %s missing, loading from CDN", kind)
await page.add_script_tag(url=cdn)
except Exception as exc:
raise RendererError(f"failed to load {kind} library: {exc}") from exc
async def _render_in_page(
page: Any,
*,
kind: str,
spec: Any,
theme: str,
width: int,
height: int,
) -> None:
"""Run the kind-specific render call inside the page's JS context."""
if kind == "echarts":
# Spec must be a JSON-serializable dict. NOTE: callers are
# expected to have already coerced JSON-string specs into dicts
# in chart_tools/tools.py — this is a defense-in-depth check.
if isinstance(spec, str):
raise RendererError(
"spec arrived as a string; it should have been parsed to a dict in chart_render"
)
try:
json.dumps(spec)
except (TypeError, ValueError) as exc:
raise RendererError(f"spec is not JSON-serializable: {exc}") from exc
# Register the OpenHive theme on the page, then init with that
# theme name. This means the agent's spec doesn't need to set
# color / textStyle / grid / axisLine — the brand defaults
# cover all of it, and any explicit field in the spec wins.
from chart_tools.theme import theme_json
result = await page.evaluate(
"""async ({option, themeJson}) => {
try {
echarts.registerTheme('openhive', JSON.parse(themeJson));
const el = document.getElementById('chart');
const chart = echarts.init(el, 'openhive', {renderer: 'svg'});
// Disable all animations for the SSR render. Without
// this the screenshot fires mid-animation and most
// data points are missing (the 2026-05-01 "all data
// points are gone" bug). We don't need animation in
// a static PNG anyway.
const sanitized = Object.assign({}, option, {
animation: false,
animationDuration: 0,
animationDurationUpdate: 0,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
});
// Signal "render complete" via window.__chartReady so
// the Python side knows when it's safe to screenshot.
// ECharts fires 'finished' once the layout pass has
// settled (axis labels measured, line paths laid out,
// bars positioned). With animation=false this is
// typically synchronous, but we still wait for the
// event to be safe.
window.__chartReady = false;
chart.on('finished', () => { window.__chartReady = true; });
chart.setOption(sanitized);
// Belt-and-braces: if 'finished' didn't fire (some
// edge cases with empty series), force-flag ready
// after the next animation frame so we don't hang.
requestAnimationFrame(() => {
requestAnimationFrame(() => { window.__chartReady = true; });
});
return {ok: true};
} catch (e) {
return {ok: false, error: String(e)};
}
}""",
{"option": spec, "themeJson": theme_json(theme)},
)
if not result.get("ok"):
raise RendererError(f"ECharts render failed: {result.get('error')}")
elif kind == "mermaid":
if not isinstance(spec, str):
raise RendererError("mermaid spec must be a string")
# mermaid.render returns a promise resolving to {svg, bindFunctions}.
# Insert the SVG into #chart, then signal ready.
result = await page.evaluate(
"""async ({source, theme}) => {
try {
if (typeof mermaid === 'undefined') {
return {ok: false, error: 'mermaid not loaded'};
}
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
});
window.__chartReady = false;
const out = await mermaid.render('mmd-' + Math.random().toString(36).slice(2), source);
document.getElementById('chart').innerHTML = out.svg;
window.__chartReady = true;
return {ok: true};
} catch (e) {
return {ok: false, error: String(e)};
}
}""",
{"source": spec, "theme": theme},
)
if not result.get("ok"):
raise RendererError(f"Mermaid render failed: {result.get('error')}")
__all__ = ["ChartRenderer", "RendererError", "get_renderer"]
+126
View File
@@ -0,0 +1,126 @@
"""chart-tools FastMCP server — entry module.
Run via:
uv run python -m chart_tools.server --stdio
uv run python chart_tools_server.py --stdio (preferred, see _DEFAULT_LOCAL_SERVERS)
"""
from __future__ import annotations
import argparse
import asyncio
import atexit
import logging
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
def setup_logger() -> None:
if not logger.handlers:
stream = sys.stderr if "--stdio" in sys.argv else sys.stdout
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("[chart-tools] %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
setup_logger()
# Suppress FastMCP banner in STDIO mode (mirrors gcu/server.py).
if "--stdio" in sys.argv:
import rich.console
_orig_console_init = rich.console.Console.__init__
def _patched_console_init(self, *args, **kwargs):
kwargs["file"] = sys.stderr
_orig_console_init(self, *args, **kwargs)
rich.console.Console.__init__ = _patched_console_init
from fastmcp import FastMCP # noqa: E402
from chart_tools import register_chart_tools # noqa: E402
from chart_tools.renderer import get_renderer # noqa: E402
@asynccontextmanager
async def _lifespan(_server: FastMCP) -> AsyncIterator[dict]:
"""Bring up the persistent Chromium on first start, tear down on exit."""
parent_pid_env = os.getenv("HIVE_DESKTOP_PARENT_PID")
if parent_pid_env:
try:
parent_pid = int(parent_pid_env)
asyncio.create_task(_parent_watchdog(parent_pid))
logger.info("Parent watchdog armed for PID %d", parent_pid)
except ValueError:
logger.warning("Invalid HIVE_DESKTOP_PARENT_PID=%r", parent_pid_env)
yield {}
logger.info("Shutting down Chromium...")
try:
await get_renderer().shutdown()
except Exception as exc:
logger.warning("Renderer shutdown error: %s", exc)
def _is_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
async def _parent_watchdog(parent_pid: int) -> None:
while True:
await asyncio.sleep(2.0)
if not _is_alive(parent_pid):
logger.warning("Parent PID %d gone — chart-tools exiting", parent_pid)
try:
await get_renderer().shutdown()
except Exception:
pass
os._exit(0)
def _atexit_cleanup() -> None:
"""Last-ditch cleanup if lifespan didn't run (SIGTERM, etc.)."""
try:
asyncio.run(get_renderer().shutdown())
except Exception:
pass
atexit.register(_atexit_cleanup)
mcp = FastMCP("chart-tools", lifespan=_lifespan)
def main() -> None:
parser = argparse.ArgumentParser(description="chart-tools MCP server")
parser.add_argument("--port", type=int, default=int(os.getenv("CHART_TOOLS_PORT", "4005")))
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--stdio", action="store_true")
args = parser.parse_args()
tools = register_chart_tools(mcp)
if not args.stdio:
logger.info("Registered %d chart-tools: %s", len(tools), tools)
if args.stdio:
mcp.run(transport="stdio")
else:
logger.info("Starting chart-tools on %s:%d", args.host, args.port)
asyncio.run(mcp.run_async(transport="http", host=args.host, port=args.port))
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+187
View File
@@ -0,0 +1,187 @@
"""OpenHive ECharts theme — brand palette + cozy spacing defaults.
Why this exists: ECharts ships with two built-in themes (default and 'dark')
that look generic-web-2010. The agent will reach for `#27ae60` / `#e74c3c`
and other hello-world hex codes unless we set sensible defaults at the
theme level. Centralizing the theme means BOTH the server-side renderer
(headless Chromium PNG) and the live in-chat ECharts mount use the
same palette and spacing pixel-equivalent output guaranteed.
The theme dict is shipped as a JSON string and loaded via
`echarts.registerTheme('openhive', themeObj)` before `echarts.init`.
"""
from __future__ import annotations
import json
# OpenHive brand palette — categorical, alternating warm/cool for
# adjacent-series distinguishability. Honey-amber primary follows our
# desktop app's `TOOL_HEX` palette; the cool counters (slate, sage,
# indigo) keep multi-series charts legible without resorting to neon.
_BRAND_PALETTE_LIGHT = [
"#db6f02", # honey orange (primary)
"#456a8d", # slate blue
"#3d7a4a", # sage green
"#a8453d", # terracotta brick
"#c48820", # warm bronze
"#5d5b88", # indigo
"#7d6b51", # olive
"#8e4200", # rust
]
# Dark theme variant: brighter saturations to maintain contrast against
# near-black backgrounds without becoming neon.
_BRAND_PALETTE_DARK = [
"#ffb825", # bright honey
"#7ba2c4", # cool slate
"#7bb285", # bright sage
"#d97470", # warm coral
"#e0a83a", # bright bronze
"#9892c4", # bright indigo
"#b8a685", # warm taupe
"#d97e3a", # bright rust
]
def build_theme(theme: str = "light") -> dict:
"""Return the OpenHive ECharts theme dict for the given mode.
Cozy by default: title sits 24px from the top, axis ticks have
breathing room, grid defaults give 80px above (for title+legend)
and 60px below (for x-axis labels). The agent can still override
everything via the spec this is the floor, not the ceiling.
"""
is_dark = theme == "dark"
fg = "#e8e6e0" if is_dark else "#1a1a1a"
fg_muted = "#8a8a8a" if is_dark else "#6b6b6b"
grid_line = "#2a2724" if is_dark else "#ebe9e2"
axis_line = "#3a3733" if is_dark else "#d0cfca"
tooltip_bg = "#181715" if is_dark else "#ffffff"
tooltip_border = "#2a2724" if is_dark else "#d0cfca"
palette = _BRAND_PALETTE_DARK if is_dark else _BRAND_PALETTE_LIGHT
return {
"color": palette,
"backgroundColor": "transparent",
"textStyle": {
"fontFamily": (
'"Inter Tight", -apple-system, BlinkMacSystemFont, '
'"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
),
"color": fg,
"fontSize": 12,
},
# Title sits cozy at the top with breathing room. Top:32 keeps
# the title clear of the canvas edge AND well separated from
# both the legend (top:72) and any axis name (which inherits
# the value-axis name padding below).
"title": {
"left": "center",
"top": 32,
"textStyle": {"color": fg, "fontSize": 18, "fontWeight": 600},
"subtextStyle": {"color": fg_muted, "fontSize": 13},
},
# Legend below the title with explicit gap so the two don't
# visually fight. Pill-shaped icons read as buttons not bullets.
"legend": {
"top": 72,
"icon": "roundRect",
"itemWidth": 12,
"itemHeight": 12,
"itemGap": 20,
"textStyle": {"color": fg_muted, "fontSize": 12},
},
# The plot area has *real* margins. top:130 leaves room for
# title (32) + title text (24) + gap (16) + legend (20) +
# legend-to-grid gap (18). bottom:80 fits two-line x-axis
# labels like "Q1 FY26\n(Apr '25)" plus an axis name.
# `containLabel: True` auto-shrinks the plot to fit axis labels.
"grid": {
"top": 130,
"left": 56,
"right": 56,
"bottom": 80,
"containLabel": True,
},
"categoryAxis": {
"axisLine": {"show": True, "lineStyle": {"color": axis_line}},
"axisTick": {"show": False},
"axisLabel": {"color": fg_muted, "fontSize": 11, "margin": 14},
"splitLine": {"show": False},
# Axis name on the side, not crammed into the corner.
"nameLocation": "middle",
"nameGap": 38,
"nameTextStyle": {"color": fg_muted, "fontSize": 12},
},
"valueAxis": {
"axisLine": {"show": False},
"axisTick": {"show": False},
"axisLabel": {"color": fg_muted, "fontSize": 11, "margin": 14},
"splitLine": {"lineStyle": {"color": grid_line, "type": "dashed"}},
# Y-axis name vertically-centered on the axis instead of
# floating in the upper-left corner where it competes with
# the title and legend.
"nameLocation": "middle",
"nameGap": 44,
"nameTextStyle": {"color": fg_muted, "fontSize": 12, "fontWeight": 500},
"nameRotate": 90,
},
# Same for log/time/etc. — keep the look consistent.
"logAxis": {
"axisLine": {"show": False},
"axisLabel": {"color": fg_muted, "fontSize": 11},
"splitLine": {"lineStyle": {"color": grid_line, "type": "dashed"}},
"nameLocation": "middle",
"nameGap": 44,
"nameTextStyle": {"color": fg_muted, "fontSize": 12, "fontWeight": 500},
"nameRotate": 90,
},
"timeAxis": {
"axisLine": {"show": True, "lineStyle": {"color": axis_line}},
"axisLabel": {"color": fg_muted, "fontSize": 11, "margin": 14},
"splitLine": {"show": False},
"nameLocation": "middle",
"nameGap": 38,
"nameTextStyle": {"color": fg_muted, "fontSize": 12},
},
# Tooltip styled to match the chat bubble palette.
"tooltip": {
"backgroundColor": tooltip_bg,
"borderColor": tooltip_border,
"borderWidth": 1,
"padding": [8, 12],
"textStyle": {"color": fg, "fontSize": 12},
"axisPointer": {
"lineStyle": {"color": axis_line, "type": "dashed"},
"crossStyle": {"color": axis_line},
},
},
# Bar series: subtle border-radius so bars look modern, not blocky.
"bar": {"itemStyle": {"borderRadius": [3, 3, 0, 0]}},
# Line series: thicker than the ECharts default for legibility on retina screens.
"line": {
"lineStyle": {"width": 2.5},
"symbol": "circle",
"symbolSize": 6,
},
# Candlestick: warm green up / brick red down — readable without
# being CSS-hello-world green/red.
"candlestick": {
"itemStyle": {
"color": "#3d7a4a", # up body
"color0": "#a8453d", # down body
"borderColor": "#3d7a4a",
"borderColor0": "#a8453d",
},
},
}
def theme_json(theme: str = "light") -> str:
"""Theme dict serialized as a JSON string, ready to be embedded in
a JS `echarts.registerTheme(...)` call."""
return json.dumps(build_theme(theme))
__all__ = ["build_theme", "theme_json"]
+313
View File
@@ -0,0 +1,313 @@
"""``chart_render`` — the single agent-facing tool.
Calling it both renders the chart in chat (the rich envelope is the
embedding signal see ChartToolDetail.tsx in the desktop renderer) and
produces a downloadable PNG file the user can save.
The result envelope echoes the spec back so the chat panel can render
the chart live from the same data the server rasterized. This means
the chart can be reconstructed even when the user reopens an old
session the spec lives in events.jsonl as the tool's result.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
from chart_tools.renderer import RendererError
if TYPE_CHECKING:
from fastmcp import FastMCP
logger = logging.getLogger(__name__)
_DEFAULT_WIDTH = 1600
_DEFAULT_HEIGHT = 900
_DEFAULT_DPI = 300
_MIN_PNG_BYTES = 200 # sanity floor for "did the screenshot actually contain anything"
def _system_theme() -> str:
"""Resolve the user's UI theme from the desktop env var.
The Electron main process sets ``HIVE_DESKTOP_THEME`` to ``"light"``
or ``"dark"`` when spawning the runtime, sourced from
``nativeTheme.shouldUseDarkColors`` so the rendered PNG matches
whatever the user is actually looking at. Theme is intentionally
NOT exposed to the agent (the agent has no UI context, and saved
charts should match the user's app). Falls back to "light" for
headless / non-desktop runtimes.
"""
val = (os.environ.get("HIVE_DESKTOP_THEME") or "").strip().lower()
return "dark" if val == "dark" else "light"
def register_tools(mcp: FastMCP) -> None:
@mcp.tool()
async def chart_render(
kind: str,
spec: Any,
title: str = "",
width: int = _DEFAULT_WIDTH,
height: int = _DEFAULT_HEIGHT,
dpi: int = _DEFAULT_DPI,
output_path: str | None = None,
) -> dict:
"""Render a chart or diagram to a high-DPI PNG and return a rich
envelope so the chat can also render the chart live.
This single tool drives both the live in-chat embedding (chat
reads `result.spec` and mounts the same lib in the bubble) and
the downloadable file (chat shows `result.file_url` as a link).
Calling this tool IS the embedding there is no separate
"show in chat" step.
Args:
kind: "echarts" for ECharts JSON specs (general BI: bar, line,
area, scatter, candlestick, heatmap, treemap, sankey,
parallel, calendar, gauge, pie, sunburst, geo maps).
"mermaid" for diagrams (flowchart, sequence, gantt, ERD,
state, mindmap, C4).
spec: For kind="echarts", a dict matching the ECharts option
schema (https://echarts.apache.org/en/option.html).
For kind="mermaid", the raw mermaid source string.
title: Short slug used in the auto-generated filename.
Trims to lowercase-hyphenated. Required for
discoverability via chart_list_recent later.
width, height: Logical viewport size in CSS pixels. Default
1600×900 is good for slide decks; 1200×800 for web;
640×400 for inline thumbnails.
dpi: Device pixel ratio for the screenshot. 300 is print
quality; 150 is web-retina; 96 is screen-default.
Higher dpi = larger file, no quality difference once
past the display's native ratio.
output_path: Override where the PNG lands. Default is
$HIVE_HOME/charts/<UTC-timestamp>-<title>.png.
Note: theme is NOT an argument. It's resolved automatically
from the desktop's HIVE_DESKTOP_THEME env var so the saved
chart matches the user's UI without the agent picking.
Returns:
{
"kind": "echarts" | "mermaid",
"spec": <echoed spec>,
"file_path": "/abs/path/to/chart.png",
"file_url": "file:///abs/path/to/chart.png",
"width": 1600,
"height": 900,
"dpi": 300,
"bytes": 142318,
"title": "revenue-by-region"
}
On error, returns {"error": "...", "spec": <echoed>, "kind": ...}
so the chat can still surface a "spec invalid" pill rather
than swallowing the failure.
"""
if kind not in ("echarts", "mermaid"):
return {
"error": f"kind must be 'echarts' or 'mermaid', got {kind!r}",
"kind": kind,
"spec": spec,
}
# Theme is sourced from the desktop env, not the agent. The
# Electron main process sets HIVE_DESKTOP_THEME from
# nativeTheme.shouldUseDarkColors when it spawns the runtime,
# so the rendered PNG matches whatever the user is looking at.
# Falls back to "light" for headless / non-desktop runs.
theme = _system_theme()
# Coerce JSON-string specs to dicts. Common LLM mistake: agent
# serializes the ECharts option to JSON before passing it instead
# of letting the MCP layer marshal a dict. Without this we'd hand
# JS a string and `chart.setOption("...")` blows up with
# "Cannot create property 'series' on string '...'" — the exact
# error reported by the user on 2026-05-01.
if kind == "echarts" and isinstance(spec, str):
try:
spec = json.loads(spec)
except json.JSONDecodeError as exc:
return {
"error": (
f"spec was a string but not valid JSON: {exc}. "
"Pass the spec as a dict, not a JSON-encoded string."
),
"kind": kind,
"spec": spec,
}
# Resolve output path
try:
resolved_path = _resolve_output_path(output_path, title)
except OSError as exc:
return {"error": f"could not create output dir: {exc}", "kind": kind, "spec": spec}
# One retry on transient errors (browser cold-start race, font
# loading flake). Persistent spec errors fail fast.
start = time.monotonic()
last_error: Exception | None = None
png_bytes: bytes | None = None
for attempt in range(2):
try:
png_bytes = await _render_async(
kind=kind,
spec=spec,
width=int(width),
height=int(height),
dpi=int(dpi),
theme=theme,
)
break
except RendererError as exc:
last_error = exc
# RendererError wraps both spec-syntax errors and
# browser-side flakes. We retry once for the latter; if
# the second attempt fails too, surface the error so the
# agent can fix it.
logger.warning(
"chart_render attempt %d/%d failed: %s", attempt + 1, 2, exc
)
if attempt == 0:
await asyncio.sleep(0.15)
continue
return {"error": str(exc), "kind": kind, "spec": spec, "retried": True}
except Exception as exc: # noqa: BLE001 — surface unexpected errors to the agent
logger.exception("chart_render unexpected failure (attempt %d)", attempt + 1)
last_error = exc
if attempt == 0:
await asyncio.sleep(0.15)
continue
msg = repr(exc) if str(exc) == "" else str(exc)
return {
"error": f"renderer crashed: {type(exc).__name__}: {msg}",
"kind": kind,
"spec": spec,
"retried": True,
}
if png_bytes is None:
return {
"error": f"renderer produced no bytes after retry; last error: {last_error}",
"kind": kind,
"spec": spec,
"retried": True,
}
if len(png_bytes) < _MIN_PNG_BYTES:
return {
"error": f"render produced suspiciously small PNG ({len(png_bytes)} bytes)",
"kind": kind,
"spec": spec,
}
try:
resolved_path.write_bytes(png_bytes)
except OSError as exc:
return {
"error": f"could not write {resolved_path}: {exc}",
"kind": kind,
"spec": spec,
}
runtime_ms = int((time.monotonic() - start) * 1000)
logger.info(
"chart_render: kind=%s title=%s %dx%d@%ddpi → %s (%d bytes, %dms)",
kind,
title or "(untitled)",
width,
height,
dpi,
resolved_path,
len(png_bytes),
runtime_ms,
)
return {
"kind": kind,
"spec": spec,
"file_path": str(resolved_path),
"file_url": resolved_path.as_uri(),
"width": int(width),
"height": int(height),
"dpi": int(dpi),
"bytes": len(png_bytes),
"title": title,
"runtime_ms": runtime_ms,
}
# NOTE: chart_list_recent was dropped as redundant per design feedback
# 2026-05-01. The agent rarely needed to enumerate past charts; when
# it does, the existing files-tools.list_directory works against
# $HIVE_HOME/charts/ with the same outcome.
# ── helpers ────────────────────────────────────────────────────────
def _hive_home() -> Path:
override = os.environ.get("HIVE_HOME")
if override:
return Path(override).expanduser()
return Path.home() / ".hive"
def _charts_dir() -> Path:
return _hive_home() / "charts"
def _slugify(s: str) -> str:
"""Lowercase, hyphenate, strip non-[a-z0-9-]. Empty input → 'chart'."""
s = (s or "").strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
return s or "chart"
def _resolve_output_path(override: str | None, title: str) -> Path:
if override:
p = Path(override).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
return p
charts_dir = _charts_dir()
charts_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime())
return charts_dir / f"{ts}-{_slugify(title)}.png"
async def _render_async(*, kind: str, spec: Any, width: int, height: int, dpi: int, theme: str) -> bytes:
"""Render with a fresh Chromium per call.
Playwright proxy objects are bound to the asyncio loop that
created them, and pytest creates a new loop per test, so a
persistent module-level renderer can't be reused safely across
test boundaries. Cost: ~700ms per render (Chromium cold start
dominates). For production, a worker-thread pool with its own
long-lived loop is the optimization (TODO).
"""
from chart_tools.renderer import ChartRenderer
renderer = ChartRenderer()
try:
return await renderer.render(
kind=kind,
spec=spec,
width=width,
height=height,
dpi=dpi,
theme=theme,
)
finally:
await renderer.shutdown()
__all__ = ["register_tools"]
+150
View File
@@ -0,0 +1,150 @@
"""End-to-end smoke for chart-tools: spec → PNG file on disk.
Tests run with ``asyncio_mode=auto`` (see tools/pyproject.toml), so any
``async def test_*`` is automatically run via the pytest-asyncio plugin.
chart_render is async because FastMCP runs tool handlers inside the
running event loop; we await it the same way the framework does.
"""
from __future__ import annotations
import struct
from pathlib import Path
import pytest
EXPECTED_TOOLS = {"chart_render"}
def test_register_chart_tools_lands_all(mcp):
from chart_tools import register_chart_tools
names = register_chart_tools(mcp)
assert set(names) == EXPECTED_TOOLS, (
f"missing: {EXPECTED_TOOLS - set(names)}, extra: {set(names) - EXPECTED_TOOLS}"
)
def test_all_tools_have_chart_prefix(mcp):
from chart_tools import register_chart_tools
for n in register_chart_tools(mcp):
assert n.startswith("chart_"), f"{n!r} missing chart_ prefix"
@pytest.fixture
def chart_tool(mcp, tmp_path, monkeypatch):
monkeypatch.setenv("HIVE_HOME", str(tmp_path))
from chart_tools.tools import register_tools
register_tools(mcp)
return mcp._tool_manager._tools["chart_render"].fn
def _is_png(b: bytes) -> bool:
"""Verify the magic bytes + at least one IHDR chunk."""
return b[:8] == b"\x89PNG\r\n\x1a\n" and b"IHDR" in b[:32]
def _png_dims(path: Path) -> tuple[int, int]:
"""Read width/height from the IHDR chunk so we can assert the
screenshot honored the requested dpi/scale."""
data = path.read_bytes()
# IHDR is at offset 16 (after 8-byte signature + 4-byte length + 4-byte 'IHDR')
width = struct.unpack(">I", data[16:20])[0]
height = struct.unpack(">I", data[20:24])[0]
return width, height
async def test_render_echarts_bar_chart(chart_tool, tmp_path):
"""The flagship test: agent calls chart_render with an ECharts spec
file lands on disk, returns a non-empty envelope including the
spec echo so the chat can render it live."""
spec = {
"title": {"text": "Smoke test"},
"xAxis": {"type": "category", "data": ["A", "B", "C"]},
"yAxis": {"type": "value"},
"series": [{"type": "bar", "data": [12, 24, 6]}],
}
result = await chart_tool(
kind="echarts",
spec=spec,
title="smoke",
width=600,
height=400,
dpi=96, # keep test fast: 1x scale
)
assert "error" not in result, result
assert result["kind"] == "echarts"
assert result["spec"] == spec, "spec must be echoed back so the chat can re-render"
assert result["width"] == 600 and result["height"] == 400
assert result["bytes"] > 1000, "PNG should be at least 1KB"
path = Path(result["file_path"])
assert path.exists(), f"file not written: {path}"
assert _is_png(path.read_bytes()), "file is not a valid PNG"
w, h = _png_dims(path)
# At dpi=96 (1x), the PNG should be roughly viewport-sized.
assert 500 <= w <= 700, f"unexpected PNG width {w}"
assert 300 <= h <= 500, f"unexpected PNG height {h}"
# File should land under HIVE_HOME/charts/
assert path.parent.name == "charts"
assert "smoke" in path.name
async def test_render_echarts_invalid_kind_returns_error(chart_tool):
result = await chart_tool(kind="bogus", spec={}, title="x")
assert "error" in result
assert result["kind"] == "bogus" # echoed for the chat to surface
async def test_render_echarts_accepts_string_spec(chart_tool):
"""Regression: agent sometimes passes the spec as a JSON STRING
instead of a dict (the actual failure shown to the user on
2026-05-01: 'Cannot create property \"series\" on string ...').
chart_render must parse string specs transparently.
"""
import json as _json
spec_dict = {
"title": {"text": "String-spec regression"},
"xAxis": {"type": "category", "data": ["A", "B", "C"]},
"yAxis": {"type": "value"},
"series": [{"type": "bar", "data": [1, 2, 3]}],
}
result = await chart_tool(
kind="echarts",
spec=_json.dumps(spec_dict), # ← STRING not dict
title="string-spec",
width=600,
height=400,
dpi=96,
)
assert "error" not in result, result
assert result["bytes"] > 1000
# Spec should be echoed back as the parsed dict, not the original string.
assert isinstance(result["spec"], dict)
assert result["spec"] == spec_dict
async def test_render_mermaid_flowchart(chart_tool, tmp_path):
"""Mermaid path: agent passes raw mermaid source as the spec str."""
src = """flowchart LR
A[Start] --> B{ok?}
B -- yes --> C[Done]
B -- no --> D[Retry]
"""
result = await chart_tool(
kind="mermaid",
spec=src,
title="flow",
width=600,
height=400,
dpi=96,
)
assert "error" not in result, result
assert result["spec"] == src
path = Path(result["file_path"])
assert path.exists() and _is_png(path.read_bytes())