diff --git a/core/framework/agents/queen/mcp_registry.json b/core/framework/agents/queen/mcp_registry.json index 6d57cb8d..b58cc76a 100644 --- a/core/framework/agents/queen/mcp_registry.json +++ b/core/framework/agents/queen/mcp_registry.json @@ -1,3 +1,3 @@ { - "include": ["gcu-tools", "hive_tools", "terminal-tools"] + "include": ["gcu-tools", "hive_tools", "terminal-tools", "chart-tools"] } diff --git a/core/framework/agents/queen/queen_tools_defaults.py b/core/framework/agents/queen/queen_tools_defaults.py index 888f6a9b..61b604dc 100644 --- a/core/framework/agents/queen/queen_tools_defaults.py +++ b/core/framework/agents/queen/queen_tools_defaults.py @@ -149,6 +149,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", + ], } @@ -177,6 +182,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": [ @@ -186,6 +192,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": [ @@ -195,6 +202,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": [ @@ -205,6 +213,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": [ @@ -242,6 +251,7 @@ QUEEN_DEFAULT_CATEGORIES: dict[str, list[str]] = { "browser_basic", "browser_interaction", "time_context", + "charts", ], } diff --git a/core/framework/loader/mcp_registry.py b/core/framework/loader/mcp_registry.py index 688a4c12..95193072 100644 --- a/core/framework/loader/mcp_registry.py +++ b/core/framework/loader/mcp_registry.py @@ -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. diff --git a/core/framework/skills/_preset_skills/chart-creation-foundations/SKILL.md b/core/framework/skills/_preset_skills/chart-creation-foundations/SKILL.md new file mode 100644 index 00000000..3d803038 --- /dev/null +++ b/core/framework/skills/_preset_skills/chart-creation-foundations/SKILL.md @@ -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. diff --git a/core/framework/skills/tool_gating.py b/core/framework/skills/tool_gating.py index 14d8751d..debbce70 100644 --- a/core/framework/skills/tool_gating.py +++ b/core/framework/skills/tool_gating.py @@ -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] = {} diff --git a/tools/chart_tools_server.py b/tools/chart_tools_server.py new file mode 100644 index 00000000..f59ba592 --- /dev/null +++ b/tools/chart_tools_server.py @@ -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() diff --git a/tools/src/chart_tools/__init__.py b/tools/src/chart_tools/__init__.py new file mode 100644 index 00000000..79bc46aa --- /dev/null +++ b/tools/src/chart_tools/__init__.py @@ -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"] diff --git a/tools/src/chart_tools/renderer.py b/tools/src/chart_tools/renderer.py new file mode 100644 index 00000000..8912add3 --- /dev/null +++ b/tools/src/chart_tools/renderer.py @@ -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""" + +
+ + + + + + + +""" + + +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"] diff --git a/tools/src/chart_tools/server.py b/tools/src/chart_tools/server.py new file mode 100644 index 00000000..65f14e68 --- /dev/null +++ b/tools/src/chart_tools/server.py @@ -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() diff --git a/tools/src/chart_tools/static/echarts.min.js b/tools/src/chart_tools/static/echarts.min.js new file mode 100644 index 00000000..835966ff --- /dev/null +++ b/tools/src/chart_tools/static/echarts.min.js @@ -0,0 +1,45 @@ + +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,(function(t){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=a)}}for(var h=this.__startIndex;h n?a:o,h=Math.abs(l.label.y-n);if(h>=u.maxY){var c=l.label.x-e-l.len2*r,p=i+l.len,f=Math.abs(c) t.unconstrainedWidth?null:d:null;i.setStyle("width",f)}var g=i.getBoundingRect();o.width=g.width;var y=(i.style.margin||0)+2.1;o.height=g.height+y,o.y-=(o.height-c)/2}}}function kM(t){return"center"===t.position}function LM(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*CM,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,h=s.x,c=s.y,p=s.height;function d(t){t.ignore=!0}i.each((function(t){var s=i.getItemGraphicEl(t),c=s.shape,p=s.getTextContent(),f=s.getTextGuideLine(),g=i.getItemModel(t),y=g.getModel("label"),v=y.get("position")||g.get(["emphasis","label","position"]),m=y.get("distanceToLabelLine"),x=y.get("alignTo"),_=$r(y.get("edgeDistance"),u),b=y.get("bleedMargin"),w=g.getModel("labelLine"),S=w.get("length");S=$r(S,u);var M=w.get("length2");if(M=$r(M,u),Math.abs(c.endAngle-c.startAngle)0?"right":"left":k>0?"left":"right"}var B=Math.PI,F=0,G=y.get("rotate");if(j(G))F=G*(B/180);else if("center"===v)F=0;else if("radial"===G||!0===G){F=k<0?-A+B:-A}else if("tangential"===G&&"outside"!==v&&"outer"!==v){var W=Math.atan2(k,L);W<0&&(W=2*B+W),L>0&&(W=B+W),F=W-B}if(o=!!F,p.x=I,p.y=T,p.rotation=F,p.setStyle({verticalAlign:"middle"}),P){p.setStyle({align:D});var H=p.states.select;H&&(H.x+=p.x,H.y+=p.y)}else{var Y=p.getBoundingRect().clone();Y.applyTransform(p.getComputedTransform());var X=(p.style.margin||0)+2.1;Y.y-=X/2,Y.height+=X,r.push({label:p,labelLine:f,position:v,len:S,len2:M,minTurnAngle:w.get("minTurnAngle"),maxSurfaceAngle:w.get("maxSurfaceAngle"),surfaceNormal:new De(k,L),linePoints:C,textAlign:D,labelDistance:m,labelAlignTo:x,edgeDistance:_,bleedMargin:b,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:p.style.width})}s.setTextConfig({inside:P})}})),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],h=Number.MAX_VALUE,c=-Number.MAX_VALUE,p=0;p i&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;a5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&Rk(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function Rk(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var Nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&C(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){E(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],(function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])}),this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];E(B(this.ecModel.queryComponents({mainType:"parallelAxis"}),(function(t){return(t.get("parallelIndex")||0)===this.componentIndex}),this),(function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)}))},e.type="parallel",e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(zp),Ek=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(ab);function zk(t,e,n,i,r,o){t=t||0;var a=n[1]-n[0];if(null!=r&&(r=Bk(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(e[1]-e[0]);s=Bk(s,[0,a]),r=o=Bk(s,[r,o]),i=0}e[0]=Bk(e[0],n),e[1]=Bk(e[1],n);var l=Vk(e,i);e[i]+=t;var u,h=r||0,c=n.slice();return l.sign<0?c[0]+=h:c[1]-=h,e[i]=Bk(e[i],c),u=Vk(e,i),null!=r&&(u.sign!==l.sign||u.span