feat: pura linea
This commit is contained in:
@@ -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_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",
|
||||
"research",
|
||||
"time_context",
|
||||
"charts",
|
||||
],
|
||||
# Head of Growth — data, experiments, competitor research; no security.
|
||||
"queen_growth": [
|
||||
@@ -187,6 +193,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
|
||||
"browser_interaction",
|
||||
"research",
|
||||
"time_context",
|
||||
"charts",
|
||||
],
|
||||
# Head of Product Strategy — user research + roadmaps; no security.
|
||||
"queen_product_strategy": [
|
||||
@@ -196,6 +203,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
|
||||
"browser_interaction",
|
||||
"research",
|
||||
"time_context",
|
||||
"charts",
|
||||
],
|
||||
# Head of Finance — financial models (CSV/Excel heavy), market research.
|
||||
"queen_finance_fundraising": [
|
||||
@@ -206,6 +214,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
|
||||
"browser_interaction",
|
||||
"research",
|
||||
"time_context",
|
||||
"charts",
|
||||
],
|
||||
# Head of Legal — reads contracts/PDFs, researches; no data/security.
|
||||
"queen_legal": [
|
||||
@@ -243,6 +252,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = {
|
||||
"browser_basic",
|
||||
"browser_interaction",
|
||||
"time_context",
|
||||
"charts",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
"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.
|
||||
|
||||
@@ -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 `` 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 `` 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.
|
||||
@@ -34,6 +34,7 @@ _BUNDLED_DIRS: tuple[Path, ...] = (
|
||||
_TOOL_GATED_SKILLS: list[tuple[str, str, str]] = [
|
||||
("browser_", "browser-automation", "hive.browser-automation"),
|
||||
("terminal_", "terminal-tools-foundations", "hive.terminal-tools-foundations"),
|
||||
("chart_", "chart-creation-foundations", "hive.chart-creation-foundations"),
|
||||
]
|
||||
|
||||
_BODY_CACHE: dict[str, str] = {}
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
+45
File diff suppressed because one or more lines are too long
+2314
File diff suppressed because one or more lines are too long
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user