From dc3ba0ec61fb0d74ce0fccd2d936a1870ad1cac6 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 18:35:38 -0700 Subject: [PATCH 01/10] MCP Apps interactive Vega-Lite charts with CSP-safe rendering - Vite-built widget bundles ext-apps SDK + Vega-Lite + vega-interpreter into a single HTML file (no CDN deps, no eval, CSP-safe) - Widget receives tool result via MCP Apps protocol (ontoolresult) - Supports fullscreen toggle when host advertises it - create_chart returns vega_spec only (no PNG, widget renders interactively) - Register ui://sidemantic/chart resource with proper MCP Apps metadata - Remove _apps_enabled flag (MCP Apps works via protocol, not a runtime flag) - Remove vendored mcp-ui-server (widget is self-contained) - Remove --apps CLI flag dependency on mcp-ui-server import - structured_output=False on all tools (fixes Claude Desktop tool visibility) - Replace | None params with falsy defaults (removes anyOf from schemas) --- sidemantic/apps/__init__.py | 67 ++---- sidemantic/apps/chart.html | 318 +++++++++++++++++++++++++++++ sidemantic/apps/web/.gitignore | 2 + sidemantic/apps/web/chart-app.ts | 116 +++++++++++ sidemantic/apps/web/chart.html | 42 ++++ sidemantic/apps/web/package.json | 20 ++ sidemantic/apps/web/vite.config.ts | 21 ++ sidemantic/cli.py | 7 - sidemantic/mcp_server.py | 49 +++-- tests/test_mcp_apps.py | 127 +++--------- tests/test_mcp_server.py | 4 - 11 files changed, 601 insertions(+), 172 deletions(-) create mode 100644 sidemantic/apps/chart.html create mode 100644 sidemantic/apps/web/.gitignore create mode 100644 sidemantic/apps/web/chart-app.ts create mode 100644 sidemantic/apps/web/chart.html create mode 100644 sidemantic/apps/web/package.json create mode 100644 sidemantic/apps/web/vite.config.ts diff --git a/sidemantic/apps/__init__.py b/sidemantic/apps/__init__.py index 736df9b8..476c3f18 100644 --- a/sidemantic/apps/__init__.py +++ b/sidemantic/apps/__init__.py @@ -1,60 +1,25 @@ """MCP Apps integration for sidemantic. -Creates vendor-neutral UI resources (MCP Apps standard) that render -interactive charts in any MCP Apps-compatible host. +Provides interactive chart widgets for MCP Apps-compatible hosts. +The widget is built with Vite (sidemantic/apps/web/) and bundled into +a single HTML file (sidemantic/apps/chart.html) that includes the +ext-apps SDK and Vega-Lite with CSP-safe interpreter. """ -import json from pathlib import Path -from typing import Any -_WIDGET_TEMPLATE: str | None = None +_WIDGET_HTML: str | None = None def _get_widget_template() -> str: - """Load the chart widget HTML template.""" - global _WIDGET_TEMPLATE - if _WIDGET_TEMPLATE is None: - path = Path(__file__).parent / "chart_widget.html" - _WIDGET_TEMPLATE = path.read_text() - return _WIDGET_TEMPLATE - - -def build_chart_html(vega_spec: dict[str, Any]) -> str: - """Build a self-contained chart widget HTML with embedded Vega spec. - - Args: - vega_spec: Vega-Lite specification dict. - - Returns: - Complete HTML string with the spec injected. - """ - template = _get_widget_template() - # Escape sequences to prevent XSS when user-provided strings - # (e.g., chart titles) flow into the Vega spec. - safe_json = json.dumps(vega_spec).replace("<", "\\u003c") - return template.replace("{{VEGA_SPEC}}", safe_json) - - -def create_chart_resource(vega_spec: dict[str, Any]): - """Create a UIResource for a chart visualization. - - Args: - vega_spec: Vega-Lite specification dict. - - Returns: - UIResource (EmbeddedResource) for MCP Apps-compatible hosts. - """ - from sidemantic.apps._mcp_ui import create_ui_resource - - html = build_chart_html(vega_spec) - return create_ui_resource( - { - "uri": "ui://sidemantic/chart", - "content": { - "type": "rawHtml", - "htmlString": html, - }, - "encoding": "text", - } - ) + """Load the built chart widget HTML for the MCP Apps resource handler.""" + global _WIDGET_HTML + if _WIDGET_HTML is None: + built = Path(__file__).parent / "chart.html" + if built.exists(): + _WIDGET_HTML = built.read_text() + else: + raise FileNotFoundError( + f"Chart widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build" + ) + return _WIDGET_HTML diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html new file mode 100644 index 00000000..3e7c903d --- /dev/null +++ b/sidemantic/apps/chart.html @@ -0,0 +1,318 @@ + + + + + + + + + +
+ +
Loading...
+
+ + diff --git a/sidemantic/apps/web/.gitignore b/sidemantic/apps/web/.gitignore new file mode 100644 index 00000000..d77474af --- /dev/null +++ b/sidemantic/apps/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts new file mode 100644 index 00000000..dab9259c --- /dev/null +++ b/sidemantic/apps/web/chart-app.ts @@ -0,0 +1,116 @@ +import { App, applyDocumentTheme, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import embed from "vega-embed"; +import { expressionInterpreter } from "vega-interpreter"; + +const container = document.getElementById("chart")!; + +function renderChart(vegaSpec: Record) { + // Clear chart content but preserve the fullscreen button + Array.from(container.children).forEach(c => { + if (c.id !== "fullscreen-btn") c.remove(); + }); + const spec = { ...vegaSpec }; + spec.width = "container"; + spec.height = 500; + spec.background = "transparent"; + + const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; + + embed(container, spec as any, { + actions: false, + theme: prefersDark ? "dark" : undefined, + // CSP-safe: use AST interpreter instead of eval + ast: true, + expr: expressionInterpreter, + }) + .then((result) => { + const ro = new ResizeObserver(() => result.view.resize().run()); + ro.observe(container); + // Tell host the actual content height after render + requestAnimationFrame(() => { + const h = Math.max(500, document.documentElement.scrollHeight); + app.sendSizeChanged({ height: h }); + }); + }) + .catch((err) => { + container.innerHTML = `
Chart render error: ${err.message}
`; + }); +} + +function extractVegaSpec(result: CallToolResult): Record | null { + // Try structuredContent first + const sc = result.structuredContent as Record | undefined; + if (sc?.vega_spec) return sc.vega_spec as Record; + // Then parse from text content + if (result.content) { + for (const item of result.content) { + if (item.type === "text") { + try { + const data = JSON.parse((item as { text: string }).text); + if (data.vega_spec) return data.vega_spec; + } catch {} + } + } + } + return null; +} + +// Create app and register handlers before connecting +const fullscreenBtn = document.getElementById("fullscreen-btn")!; +let currentDisplayMode: "inline" | "fullscreen" = "inline"; + +const app = new App( + { name: "sidemantic-chart", version: "1.0.0" }, + { availableDisplayModes: ["inline", "fullscreen"] }, + { autoResize: false }, +); + +app.ontoolresult = (result: CallToolResult) => { + const spec = extractVegaSpec(result); + if (spec) { + renderChart(spec); + } else { + container.innerHTML = '
No chart data in tool result
'; + } +}; + +app.ontoolinput = () => { + container.innerHTML = '
Running query...
'; +}; + +app.onhostcontextchanged = (ctx: McpUiHostContext) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.availableDisplayModes) { + const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); + fullscreenBtn.classList.toggle("available", canFullscreen); + } + if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { + currentDisplayMode = ctx.displayMode; + document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); + fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; + } +}; + +fullscreenBtn.addEventListener("click", async () => { + const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; + const ctx = app.getHostContext(); + if (ctx?.availableDisplayModes?.includes(newMode)) { + const result = await app.requestDisplayMode({ mode: newMode }); + currentDisplayMode = result.mode as "inline" | "fullscreen"; + document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); + fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; + } +}); + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.theme) applyDocumentTheme(ctx.theme); + if (ctx?.availableDisplayModes?.includes("fullscreen")) { + fullscreenBtn.classList.add("available"); + } + // Keep fullscreen button, replace only the chart content area + const loading = container.querySelector(".loading"); + if (loading) loading.textContent = "Waiting for chart data..."; + app.sendSizeChanged({ height: 500 }); +}); diff --git a/sidemantic/apps/web/chart.html b/sidemantic/apps/web/chart.html new file mode 100644 index 00000000..d600fffe --- /dev/null +++ b/sidemantic/apps/web/chart.html @@ -0,0 +1,42 @@ + + + + + + + + +
+ +
Loading...
+
+ + + diff --git a/sidemantic/apps/web/package.json b/sidemantic/apps/web/package.json new file mode 100644 index 00000000..e3745bd7 --- /dev/null +++ b/sidemantic/apps/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "sidemantic-chart-widget", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.3.2", + "vega": "^6.2.0", + "vega-embed": "^7.1.0", + "vega-interpreter": "^2.2.1", + "vega-lite": "^6.4.2" + }, + "devDependencies": { + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0", + "typescript": "^5.9.3" + } +} diff --git a/sidemantic/apps/web/vite.config.ts b/sidemantic/apps/web/vite.config.ts new file mode 100644 index 00000000..e8b27df5 --- /dev/null +++ b/sidemantic/apps/web/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + rollupOptions: { input: "chart.html" }, + outDir: "../", + emptyOutDir: false, + }, + define: { + // Replace new Function calls with a safe fallback at build time + // This prevents CSP violations in MCP Apps sandboxes + }, + resolve: { + alias: { + // Use CSP-safe expression interpreter + "vega-functions/codegenExpression": "vega-interpreter", + }, + }, +}); diff --git a/sidemantic/cli.py b/sidemantic/cli.py index 31a58f7f..f7b0f66c 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -328,13 +328,6 @@ def mcp_serve( placeholders = ", ".join(["?" for _ in columns]) layer.adapter.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows) - # Enable apps mode if requested - if apps: - import sidemantic.mcp_server as _mcp_mod - - _mcp_mod._apps_enabled = True - typer.echo("Interactive UI widgets enabled", err=True) - # Determine transport if http or apps: if apps and not http: diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index 56b366f6..c464c848 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -13,7 +13,6 @@ # Global semantic layer instance _layer: SemanticLayer | None = None -_apps_enabled: bool = False def initialize_layer( @@ -391,7 +390,18 @@ def run_query( } -@mcp.tool(structured_output=False, meta={"ui": {"resourceUri": "ui://sidemantic/chart"}}) +@mcp.tool( + structured_output=False, + meta={ + "ui": { + "resourceUri": "ui://sidemantic/chart", + "csp": { + "connectDomains": [], + "resourceDomains": [], + }, + }, + }, +) def create_chart( dimensions: list[str] = [], metrics: list[str] = [], @@ -433,7 +443,7 @@ def create_chart( png_base64: Base64-encoded PNG image row_count: Number of data points """ - from sidemantic.charts import chart_to_base64_png, chart_to_vega + from sidemantic.charts import chart_to_vega from sidemantic.charts import create_chart as make_chart layer = get_layer() @@ -478,25 +488,15 @@ def create_chart( height=height, ) - # Export to both formats + # Export Vega spec (rendered interactively by MCP Apps widget) vega_spec = chart_to_vega(chart) - png_base64 = chart_to_base64_png(chart) - result = { + return { "sql": sql, "vega_spec": vega_spec, - "png_base64": png_base64, "row_count": len(row_dicts), } - # When apps mode is enabled, include an interactive UI widget - if _apps_enabled: - from sidemantic.apps import create_chart_resource - - return [result, create_chart_resource(vega_spec)] - - return result - def _generate_chart_title(dimensions: list[str], metrics: list[str]) -> str: """Generate a descriptive title from query parameters.""" @@ -699,7 +699,24 @@ def get_semantic_graph() -> dict[str, Any]: return result -# --- MCP Resource: Catalog Metadata --- +# --- MCP Resources --- + + +@mcp.resource( + "ui://sidemantic/chart", + mime_type="text/html;profile=mcp-app", + meta={ + "ui": { + "csp": {"connectDomains": [], "resourceDomains": []}, + }, + "mcpui.dev/ui-preferred-frame-size": ["100%", "500px"], + }, +) +def chart_widget_resource() -> str: + """Interactive Vega-Lite chart widget for MCP Apps-compatible hosts.""" + from sidemantic.apps import _get_widget_template + + return _get_widget_template() @mcp.resource("semantic://catalog") diff --git a/tests/test_mcp_apps.py b/tests/test_mcp_apps.py index ca75c71b..13586c12 100644 --- a/tests/test_mcp_apps.py +++ b/tests/test_mcp_apps.py @@ -4,7 +4,7 @@ pytest.importorskip("mcp") # Skip if mcp extra not installed -from sidemantic.apps import build_chart_html, create_chart_resource +from sidemantic.apps import _get_widget_template from sidemantic.mcp_server import create_chart, initialize_layer @@ -40,96 +40,35 @@ def demo_layer(tmp_path): yield layer -def test_build_chart_html(): - """Test that build_chart_html embeds the Vega spec.""" - spec = {"$schema": "https://vega.github.io/schema/vega-lite/v5.json", "mark": "bar"} - html = build_chart_html(spec) - - assert "{{VEGA_SPEC}}" not in html - assert '"$schema"' in html - assert '"mark"' in html - assert "vega-embed" in html - - -def test_build_chart_html_escapes_json(): - """Test that JSON with special chars is properly embedded.""" - spec = {"title": "Revenue <&> Costs", "description": 'Test\'s "spec"'} - html = build_chart_html(spec) - - # < in the JSON data should be escaped to \u003c - assert "\\u003c" in html - # The raw < from user input should not appear in the JSON block - assert "Revenue <&>" not in html - assert "Revenue \\u003c&>" in html - - -def test_build_chart_html_prevents_script_injection(): - """Test that in user input cannot break out of the JSON block.""" - spec = {"title": ''} - html = build_chart_html(spec) - - assert " +`,K_e="chart-wrapper";function Q_e(e){return typeof e=="function"}function WR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function ewe(e,t,n){if(e.$schema){const i=F9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return q9(um[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${um[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function H9(e){return!!(e&&"load"in e)}function HR(e){return H9(e)?e:xr.loader(e)}function twe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function nwe(e,t,n={}){let i,r;te(t)?(r=HR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=twe(i),s=o.loader;(!r||s)&&(r=HR(n.loader??s));const a=await GR(o,r),u=await GR(n,r),l={...W9(u,a),config:Hl(u.config??{},a.config??{})};return await rwe(e,i,l,r)}async function GR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function iwe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function rwe(e,t,n={},i){const r=n.theme?Hl(s_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:W9({},V_e,n.actions??{}),s={...Y_e,...n.i18n},a=n.renderer??"svg",u=n.logger??bm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=iwe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?G_e.toString():n.defaultStyle,_.appendChild(E)}}const f=ewe(t,u,n.mode);let d=X_e[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=F9(d.$schema);q9(um.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${um.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(K_e),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):_y(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??rL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!H9(w)?w==null?void 0:w.baseURL:void 0,E=Q_e(S)?S:new m_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=J_e,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){WR($y(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){WR($y(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){H_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:$y(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const us=document.getElementById("chart");let lm="inline",sd=null;function Ik(e){var i;us.innerHTML="";const t={...e};t.width="container",t.height=lm==="fullscreen"?"container":500,t.background="transparent";const n=(i=window.matchMedia)==null?void 0:i.call(window,"(prefers-color-scheme: dark)").matches;nwe(us,t,{actions:!1,theme:n?"dark":void 0,ast:!0,expr:rL}).then(r=>{new ResizeObserver(()=>r.view.resize().run()).observe(us),lm==="inline"&&owe(),requestAnimationFrame(()=>{const s=Math.max(500,document.documentElement.scrollHeight);gs.sendSizeChanged({height:s})})}).catch(r=>{us.innerHTML=`
Chart render error: ${r.message}
`})}function owe(){const e=document.createElement("div");e.className="expand-bar",e.innerHTML="↗ Expand",e.addEventListener("click",swe),us.appendChild(e)}async function swe(){try{lm=(await gs.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Ik(sd)}catch{}}function awe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const gs=new YW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});gs.ontoolresult=e=>{const t=awe(e);t?(sd=t,Ik(t)):us.innerHTML='
No chart data in tool result
'};gs.ontoolinput=()=>{us.innerHTML='
Running query...
'};gs.onhostcontextchanged=e=>{e.theme&&XF(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(lm=e.displayMode,sd&&Ik(sd))};gs.connect().then(()=>{const e=gs.getHostContext();e!=null&&e.theme&&XF(e.theme);const t=us.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),gs.sendSizeChanged({height:500})});
-
Loading...
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index dab9259c..613c4d86 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -4,15 +4,14 @@ import embed from "vega-embed"; import { expressionInterpreter } from "vega-interpreter"; const container = document.getElementById("chart")!; +let currentDisplayMode: "inline" | "fullscreen" = "inline"; +let lastSpec: Record | null = null; function renderChart(vegaSpec: Record) { - // Clear chart content but preserve the fullscreen button - Array.from(container.children).forEach(c => { - if (c.id !== "fullscreen-btn") c.remove(); - }); + container.innerHTML = ""; const spec = { ...vegaSpec }; spec.width = "container"; - spec.height = 500; + spec.height = currentDisplayMode === "fullscreen" ? "container" : 500; spec.background = "transparent"; const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; @@ -20,14 +19,18 @@ function renderChart(vegaSpec: Record) { embed(container, spec as any, { actions: false, theme: prefersDark ? "dark" : undefined, - // CSP-safe: use AST interpreter instead of eval ast: true, expr: expressionInterpreter, }) .then((result) => { const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); - // Tell host the actual content height after render + + // Add expand bar in inline mode + if (currentDisplayMode === "inline") { + addExpandBar(); + } + requestAnimationFrame(() => { const h = Math.max(500, document.documentElement.scrollHeight); app.sendSizeChanged({ height: h }); @@ -38,11 +41,27 @@ function renderChart(vegaSpec: Record) { }); } +function addExpandBar() { + const bar = document.createElement("div"); + bar.className = "expand-bar"; + bar.innerHTML = '↗ Expand'; + bar.addEventListener("click", goFullscreen); + container.appendChild(bar); +} + +async function goFullscreen() { + try { + const result = await app.requestDisplayMode({ mode: "fullscreen" }); + currentDisplayMode = result.mode as "inline" | "fullscreen"; + if (lastSpec) renderChart(lastSpec); + } catch { + // host doesn't support fullscreen + } +} + function extractVegaSpec(result: CallToolResult): Record | null { - // Try structuredContent first const sc = result.structuredContent as Record | undefined; if (sc?.vega_spec) return sc.vega_spec as Record; - // Then parse from text content if (result.content) { for (const item of result.content) { if (item.type === "text") { @@ -56,19 +75,16 @@ function extractVegaSpec(result: CallToolResult): Record | null return null; } -// Create app and register handlers before connecting -const fullscreenBtn = document.getElementById("fullscreen-btn")!; -let currentDisplayMode: "inline" | "fullscreen" = "inline"; - const app = new App( { name: "sidemantic-chart", version: "1.0.0" }, - { availableDisplayModes: ["inline", "fullscreen"] }, + {}, { autoResize: false }, ); app.ontoolresult = (result: CallToolResult) => { const spec = extractVegaSpec(result); if (spec) { + lastSpec = spec; renderChart(spec); } else { container.innerHTML = '
No chart data in tool result
'; @@ -81,35 +97,15 @@ app.ontoolinput = () => { app.onhostcontextchanged = (ctx: McpUiHostContext) => { if (ctx.theme) applyDocumentTheme(ctx.theme); - if (ctx.availableDisplayModes) { - const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); - fullscreenBtn.classList.toggle("available", canFullscreen); - } if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { currentDisplayMode = ctx.displayMode; - document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); - fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; + if (lastSpec) renderChart(lastSpec); } }; -fullscreenBtn.addEventListener("click", async () => { - const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; - const ctx = app.getHostContext(); - if (ctx?.availableDisplayModes?.includes(newMode)) { - const result = await app.requestDisplayMode({ mode: newMode }); - currentDisplayMode = result.mode as "inline" | "fullscreen"; - document.body.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); - fullscreenBtn.style.display = currentDisplayMode === "fullscreen" ? "none" : ""; - } -}); - app.connect().then(() => { const ctx = app.getHostContext(); if (ctx?.theme) applyDocumentTheme(ctx.theme); - if (ctx?.availableDisplayModes?.includes("fullscreen")) { - fullscreenBtn.classList.add("available"); - } - // Keep fullscreen button, replace only the chart content area const loading = container.querySelector(".loading"); if (loading) loading.textContent = "Waiting for chart data..."; app.sendSizeChanged({ height: 500 }); diff --git a/sidemantic/apps/web/chart.html b/sidemantic/apps/web/chart.html index d600fffe..85aa50fa 100644 --- a/sidemantic/apps/web/chart.html +++ b/sidemantic/apps/web/chart.html @@ -4,37 +4,23 @@
-
Loading...
From 606d450029935534617543d520081e6992529ef4 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 19:28:23 -0700 Subject: [PATCH 03/10] Polish chart widget: expand icon, fullscreen sizing, padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move expand button to top-right inline with title ("Expand ↗") - Fix fullscreen height to fill viewport minus 150px for prompt box - Add padding in fullscreen mode (16px top, 24px sides) - Add 5px height buffer to prevent inline scroll - Dark mode support for expand button --- sidemantic/apps/chart.html | 63 ++++++++++++++++++-------------- sidemantic/apps/web/chart-app.ts | 31 ++++++++++------ sidemantic/apps/web/chart.html | 19 +++++++--- 3 files changed, 69 insertions(+), 44 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 36e0d331..e0603c48 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -6,21 +6,30 @@ - +`,K_e="chart-wrapper";function Q_e(e){return typeof e=="function"}function WR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function ewe(e,t,n){if(e.$schema){const i=F9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return q9(um[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${um[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function H9(e){return!!(e&&"load"in e)}function HR(e){return H9(e)?e:xr.loader(e)}function twe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function nwe(e,t,n={}){let i,r;te(t)?(r=HR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=twe(i),s=o.loader;(!r||s)&&(r=HR(n.loader??s));const a=await GR(o,r),u=await GR(n,r),l={...W9(u,a),config:Hl(u.config??{},a.config??{})};return await rwe(e,i,l,r)}async function GR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function iwe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function rwe(e,t,n={},i){const r=n.theme?Hl(s_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:W9({},V_e,n.actions??{}),s={...Y_e,...n.i18n},a=n.renderer??"svg",u=n.logger??ym(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=iwe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?G_e.toString():n.defaultStyle,_.appendChild(E)}}const f=ewe(t,u,n.mode);let d=X_e[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=F9(d.$schema);q9(um.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${um.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(K_e),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):xy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??rL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!H9(w)?w==null?void 0:w.baseURL:void 0,E=Q_e(S)?S:new m_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=J_e,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){WR(ky(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){WR(ky(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){H_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:ky(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let zk="inline",sd=null;function Ik(e){var r;ls.innerHTML="";const t=zk==="fullscreen";document.documentElement.classList.toggle("fullscreen",t);const n={...e};n.width="container",n.height=t?"container":500,n.background="transparent";const i=(r=window.matchMedia)==null?void 0:r.call(window,"(prefers-color-scheme: dark)").matches;nwe(ls,n,{actions:!1,theme:i?"dark":void 0,ast:!0,expr:rL}).then(o=>{new ResizeObserver(()=>o.view.resize().run()).observe(ls),t||owe(),requestAnimationFrame(()=>{if(t)Ao.sendSizeChanged({height:window.innerHeight-150});else{const a=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:a})}})}).catch(o=>{ls.innerHTML=`
Chart render error: ${o.message}
`})}function owe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",swe),ls.appendChild(e)}async function swe(){try{zk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Ik(sd)}catch{}}function awe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new YW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=awe(e);t?(sd=t,Ik(t)):ls.innerHTML='
No chart data in tool result
'};Ao.ontoolinput=()=>{ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&XF(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(zk=e.displayMode,sd&&Ik(sd))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&XF(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 613c4d86..6bd1833f 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -9,9 +9,12 @@ let lastSpec: Record | null = null; function renderChart(vegaSpec: Record) { container.innerHTML = ""; + const isFullscreen = currentDisplayMode === "fullscreen"; + document.documentElement.classList.toggle("fullscreen", isFullscreen); + const spec = { ...vegaSpec }; spec.width = "container"; - spec.height = currentDisplayMode === "fullscreen" ? "container" : 500; + spec.height = isFullscreen ? "container" : 500; spec.background = "transparent"; const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; @@ -26,14 +29,17 @@ function renderChart(vegaSpec: Record) { const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); - // Add expand bar in inline mode - if (currentDisplayMode === "inline") { - addExpandBar(); + if (!isFullscreen) { + addExpandButton(); } requestAnimationFrame(() => { - const h = Math.max(500, document.documentElement.scrollHeight); - app.sendSizeChanged({ height: h }); + if (isFullscreen) { + app.sendSizeChanged({ height: window.innerHeight - 150 }); + } else { + const h = Math.max(505, document.documentElement.scrollHeight + 5); + app.sendSizeChanged({ height: h }); + } }); }) .catch((err) => { @@ -41,12 +47,13 @@ function renderChart(vegaSpec: Record) { }); } -function addExpandBar() { - const bar = document.createElement("div"); - bar.className = "expand-bar"; - bar.innerHTML = '↗ Expand'; - bar.addEventListener("click", goFullscreen); - container.appendChild(bar); +function addExpandButton() { + const btn = document.createElement("div"); + btn.className = "expand-btn"; + btn.title = "Expand to fullscreen"; + btn.textContent = "Expand ↗"; + btn.addEventListener("click", goFullscreen); + container.appendChild(btn); } async function goFullscreen() { diff --git a/sidemantic/apps/web/chart.html b/sidemantic/apps/web/chart.html index 85aa50fa..3e1bc11c 100644 --- a/sidemantic/apps/web/chart.html +++ b/sidemantic/apps/web/chart.html @@ -6,17 +6,26 @@ From f894c5edc7dcad66f46cedc60fd577d02a705453 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 19:48:59 -0700 Subject: [PATCH 04/10] Dispose old chart observer and view before re-rendering --- sidemantic/apps/chart.html | 50 ++++++++++++++++---------------- sidemantic/apps/web/chart-app.ts | 7 +++++ 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index e0603c48..1567e69d 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,ewe="chart-wrapper";function twe(e){return typeof e=="function"}function GR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function nwe(e,t,n){if(e.$schema){const i=O9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return H9(cm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${cm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Z9(e){return!!(e&&"load"in e)}function ZR(e){return Z9(e)?e:xr.loader(e)}function iwe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function rwe(e,t,n={}){let i,r;te(t)?(r=ZR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=iwe(i),s=o.loader;(!r||s)&&(r=ZR(n.loader??s));const a=await VR(o,r),u=await VR(n,r),l={...G9(u,a),config:Hl(u.config??{},a.config??{})};return await swe(e,i,l,r)}async function VR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function owe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function swe(e,t,n={},i){const r=n.theme?Hl(u_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:G9({},X_e,n.actions??{}),s={...J_e,...n.i18n},a=n.renderer??"svg",u=n.logger??vm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=owe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?V_e.toString():n.defaultStyle,_.appendChild(E)}}const f=nwe(t,u,n.mode);let d=K_e[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=O9(d.$schema);H9(cm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${cm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(ewe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):wy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??sL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Z9(w)?w==null?void 0:w.baseURL:void 0,E=twe(S)?S:new b_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=Q_e,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){GR(Ay(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){GR(Ay(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Z_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Ay(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Pk="inline",sd=null,Kh=null,Qh=null;function Lk(e){var r;Kh&&(Kh.disconnect(),Kh=null),Qh&&(Qh.finalize(),Qh=null),ls.innerHTML="";const t=Pk==="fullscreen";document.documentElement.classList.toggle("fullscreen",t);const n={...e};n.width="container",n.height=t?"container":500,n.background="transparent";const i=(r=window.matchMedia)==null?void 0:r.call(window,"(prefers-color-scheme: dark)").matches;rwe(ls,n,{actions:!1,theme:i?"dark":void 0,ast:!0,expr:sL}).then(o=>{Qh=o;const s=new ResizeObserver(()=>o.view.resize().run());s.observe(ls),Kh=s,t||awe(),requestAnimationFrame(()=>{if(t)Ao.sendSizeChanged({height:window.innerHeight-150});else{const a=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:a})}})}).catch(o=>{ls.innerHTML=`
Chart render error: ${o.message}
`})}function awe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",uwe),ls.appendChild(e)}async function uwe(){try{Pk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Lk(sd)}catch{}}function lwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new JW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=lwe(e);t?(sd=t,Lk(t)):ls.innerHTML='
No chart data in tool result
'};Ao.ontoolinput=()=>{ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&KF(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Pk=e.displayMode,sd&&Lk(sd))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&KF(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 6bd1833f..5ef86c91 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -6,8 +6,13 @@ import { expressionInterpreter } from "vega-interpreter"; const container = document.getElementById("chart")!; let currentDisplayMode: "inline" | "fullscreen" = "inline"; let lastSpec: Record | null = null; +let activeObserver: ResizeObserver | null = null; +let activeView: { finalize: () => void } | null = null; function renderChart(vegaSpec: Record) { + if (activeObserver) { activeObserver.disconnect(); activeObserver = null; } + if (activeView) { activeView.finalize(); activeView = null; } + container.innerHTML = ""; const isFullscreen = currentDisplayMode === "fullscreen"; document.documentElement.classList.toggle("fullscreen", isFullscreen); @@ -26,8 +31,10 @@ function renderChart(vegaSpec: Record) { expr: expressionInterpreter, }) .then((result) => { + activeView = result; const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); + activeObserver = ro; if (!isFullscreen) { addExpandButton(); From 9d4786aca103a97461315a9600b6187fe2c521a6 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 19:54:59 -0700 Subject: [PATCH 05/10] Guard against stale renders and clean up chart on non-chart content - Generation counter ensures out-of-order embed promises discard stale results - cleanupChart() called before error/loading content to prevent leaked observers --- sidemantic/apps/chart.html | 50 ++++++++++++++++---------------- sidemantic/apps/web/chart-app.ts | 14 ++++++++- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 1567e69d..6b7749e0 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,nwe="chart-wrapper";function iwe(e){return typeof e=="function"}function VR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${df[i]} JSON Source`}function rwe(e,t,n){if(e.$schema){const i=z9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${df[i.library]}, but mode argument sets ${df[n]??n}.`);const r=i.library;return Z9(fm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${df[r]} ${i.version}, but the current version of ${df[r]} is v${fm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Y9(e){return!!(e&&"load"in e)}function YR(e){return Y9(e)?e:xr.loader(e)}function owe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function swe(e,t,n={}){let i,r;te(t)?(r=YR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=owe(i),s=o.loader;(!r||s)&&(r=YR(n.loader??s));const a=await XR(o,r),u=await XR(n,r),l={...V9(u,a),config:Hl(u.config??{},a.config??{})};return await uwe(e,i,l,r)}async function XR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function awe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function uwe(e,t,n={},i){const r=n.theme?Hl(c_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:V9({},K_e,n.actions??{}),s={...Q_e,...n.i18n},a=n.renderer??"svg",u=n.logger??xm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=awe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?X_e.toString():n.defaultStyle,_.appendChild(E)}}const f=rwe(t,u,n.mode);let d=ewe[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=z9(d.$schema);Z9(fm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${fm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(nwe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):Sy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??uL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Y9(w)?w==null?void 0:w.baseURL:void 0,E=iwe(S)?S:new x_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=twe,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Y_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Cy(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Lk="inline",sd=null,kp=null,$p=null,Kh=0;function Bk(){kp&&(kp.disconnect(),kp=null),$p&&($p.finalize(),$p=null)}function Uk(e){var o;Bk();const t=++Kh;ls.innerHTML="";const n=Lk==="fullscreen";document.documentElement.classList.toggle("fullscreen",n);const i={...e};i.width="container",i.height=n?"container":500,i.background="transparent";const r=(o=window.matchMedia)==null?void 0:o.call(window,"(prefers-color-scheme: dark)").matches;swe(ls,i,{actions:!1,theme:r?"dark":void 0,ast:!0,expr:uL}).then(s=>{if(t!==Kh){s.finalize();return}$p=s;const a=new ResizeObserver(()=>s.view.resize().run());a.observe(ls),kp=a,n||lwe(),requestAnimationFrame(()=>{if(t===Kh)if(n)Ao.sendSizeChanged({height:window.innerHeight-150});else{const u=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:u})}})}).catch(s=>{t===Kh&&(ls.innerHTML=`
Chart render error: ${s.message}
`)})}function lwe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",cwe),ls.appendChild(e)}async function cwe(){try{Lk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,sd&&Uk(sd)}catch{}}function fwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new QW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=fwe(e);t?(sd=t,Uk(t)):(Bk(),ls.innerHTML='
No chart data in tool result
')};Ao.ontoolinput=()=>{Bk(),ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&eN(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Lk=e.displayMode,sd&&Uk(sd))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&eN(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 5ef86c91..99e102da 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -8,10 +8,16 @@ let currentDisplayMode: "inline" | "fullscreen" = "inline"; let lastSpec: Record | null = null; let activeObserver: ResizeObserver | null = null; let activeView: { finalize: () => void } | null = null; +let renderGeneration = 0; -function renderChart(vegaSpec: Record) { +function cleanupChart() { if (activeObserver) { activeObserver.disconnect(); activeObserver = null; } if (activeView) { activeView.finalize(); activeView = null; } +} + +function renderChart(vegaSpec: Record) { + cleanupChart(); + const generation = ++renderGeneration; container.innerHTML = ""; const isFullscreen = currentDisplayMode === "fullscreen"; @@ -31,6 +37,8 @@ function renderChart(vegaSpec: Record) { expr: expressionInterpreter, }) .then((result) => { + if (generation !== renderGeneration) { result.finalize(); return; } + activeView = result; const ro = new ResizeObserver(() => result.view.resize().run()); ro.observe(container); @@ -41,6 +49,7 @@ function renderChart(vegaSpec: Record) { } requestAnimationFrame(() => { + if (generation !== renderGeneration) return; if (isFullscreen) { app.sendSizeChanged({ height: window.innerHeight - 150 }); } else { @@ -50,6 +59,7 @@ function renderChart(vegaSpec: Record) { }); }) .catch((err) => { + if (generation !== renderGeneration) return; container.innerHTML = `
Chart render error: ${err.message}
`; }); } @@ -101,11 +111,13 @@ app.ontoolresult = (result: CallToolResult) => { lastSpec = spec; renderChart(spec); } else { + cleanupChart(); container.innerHTML = '
No chart data in tool result
'; } }; app.ontoolinput = () => { + cleanupChart(); container.innerHTML = '
Running query...
'; }; From 4d8fe76d1d023c5edbea4497f64df42ac68fb3cc Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:00:41 -0700 Subject: [PATCH 06/10] Invalidate in-flight embeds on tool input, clear stale spec on error --- sidemantic/apps/chart.html | 42 ++++++++++++++++---------------- sidemantic/apps/web/chart-app.ts | 2 ++ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 6b7749e0..75914446 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,nwe="chart-wrapper";function iwe(e){return typeof e=="function"}function VR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${hf[i]} JSON Source`}function rwe(e,t,n){if(e.$schema){const i=z9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${hf[i.library]}, but mode argument sets ${hf[n]??n}.`);const r=i.library;return Z9(fm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${hf[r]} ${i.version}, but the current version of ${hf[r]} is v${fm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Y9(e){return!!(e&&"load"in e)}function YR(e){return Y9(e)?e:xr.loader(e)}function owe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function swe(e,t,n={}){let i,r;te(t)?(r=YR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=owe(i),s=o.loader;(!r||s)&&(r=YR(n.loader??s));const a=await XR(o,r),u=await XR(n,r),l={...V9(u,a),config:Gl(u.config??{},a.config??{})};return await uwe(e,i,l,r)}async function XR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function awe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function uwe(e,t,n={},i){const r=n.theme?Gl(c_e[n.theme],n.config??{}):n.config,o=tu(n.actions)?n.actions:V9({},K_e,n.actions??{}),s={...Q_e,...n.i18n},a=n.renderer??"svg",u=n.logger??xm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=awe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?X_e.toString():n.defaultStyle,_.appendChild(E)}}const f=rwe(t,u,n.mode);let d=ewe[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=z9(d.$schema);Z9(fm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${fm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(nwe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):Sy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??uL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Y9(w)?w==null?void 0:w.baseURL:void 0,E=iwe(S)?S:new x_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=twe,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Y_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Cy(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Lk="inline",jl=null,kp=null,$p=null,pf=0;function Bk(){kp&&(kp.disconnect(),kp=null),$p&&($p.finalize(),$p=null)}function Uk(e){var o;Bk();const t=++pf;ls.innerHTML="";const n=Lk==="fullscreen";document.documentElement.classList.toggle("fullscreen",n);const i={...e};i.width="container",i.height=n?"container":500,i.background="transparent";const r=(o=window.matchMedia)==null?void 0:o.call(window,"(prefers-color-scheme: dark)").matches;swe(ls,i,{actions:!1,theme:r?"dark":void 0,ast:!0,expr:uL}).then(s=>{if(t!==pf){s.finalize();return}$p=s;const a=new ResizeObserver(()=>s.view.resize().run());a.observe(ls),kp=a,n||lwe(),requestAnimationFrame(()=>{if(t===pf)if(n)Ao.sendSizeChanged({height:window.innerHeight-150});else{const u=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:u})}})}).catch(s=>{t===pf&&(ls.innerHTML=`
Chart render error: ${s.message}
`)})}function lwe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",cwe),ls.appendChild(e)}async function cwe(){try{Lk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,jl&&Uk(jl)}catch{}}function fwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new QW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=fwe(e);t?(jl=t,Uk(t)):(Bk(),jl=null,ls.innerHTML='
No chart data in tool result
')};Ao.ontoolinput=()=>{Bk(),++pf,ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&eN(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Lk=e.displayMode,jl&&Uk(jl))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&eN(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 99e102da..18d9d0bb 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -112,12 +112,14 @@ app.ontoolresult = (result: CallToolResult) => { renderChart(spec); } else { cleanupChart(); + lastSpec = null; container.innerHTML = '
No chart data in tool result
'; } }; app.ontoolinput = () => { cleanupChart(); + ++renderGeneration; container.innerHTML = '
Running query...
'; }; From aa70f56d983937344582a54e07a02c8d73cb1b6b Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:23:51 -0700 Subject: [PATCH 07/10] Clear cached spec on new tool input to prevent stale chart resurrection --- sidemantic/apps/chart.html | 26 +++++++++++++------------- sidemantic/apps/web/chart-app.ts | 1 + 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/sidemantic/apps/chart.html b/sidemantic/apps/chart.html index 75914446..d3c3e78f 100644 --- a/sidemantic/apps/chart.html +++ b/sidemantic/apps/chart.html @@ -27,9 +27,9 @@ .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } } - +`,nwe="chart-wrapper";function iwe(e){return typeof e=="function"}function VR(e,t,n,i){const r=`${t}
`,o=`
${n}`,s=window.open("");s.document.write(r+e+o),s.document.title=`${hf[i]} JSON Source`}function rwe(e,t,n){if(e.$schema){const i=z9(e.$schema);n&&n!==i.library&&t.warn(`The given visualization spec is written in ${hf[i.library]}, but mode argument sets ${hf[n]??n}.`);const r=i.library;return Z9(fm[r],`^${i.version.slice(1)}`)||t.warn(`The input spec uses ${hf[r]} ${i.version}, but the current version of ${hf[r]} is v${fm[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":n??"vega"}function Y9(e){return!!(e&&"load"in e)}function YR(e){return Y9(e)?e:xr.loader(e)}function owe(e){var n;const t=((n=e.usermeta)==null?void 0:n.embedOptions)??{};return te(t.defaultStyle)&&(t.defaultStyle=!1),t}async function swe(e,t,n={}){let i,r;te(t)?(r=YR(n.loader),i=JSON.parse(await r.load(t))):i=t;const o=owe(i),s=o.loader;(!r||s)&&(r=YR(n.loader??s));const a=await XR(o,r),u=await XR(n,r),l={...V9(u,a),config:Gl(u.config??{},a.config??{})};return await uwe(e,i,l,r)}async function XR(e,t){const n=te(e.config)?JSON.parse(await t.load(e.config)):e.config??{},i=te(e.patch)?JSON.parse(await t.load(e.patch)):e.patch;return{...e,...i?{patch:i}:{},...n?{config:n}:{}}}function awe(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}async function uwe(e,t,n={},i){const r=n.theme?Gl(c_e[n.theme],n.config??{}):n.config,o=nu(n.actions)?n.actions:V9({},K_e,n.actions??{}),s={...Q_e,...n.i18n},a=n.renderer??"svg",u=n.logger??xm(xr.Warn);n.logLevel!==void 0&&u.level(n.logLevel);const l=n.downloadFileName??"visualization",c=typeof e=="string"?document.querySelector(e):e;if(!c)throw new Error(`${e} does not exist`);if(n.defaultStyle!==!1){const w="vega-embed-style",{root:S,rootContainer:_}=awe(c);if(!S.getElementById(w)){const E=document.createElement("style");E.id=w,E.innerHTML=n.defaultStyle===void 0||n.defaultStyle===!0?X_e.toString():n.defaultStyle,_.appendChild(E)}}const f=rwe(t,u,n.mode);let d=ewe[f](t,u,r);if(f==="vega-lite"&&d.$schema){const w=z9(d.$schema);Z9(fm.vega,`^${w.version.slice(1)}`)||u.warn(`The compiled spec uses Vega ${w.version}, but current version is v${fm.vega}.`)}c.classList.add("vega-embed"),o&&c.classList.add("has-actions"),c.innerHTML="";let h=c;if(o){const w=document.createElement("div");w.classList.add(nwe),c.appendChild(w),h=w}const p=n.patch;if(p&&(d=p instanceof Function?p(d):Sy(d,p,!0,!1).newDocument),n.formatLocale&&xr.formatLocale(n.formatLocale),n.timeFormatLocale&&xr.timeFormatLocale(n.timeFormatLocale),n.expressionFunctions)for(const w in n.expressionFunctions){const S=n.expressionFunctions[w];"fn"in S?xr.expressionFunction(w,S.fn,S.visitor):S instanceof Function&&xr.expressionFunction(w,S)}const{ast:g}=n,m=xr.parse(d,f==="vega-lite"?{}:r,{ast:g}),y=new(n.viewClass||xr.View)(m,{loader:i,logger:u,renderer:a,...g?{expr:xr.expressionInterpreter??n.expr??uL}:{}});if(y.addSignalListener("autosize",(w,S)=>{const{type:_}=S;_=="fit-x"?(h.classList.add("fit-x"),h.classList.remove("fit-y")):_=="fit-y"?(h.classList.remove("fit-x"),h.classList.add("fit-y")):_=="fit"?h.classList.add("fit-x","fit-y"):h.classList.remove("fit-x","fit-y")}),n.tooltip!==!1){const{loader:w,tooltip:S}=n,_=w&&!Y9(w)?w==null?void 0:w.baseURL:void 0,E=iwe(S)?S:new x_e({baseURL:_,...S===!0?{}:S}).call;y.tooltip(E)}let{hover:b}=n;if(b===void 0&&(b=f==="vega"),b){const{hoverSet:w,updateSet:S}=typeof b=="boolean"?{}:b;y.hover(w,S)}n&&(n.width!=null&&y.width(n.width),n.height!=null&&y.height(n.height),n.padding!=null&&y.padding(n.padding)),await y.initialize(h,n.bind).runAsync();let v;if(o!==!1){let w=c;if(n.defaultStyle!==!1||n.forceActionsMenu){const _=document.createElement("details");_.title=s.CLICK_TO_VIEW_ACTIONS,c.append(_),w=_;const E=document.createElement("summary");E.innerHTML=twe,_.append(E),v=k=>{_.contains(k.target)||_.removeAttribute("open")},document.addEventListener("click",v)}const S=document.createElement("div");if(w.append(S),S.classList.add("vega-actions"),o===!0||o.export!==!1){for(const _ of["svg","png"])if(o===!0||o.export===!0||o.export[_]){const E=s[`${_.toUpperCase()}_ACTION`],k=document.createElement("a"),A=ee(n.scaleFactor)?n.scaleFactor[_]:n.scaleFactor;k.text=E,k.href="#",k.target="_blank",k.download=`${l}.${_}`,k.addEventListener("mousedown",async function(T){T.preventDefault();const R=await y.toImageURL(_,A);this.href=R}),S.append(k)}}if(o===!0||o.source!==!1){const _=document.createElement("a");_.text=s.SOURCE_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(t),n.sourceHeader??"",n.sourceFooter??"",f),E.preventDefault()}),S.append(_)}if(f==="vega-lite"&&(o===!0||o.compiled!==!1)){const _=document.createElement("a");_.text=s.COMPILED_ACTION,_.href="#",_.addEventListener("click",function(E){VR(Cy(d),n.sourceHeader??"",n.sourceFooter??"","vega"),E.preventDefault()}),S.append(_)}if(o===!0||o.editor!==!1){const _=n.editorUrl??"https://vega.github.io/editor/",E=document.createElement("a");E.text=s.EDITOR_ACTION,E.href="#",E.addEventListener("click",function(k){Y_e(window,_,{config:r,mode:p?"vega":f,renderer:a,spec:Cy(p?d:t)}),k.preventDefault()}),S.append(E)}}function x(){v&&document.removeEventListener("click",v),y.finalize()}return{view:y,spec:t,vgSpec:d,finalize:x,embedOptions:n}}const ls=document.getElementById("chart");let Lk="inline",eu=null,kp=null,$p=null,pf=0;function Bk(){kp&&(kp.disconnect(),kp=null),$p&&($p.finalize(),$p=null)}function Uk(e){var o;Bk();const t=++pf;ls.innerHTML="";const n=Lk==="fullscreen";document.documentElement.classList.toggle("fullscreen",n);const i={...e};i.width="container",i.height=n?"container":500,i.background="transparent";const r=(o=window.matchMedia)==null?void 0:o.call(window,"(prefers-color-scheme: dark)").matches;swe(ls,i,{actions:!1,theme:r?"dark":void 0,ast:!0,expr:uL}).then(s=>{if(t!==pf){s.finalize();return}$p=s;const a=new ResizeObserver(()=>s.view.resize().run());a.observe(ls),kp=a,n||lwe(),requestAnimationFrame(()=>{if(t===pf)if(n)Ao.sendSizeChanged({height:window.innerHeight-150});else{const u=Math.max(505,document.documentElement.scrollHeight+5);Ao.sendSizeChanged({height:u})}})}).catch(s=>{t===pf&&(ls.innerHTML=`
Chart render error: ${s.message}
`)})}function lwe(){const e=document.createElement("div");e.className="expand-btn",e.title="Expand to fullscreen",e.textContent="Expand ↗",e.addEventListener("click",cwe),ls.appendChild(e)}async function cwe(){try{Lk=(await Ao.requestDisplayMode({mode:"fullscreen"})).mode,eu&&Uk(eu)}catch{}}function fwe(e){const t=e.structuredContent;if(t!=null&&t.vega_spec)return t.vega_spec;if(e.content){for(const n of e.content)if(n.type==="text")try{const i=JSON.parse(n.text);if(i.vega_spec)return i.vega_spec}catch{}}return null}const Ao=new QW({name:"sidemantic-chart",version:"1.0.0"},{},{autoResize:!1});Ao.ontoolresult=e=>{const t=fwe(e);t?(eu=t,Uk(t)):(Bk(),eu=null,ls.innerHTML='
No chart data in tool result
')};Ao.ontoolinput=()=>{Bk(),eu=null,++pf,ls.innerHTML='
Running query...
'};Ao.onhostcontextchanged=e=>{e.theme&&eN(e.theme),(e.displayMode==="inline"||e.displayMode==="fullscreen")&&(Lk=e.displayMode,eu&&Uk(eu))};Ao.connect().then(()=>{const e=Ao.getHostContext();e!=null&&e.theme&&eN(e.theme);const t=ls.querySelector(".loading");t&&(t.textContent="Waiting for chart data..."),Ao.sendSizeChanged({height:500})});
diff --git a/sidemantic/apps/web/chart-app.ts b/sidemantic/apps/web/chart-app.ts index 18d9d0bb..7bbefe0b 100644 --- a/sidemantic/apps/web/chart-app.ts +++ b/sidemantic/apps/web/chart-app.ts @@ -119,6 +119,7 @@ app.ontoolresult = (result: CallToolResult) => { app.ontoolinput = () => { cleanupChart(); + lastSpec = null; ++renderGeneration; container.innerHTML = '
Running query...
'; }; From 8e2ce34f37413a632c12b4611419da9c873c0689 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:25:50 -0700 Subject: [PATCH 08/10] Add interactive metrics explorer as MCP App - New explore_metrics tool launches dashboard with metric series, totals, and dimension leaderboards via ui://sidemantic/explorer - App-only widget_query tool (visibility: ["app"]) handles refresh calls from the widget without LLM round-trips - WidgetModel adapter bridges anywidget's model interface to ext-apps SDK, translating set/save_changes into callServerTool requests - Data transported as base64 Arrow IPC for efficient typed transfer - Multi-entry Vite build for chart (960KB) and explorer (336KB) widgets - Add pyarrow to apps optional dependency --- pyproject.toml | 1 + sidemantic/apps/__init__.py | 25 +- sidemantic/apps/explorer.html | 194 +++++++++++ sidemantic/apps/web/explorer-app.ts | 268 +++++++++++++++ sidemantic/apps/web/explorer.html | 35 ++ sidemantic/apps/web/package.json | 4 +- sidemantic/apps/web/vite.config.ts | 11 +- sidemantic/mcp_server.py | 504 ++++++++++++++++++++++++++++ uv.lock | 2 + 9 files changed, 1032 insertions(+), 12 deletions(-) create mode 100644 sidemantic/apps/explorer.html create mode 100644 sidemantic/apps/web/explorer-app.ts create mode 100644 sidemantic/apps/web/explorer.html diff --git a/pyproject.toml b/pyproject.toml index c471ebd1..ee732036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ mcp = [ ] apps = [ "mcp[cli]>=1.25.0,<2", + "pyarrow>=14.0.0", ] charts = [ "altair>=5.0.0", diff --git a/sidemantic/apps/__init__.py b/sidemantic/apps/__init__.py index 476c3f18..41cd53bc 100644 --- a/sidemantic/apps/__init__.py +++ b/sidemantic/apps/__init__.py @@ -8,18 +8,33 @@ from pathlib import Path -_WIDGET_HTML: str | None = None +_CHART_HTML: str | None = None +_EXPLORER_HTML: str | None = None def _get_widget_template() -> str: """Load the built chart widget HTML for the MCP Apps resource handler.""" - global _WIDGET_HTML - if _WIDGET_HTML is None: + global _CHART_HTML + if _CHART_HTML is None: built = Path(__file__).parent / "chart.html" if built.exists(): - _WIDGET_HTML = built.read_text() + _CHART_HTML = built.read_text() else: raise FileNotFoundError( f"Chart widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build" ) - return _WIDGET_HTML + return _CHART_HTML + + +def _get_explorer_template() -> str: + """Load the built explorer widget HTML for the MCP Apps resource handler.""" + global _EXPLORER_HTML + if _EXPLORER_HTML is None: + built = Path(__file__).parent / "explorer.html" + if built.exists(): + _EXPLORER_HTML = built.read_text() + else: + raise FileNotFoundError( + f"Explorer widget not built at {built}. Run: cd sidemantic/apps/web && bun install && bun run build" + ) + return _EXPLORER_HTML diff --git a/sidemantic/apps/explorer.html b/sidemantic/apps/explorer.html new file mode 100644 index 00000000..3600d971 --- /dev/null +++ b/sidemantic/apps/explorer.html @@ -0,0 +1,194 @@ + + + + + + + + + + +
+
Loading...
+
+ + diff --git a/sidemantic/apps/web/explorer-app.ts b/sidemantic/apps/web/explorer-app.ts new file mode 100644 index 00000000..cb9b6cd3 --- /dev/null +++ b/sidemantic/apps/web/explorer-app.ts @@ -0,0 +1,268 @@ +import { App, applyDocumentTheme, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +// @ts-ignore - pre-built anywidget module +import widgetModule from "../../widget/static/widget.js"; + +const container = document.getElementById("explorer")!; +let currentDisplayMode: "inline" | "fullscreen" = "inline"; + +// Resolve render function: default export is { render: md } but bundler +// may expose it as widgetModule.render or widgetModule.default.render. +const renderWidget: (ctx: { model: WidgetModel; el: HTMLElement }) => (() => void) | void = + widgetModule.render || (widgetModule as any).default?.render; + +// The WidgetModel adapter bridges anywidget's model interface to MCP App tool calls. +// When widget.js calls model.set() + model.save_changes(), we determine what data +// needs refreshing and call the widget_query tool on the MCP server. + +class WidgetModel { + private state: Record = {}; + private listeners: Map> = new Map(); + private pendingChanges: Set = new Set(); + private app: App; + + constructor(app: App) { + this.app = app; + } + + get(field: string): any { + return this.state[field]; + } + + set(field: string, value: any): void { + this.state[field] = value; + this.pendingChanges.add(field); + } + + save_changes(): void { + const changed = new Set(this.pendingChanges); + this.pendingChanges.clear(); + + // Determine what to refresh based on what changed. + // These mirror the Python widget's observer logic: + // - filters -> all (or dimensions if active_dimension set) + // - brush_selection -> all + // - selected_metric -> dimensions + // - time_grain -> metrics + // - active_dimension -> special handling + // + // active_dimension is set briefly during filter changes, then cleared + // after 400ms. When it's set, only refresh that dimension. When cleared, + // full refresh. We handle this by checking if active_dimension was just + // set (don't query yet) or if it was just cleared or not involved + // (query based on other changes). + + if (changed.has("active_dimension")) { + const ad = this.state.active_dimension; + if (ad) { + // Just set: don't query yet, widget will clear it in 400ms + return; + } + // Was cleared: do a full refresh + this.callRefresh("all"); + return; + } + + if (changed.has("filters")) { + const ad = this.state.active_dimension; + if (ad) { + this.callRefresh("dimensions"); + } else { + this.callRefresh("all"); + } + return; + } + + if (changed.has("brush_selection")) { + this.callRefresh("all"); + return; + } + + if (changed.has("selected_metric")) { + this.callRefresh("dimensions"); + return; + } + + if (changed.has("time_grain")) { + this.callRefresh("metrics"); + return; + } + } + + on(event: string, callback: Function): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + off(event: string, callback?: Function): void { + if (!callback) { + this.listeners.delete(event); + } else { + this.listeners.get(event)?.delete(callback); + } + } + + // Fire change event for a field + private fireChange(field: string): void { + const event = `change:${field}`; + this.listeners.get(event)?.forEach((cb) => cb()); + } + + // Apply data from tool result, updating state and firing change events + applyData(data: Record): void { + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) { + this.state[key] = value; + this.fireChange(key); + } + } + } + + // Extract data dict from CallToolResult + extractData(result: CallToolResult): Record | null { + // Check structuredContent first + const sc = result.structuredContent as Record | undefined; + if (sc) return sc; + + // Fall back to text content + if (result.content) { + for (const item of result.content) { + if (item.type === "text") { + try { + return JSON.parse((item as { text: string }).text); + } catch { + // not JSON, skip + } + } + } + } + return null; + } + + // Call the widget_query tool on the MCP server + private async callRefresh(queryType: string): Promise { + // Show loading state + this.state.status = "loading"; + this.fireChange("status"); + + try { + const result = await this.app.callServerTool({ + name: "widget_query", + arguments: { + query_type: queryType, + selected_metric: this.state.selected_metric || "", + time_grain: this.state.time_grain || "day", + filters_json: JSON.stringify(this.state.filters || {}), + brush_selection_json: JSON.stringify(this.state.brush_selection || []), + active_dimension: this.state.active_dimension || "", + }, + }); + + // Extract data from tool result + const data = this.extractData(result); + if (data) { + this.applyData(data); + } + } catch (err) { + this.state.status = "error"; + this.state.error = err instanceof Error ? err.message : String(err); + this.fireChange("status"); + this.fireChange("error"); + } + } +} + +// --- App setup --- + +const app = new App( + { name: "sidemantic-explorer", version: "1.0.0" }, + {}, + { autoResize: false }, +); + +const model = new WidgetModel(app); +let cleanup: (() => void) | null = null; + +function renderExplorer(): void { + if (cleanup) { + cleanup(); + cleanup = null; + } + container.innerHTML = ""; + + const isFullscreen = currentDisplayMode === "fullscreen"; + document.documentElement.classList.toggle("fullscreen", isFullscreen); + + // Create widget container + const widgetEl = document.createElement("div"); + container.appendChild(widgetEl); + + // Add expand button in inline mode + if (!isFullscreen) { + const btn = document.createElement("div"); + btn.className = "expand-btn"; + btn.title = "Expand to fullscreen"; + btn.textContent = "Expand \u2197"; + btn.addEventListener("click", async () => { + try { + const result = await app.requestDisplayMode({ mode: "fullscreen" }); + currentDisplayMode = result.mode as "inline" | "fullscreen"; + renderExplorer(); + } catch { + // host doesn't support fullscreen + } + }); + container.appendChild(btn); + } + + // Render the anywidget + const result = renderWidget({ model, el: widgetEl }); + if (typeof result === "function") { + cleanup = result; + } + + // Report size + requestAnimationFrame(() => { + if (isFullscreen) { + app.sendSizeChanged({ height: window.innerHeight - 150 }); + } else { + const h = Math.max(605, document.documentElement.scrollHeight + 5); + app.sendSizeChanged({ height: h }); + } + }); +} + +// Handle initial tool result (from explore_metrics) +app.ontoolresult = (result: CallToolResult) => { + const data = model.extractData(result); + if (data) { + model.applyData(data); + renderExplorer(); + } else { + container.innerHTML = '
No explorer data in tool result
'; + } +}; + +app.ontoolinput = () => { + container.innerHTML = '
Loading explorer...
'; +}; + +app.onhostcontextchanged = (ctx: McpUiHostContext) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.displayMode === "inline" || ctx.displayMode === "fullscreen") { + currentDisplayMode = ctx.displayMode; + if (model.get("status") === "ready") { + renderExplorer(); + } + } +}; + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.theme) applyDocumentTheme(ctx.theme); + const loading = container.querySelector(".loading"); + if (loading) loading.textContent = "Waiting for data..."; + app.sendSizeChanged({ height: 600 }); +}); diff --git a/sidemantic/apps/web/explorer.html b/sidemantic/apps/web/explorer.html new file mode 100644 index 00000000..4d52a605 --- /dev/null +++ b/sidemantic/apps/web/explorer.html @@ -0,0 +1,35 @@ + + + + + + + + + +
+
Loading...
+
+ + + diff --git a/sidemantic/apps/web/package.json b/sidemantic/apps/web/package.json index e3745bd7..5599690b 100644 --- a/sidemantic/apps/web/package.json +++ b/sidemantic/apps/web/package.json @@ -3,7 +3,9 @@ "private": true, "type": "module", "scripts": { - "build": "vite build" + "build": "ENTRY=chart vite build && ENTRY=explorer vite build", + "build:chart": "ENTRY=chart vite build", + "build:explorer": "ENTRY=explorer vite build" }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.3.2", diff --git a/sidemantic/apps/web/vite.config.ts b/sidemantic/apps/web/vite.config.ts index e8b27df5..e5d06005 100644 --- a/sidemantic/apps/web/vite.config.ts +++ b/sidemantic/apps/web/vite.config.ts @@ -1,20 +1,19 @@ import { defineConfig } from "vite"; import { viteSingleFile } from "vite-plugin-singlefile"; +import path from "path"; + +const entry = process.env.ENTRY || "chart"; export default defineConfig({ plugins: [viteSingleFile()], build: { - rollupOptions: { input: "chart.html" }, + rollupOptions: { input: `${entry}.html` }, outDir: "../", emptyOutDir: false, }, - define: { - // Replace new Function calls with a safe fallback at build time - // This prevents CSP violations in MCP Apps sandboxes - }, resolve: { alias: { - // Use CSP-safe expression interpreter + // Use CSP-safe expression interpreter (chart widget) "vega-functions/codegenExpression": "vega-interpreter", }, }, diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index c464c848..5e512f4e 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -699,6 +699,493 @@ def get_semantic_graph() -> dict[str, Any]: return result +# --- Explorer state --- + +_explorer_state: dict | None = None + + +def _table_to_ipc_base64(table, *, decimal_mode: str = "float") -> str: + """Serialize a PyArrow table to base64-encoded Arrow IPC bytes.""" + import base64 + import io + + import pyarrow as pa + import pyarrow.compute as pc + + if any(pa.types.is_decimal(field.type) for field in table.schema): + arrays = [] + fields = [] + for field in table.schema: + column = table[field.name] + if pa.types.is_decimal(field.type): + cast_type = pa.string() if decimal_mode == "string" else pa.float64() + arrays.append(pc.cast(column, cast_type)) + fields.append(pa.field(field.name, cast_type)) + else: + arrays.append(column) + fields.append(field) + table = pa.table(arrays, schema=pa.schema(fields)) + + sink = io.BytesIO() + with pa.ipc.new_file(sink, table.schema) as writer: + writer.write_table(table) + return base64.b64encode(sink.getvalue()).decode("ascii") + + +def _execute_arrow(layer, sql): + """Execute SQL via the layer adapter and return a PyArrow Table.""" + result = layer.adapter.execute(sql) + reader = result.fetch_record_batch() + return reader.read_all() + + +def _escape_sql_literal(value: str) -> str: + """Escape a string for use inside a SQL single-quoted literal.""" + return value.replace("'", "''") + + +def _build_explorer_filters(state: dict, *, exclude_dimension: str | None = None) -> list[str]: + """Build SQL filter expressions from explorer state. + + State dict keys: model_name, time_dimension, filters, date_range, brush_selection. + """ + filter_exprs: list[str] = [] + model_name = state["model_name"] + time_dim = state.get("time_dimension") + + # Determine effective date range (brush overrides date_range) + brush = state.get("brush_selection", []) + date_range = brush if len(brush) == 2 else state.get("date_range", []) + + if time_dim and len(date_range) == 2: + start_str = str(date_range[0]) + end_str = str(date_range[1]) + start_literal = _format_date_literal(start_str) + end_literal = _format_date_literal(end_str) + filter_exprs.append(f"{model_name}.{time_dim} >= {start_literal} AND {model_name}.{time_dim} <= {end_literal}") + + # Dimension filters + for dim_key, values in state.get("filters", {}).items(): + if exclude_dimension and dim_key == exclude_dimension: + continue + if not values: + continue + if len(values) == 1: + if values[0] is None: + filter_exprs.append(f"{model_name}.{dim_key} IS NULL") + else: + safe = _escape_sql_literal(str(values[0])) + filter_exprs.append(f"{model_name}.{dim_key} = '{safe}'") + else: + clauses: list[str] = [] + for v in values: + if v is None: + clauses.append(f"{model_name}.{dim_key} IS NULL") + else: + safe = _escape_sql_literal(str(v)) + clauses.append(f"{model_name}.{dim_key} = '{safe}'") + filter_exprs.append(f"({' OR '.join(clauses)})") + + return filter_exprs + + +def _format_date_literal(value: str) -> str: + """Format a date/datetime string as a SQL CAST expression.""" + # Date-only: exactly 10 chars like "2024-01-01" + if len(value) == 10 and value[4] == "-" and value[7] == "-": + return f"CAST('{value}' AS DATE)" + return f"CAST('{value}' AS TIMESTAMP)" + + +def _detect_time_series_column(table, *, grain: str) -> str | None: + """Find the time-series column name from an Arrow schema.""" + import pyarrow as pa + + schema = getattr(table, "schema", None) + if schema is None: + return None + for field in schema: + if pa.types.is_date(field.type) or pa.types.is_timestamp(field.type): + return field.name + suffix = f"__{grain}" + for field in schema: + if suffix in field.name: + return field.name + return None + + +@mcp.tool( + structured_output=False, + meta={ + "ui": { + "resourceUri": "ui://sidemantic/explorer", + "csp": {"connectDomains": [], "resourceDomains": []}, + }, + }, +) +def explore_metrics( + model_name: str = "", + metrics: list[str] = [], + dimensions: list[str] = [], + time_dimension: str = "", +) -> dict[str, Any]: + """Launch an interactive metrics explorer for a semantic model. + + Opens a dashboard showing metric time series, totals, and dimension + leaderboards. All parameters are optional: without arguments, uses the + first model and auto-discovers metrics, dimensions, and time dimension. + + Args: + model_name: Model to explore (defaults to first model) + metrics: Metric refs to show (e.g., ["orders.revenue"]). Defaults to all. + dimensions: Dimension refs for leaderboards. Defaults to categorical/boolean dims. + time_dimension: Time dimension name for series. Defaults to model default. + + Returns: + Explorer configuration and initial data for the interactive widget. + """ + global _explorer_state + + layer = get_layer() + graph = layer.graph + model_names = list(graph.models.keys()) + if not model_names: + raise ValueError("SemanticLayer has no models") + + # Resolve model + if not model_name: + model_name = model_names[0] + model = graph.models.get(model_name) + if model is None: + raise ValueError(f"Model '{model_name}' not found. Available: {model_names}") + + # Resolve metrics + if not metrics: + metrics = [f"{model_name}.{m.name}" for m in (model.metrics or [])] + + # Resolve dimensions (categorical/boolean only for leaderboards) + if not dimensions: + dimensions = [ + f"{model_name}.{d.name}" for d in (model.dimensions or []) if d.type in ("categorical", "boolean") + ] + + # Resolve time dimension + if not time_dimension: + if model.default_time_dimension: + time_dimension = model.default_time_dimension + else: + time_dims = [d for d in (model.dimensions or []) if d.type == "time"] + if time_dims: + time_dimension = time_dims[0].name + + # Build config + config = { + "model_name": model_name, + "time_dimension": time_dimension, + "time_dimension_ref": f"{model_name}.{time_dimension}" if time_dimension else None, + } + + # Build metrics config + metrics_config = [] + for metric_ref in metrics: + metric_name = metric_ref.split(".")[-1] + metrics_config.append( + { + "key": metric_name, + "ref": metric_ref, + "label": metric_name.replace("_", " ").title(), + "format": "number", + } + ) + + # Build dimensions config + dimensions_config = [] + for dim_ref in dimensions: + dim_name = dim_ref.split(".")[-1] + dimensions_config.append( + { + "key": dim_name, + "ref": dim_ref, + "label": dim_name.replace("_", " ").title(), + } + ) + + # Time grain options + time_dim_obj = model.get_dimension(time_dimension) if time_dimension else None + time_grain_options = ( + time_dim_obj.supported_granularities + if time_dim_obj and time_dim_obj.supported_granularities + else ["day", "week", "month", "quarter", "year"] + ) + default_grain = model.default_grain or (time_dim_obj.granularity if time_dim_obj else None) or "day" + if default_grain not in time_grain_options: + time_grain_options = [default_grain] + [g for g in time_grain_options if g != default_grain] + + # Default selected metric + selected_metric = metrics_config[0]["key"] if metrics_config else "" + + # Compute date range from the underlying table + date_range: list[str] = [] + if time_dimension and model.table: + try: + from sidemantic.widget._widget import _quote_qualified_name + + dialect = layer.dialect or "duckdb" + table_ref = _quote_qualified_name(model.table, dialect=dialect) + time_col = _quote_qualified_name(time_dimension, dialect=dialect) + range_sql = f"SELECT MIN({time_col}) as min_date, MAX({time_col}) as max_date FROM {table_ref}" + range_result = layer.adapter.execute(range_sql).fetchone() + if range_result and range_result[0] is not None and range_result[1] is not None: + min_val, max_val = range_result[0], range_result[1] + # Stringify + for val in (min_val, max_val): + if isinstance(val, datetime): + date_range.append(val.isoformat(sep=" ")) + elif isinstance(val, date): + date_range.append(val.isoformat()) + else: + date_range.append(str(val)) + except Exception: + pass + + # Build date filters for initial queries + date_filters: list[str] = [] + if time_dimension and len(date_range) == 2: + start_literal = _format_date_literal(date_range[0]) + end_literal = _format_date_literal(date_range[1]) + date_filters.append( + f"{model_name}.{time_dimension} >= {start_literal} AND {model_name}.{time_dimension} <= {end_literal}" + ) + + # Metric series query + metric_refs = [m["ref"] for m in metrics_config] + time_dim_ref_grain = f"{model_name}.{time_dimension}__{default_grain}" if time_dimension else None + time_dim_ref = f"{model_name}.{time_dimension}" if time_dimension else None + + metric_series_data = "" + time_series_column: str | None = None + if time_dim_ref_grain and metric_refs: + try: + series_sql = layer.compile( + metrics=metric_refs, + dimensions=[time_dim_ref_grain], + filters=date_filters or None, + order_by=[time_dim_ref] if time_dim_ref else None, + limit=500, + ) + series_result = _execute_arrow(layer, series_sql) + time_series_column = _detect_time_series_column(series_result, grain=default_grain) + metric_series_data = _table_to_ipc_base64(series_result, decimal_mode="float") + except Exception: + pass + + config["time_series_column"] = time_series_column + + # Metric totals query + metric_totals: dict[str, Any] = {} + if metric_refs: + try: + totals_sql = layer.compile( + metrics=metric_refs, + dimensions=[], + filters=date_filters or None, + ) + totals_row = layer.adapter.execute(totals_sql).fetchone() + if totals_row: + for i, metric_ref in enumerate(metric_refs): + value = _convert_to_json_compatible(totals_row[i]) + if isinstance(value, Decimal): + value = str(value) + metric_totals[metric_ref.split(".")[-1]] = value + except Exception: + pass + + # Dimension leaderboards + selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" + dimension_data: dict[str, str] = {} + if selected_metric_ref and dimensions_config: + for dim_config in dimensions_config: + dim_key = dim_config["key"] + dim_ref = dim_config["ref"] + try: + dim_sql = layer.compile( + metrics=[selected_metric_ref], + dimensions=[dim_ref], + filters=date_filters or None, + order_by=[f"{selected_metric_ref} DESC"], + limit=6, + ) + dim_result = _execute_arrow(layer, dim_sql) + dimension_data[dim_key] = _table_to_ipc_base64(dim_result, decimal_mode="string") + except Exception: + dimension_data[dim_key] = "" + + # Store state for widget_query + _explorer_state = { + "model_name": model_name, + "time_dimension": time_dimension, + "metrics_config": metrics_config, + "dimensions_config": dimensions_config, + "date_range": date_range, + "filters": {}, + "brush_selection": [], + } + + return { + "config": config, + "metrics_config": metrics_config, + "dimensions_config": dimensions_config, + "date_range": date_range, + "time_grain": default_grain, + "time_grain_options": time_grain_options, + "selected_metric": selected_metric, + "metric_series_data": metric_series_data, + "metric_totals": metric_totals, + "dimension_data": dimension_data, + "status": "ready", + } + + +@mcp.tool( + structured_output=False, + meta={"ui": {"visibility": ["app"]}}, +) +def widget_query( + query_type: str = "all", + selected_metric: str = "", + time_grain: str = "day", + filters_json: str = "{}", + brush_selection_json: str = "[]", + active_dimension: str = "", +) -> dict[str, Any]: + """Refresh explorer widget data (app-only, not visible to LLM). + + Called by the explorer widget to fetch updated metric series, totals, + and/or dimension leaderboard data after user interactions. + + Args: + query_type: What to refresh: "metrics", "dimensions", or "all" + selected_metric: Active metric key for dimension leaderboards + time_grain: Time granularity for metric series + filters_json: JSON-encoded dimension filters ({dim_key: [values]}) + brush_selection_json: JSON-encoded brush selection ([start, end] or []) + active_dimension: If set, only refresh this single dimension leaderboard + + Returns: + Updated data matching the requested query_type. + """ + global _explorer_state + + if _explorer_state is None: + raise RuntimeError("Explorer not initialized. Call explore_metrics first.") + + try: + layer = get_layer() + model_name = _explorer_state["model_name"] + time_dimension = _explorer_state["time_dimension"] + metrics_config = _explorer_state["metrics_config"] + dimensions_config = _explorer_state["dimensions_config"] + + filters = json.loads(filters_json) if filters_json else {} + brush_selection = json.loads(brush_selection_json) if brush_selection_json else [] + + # Update stored state + _explorer_state["filters"] = filters + _explorer_state["brush_selection"] = brush_selection + + # Build state dict for filter builder + state = { + "model_name": model_name, + "time_dimension": time_dimension, + "filters": filters, + "date_range": _explorer_state.get("date_range", []), + "brush_selection": brush_selection, + } + + result: dict[str, Any] = {"status": "ready"} + + # --- Metrics --- + if query_type in ("metrics", "all"): + metric_refs = [m["ref"] for m in metrics_config] + time_dim_ref_grain = f"{model_name}.{time_dimension}__{time_grain}" if time_dimension else None + time_dim_ref = f"{model_name}.{time_dimension}" if time_dimension else None + date_filters = _build_explorer_filters(state) + + # Metric series + metric_series_data = "" + time_series_column: str | None = None + if time_dim_ref_grain and metric_refs: + series_sql = layer.compile( + metrics=metric_refs, + dimensions=[time_dim_ref_grain], + filters=date_filters or None, + order_by=[time_dim_ref] if time_dim_ref else None, + limit=500, + ) + series_table = _execute_arrow(layer, series_sql) + time_series_column = _detect_time_series_column(series_table, grain=time_grain) + metric_series_data = _table_to_ipc_base64(series_table, decimal_mode="float") + + result["metric_series_data"] = metric_series_data + result["config"] = { + "model_name": model_name, + "time_dimension": time_dimension, + "time_dimension_ref": f"{model_name}.{time_dimension}" if time_dimension else None, + "time_series_column": time_series_column, + } + + # Metric totals + metric_totals: dict[str, Any] = {} + if metric_refs: + totals_sql = layer.compile( + metrics=metric_refs, + dimensions=[], + filters=date_filters or None, + ) + totals_row = layer.adapter.execute(totals_sql).fetchone() + if totals_row: + for i, metric_ref in enumerate(metric_refs): + value = _convert_to_json_compatible(totals_row[i]) + if isinstance(value, Decimal): + value = str(value) + metric_totals[metric_ref.split(".")[-1]] = value + result["metric_totals"] = metric_totals + + # --- Dimensions --- + if query_type in ("dimensions", "all"): + selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" + dimension_data: dict[str, str] = {} + + dims_to_query = dimensions_config + if active_dimension: + dims_to_query = [d for d in dimensions_config if d["key"] == active_dimension] + + if selected_metric_ref and dims_to_query: + for dim_config in dims_to_query: + dim_key = dim_config["key"] + dim_ref = dim_config["ref"] + dim_filters = _build_explorer_filters(state, exclude_dimension=dim_key) + try: + dim_sql = layer.compile( + metrics=[selected_metric_ref], + dimensions=[dim_ref], + filters=dim_filters or None, + order_by=[f"{selected_metric_ref} DESC"], + limit=6, + ) + dim_table = _execute_arrow(layer, dim_sql) + dimension_data[dim_key] = _table_to_ipc_base64(dim_table, decimal_mode="string") + except Exception: + dimension_data[dim_key] = "" + + result["dimension_data"] = dimension_data + + return result + + except Exception as e: + return {"status": "error", "error": str(e)} + + # --- MCP Resources --- @@ -719,6 +1206,23 @@ def chart_widget_resource() -> str: return _get_widget_template() +@mcp.resource( + "ui://sidemantic/explorer", + mime_type="text/html;profile=mcp-app", + meta={ + "ui": { + "csp": {"connectDomains": [], "resourceDomains": []}, + }, + "mcpui.dev/ui-preferred-frame-size": ["100%", "600px"], + }, +) +def explorer_widget_resource() -> str: + """Interactive metrics explorer widget for MCP Apps-compatible hosts.""" + from sidemantic.apps import _get_explorer_template + + return _get_explorer_template() + + @mcp.resource("semantic://catalog") def catalog_resource() -> str: """Postgres-compatible catalog metadata for the semantic layer. diff --git a/uv.lock b/uv.lock index fe26cc40..8e916e6e 100644 --- a/uv.lock +++ b/uv.lock @@ -3293,6 +3293,7 @@ api = [ ] apps = [ { name = "mcp", extra = ["cli"] }, + { name = "pyarrow" }, ] bigquery = [ { name = "google-cloud-bigquery" }, @@ -3438,6 +3439,7 @@ requires-dist = [ { name = "pure-sasl", marker = "extra == 'spark'", specifier = ">=0.6.2" }, { name = "pyarrow", marker = "extra == 'adbc'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'api'", specifier = ">=14.0.0" }, + { name = "pyarrow", marker = "extra == 'apps'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'bigquery'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'clickhouse'", specifier = ">=14.0.0" }, { name = "pyarrow", marker = "extra == 'databricks'", specifier = ">=14.0.0" }, From 6e28033d1b2528dfc09232c9e9fd75701d8ca95d Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 30 Mar 2026 20:30:27 -0700 Subject: [PATCH 09/10] Add start_date/end_date params to explore_metrics for time filtering --- sidemantic/mcp_server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index 5e512f4e..9dd1a30e 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -828,6 +828,8 @@ def explore_metrics( metrics: list[str] = [], dimensions: list[str] = [], time_dimension: str = "", + start_date: str = "", + end_date: str = "", ) -> dict[str, Any]: """Launch an interactive metrics explorer for a semantic model. @@ -840,6 +842,8 @@ def explore_metrics( metrics: Metric refs to show (e.g., ["orders.revenue"]). Defaults to all. dimensions: Dimension refs for leaderboards. Defaults to categorical/boolean dims. time_dimension: Time dimension name for series. Defaults to model default. + start_date: Start date filter (e.g., "2026-01-01"). Defaults to min date in data. + end_date: End date filter (e.g., "2026-03-30"). Defaults to max date in data. Returns: Explorer configuration and initial data for the interactive widget. @@ -948,6 +952,15 @@ def explore_metrics( except Exception: pass + # Override date range if start_date/end_date provided + if start_date or end_date: + if start_date and end_date: + date_range = [start_date, end_date] + elif start_date and len(date_range) == 2: + date_range = [start_date, date_range[1]] + elif end_date and len(date_range) == 2: + date_range = [date_range[0], end_date] + # Build date filters for initial queries date_filters: list[str] = [] if time_dimension and len(date_range) == 2: From 85b72785d64ef6c9091116c45a8d3fe2d05882e0 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 13 Apr 2026 19:43:54 -0700 Subject: [PATCH 10/10] Explorer: lazy loading, per-item queries, and metric series fixes - explore_metrics returns config only (instant), no data queries - Widget renders skeletons immediately, then fetches data via widget_query - widget_query restructured to query one metric or one dimension per call, preventing timeout from sequential batch of 33+ queries - Fix ORDER BY using raw timestamp instead of grain-suffixed column - Improve time_series_column detection: grain suffix > Arrow type > name match > first col - Cap auto-discovered metrics and dimensions at 8 each - Skip full-table date range scan when start_date/end_date provided - Remove outer try/except in widget_query so partial results still return - Expand button as top bar instead of absolute overlay - Add stderr logging for debugging MCP tool calls --- sidemantic/apps/explorer.html | 48 ++-- sidemantic/apps/web/explorer-app.ts | 133 +++++++--- sidemantic/apps/web/explorer.html | 8 +- sidemantic/mcp_server.py | 371 +++++++++++++--------------- 4 files changed, 294 insertions(+), 266 deletions(-) diff --git a/sidemantic/apps/explorer.html b/sidemantic/apps/explorer.html index 3600d971..24695247 100644 --- a/sidemantic/apps/explorer.html +++ b/sidemantic/apps/explorer.html @@ -10,12 +10,10 @@ .loading { padding: 2rem; text-align: center; color: #999; } .error { padding: 2rem; text-align: center; color: #dc2626; } .expand-btn { - position: absolute; top: 6px; right: 8px; z-index: 10; - cursor: pointer; color: #666; font-size: 13px; - line-height: 1; padding: 4px 8px; border-radius: 4px; - transition: color 0.2s, background 0.2s; + text-align: right; padding: 6px 12px; cursor: pointer; + color: #666; font-size: 13px; transition: color 0.2s; } - .expand-btn:hover { color: #333; background: rgba(0,0,0,0.06); } + .expand-btn:hover { color: #333; } @media (prefers-color-scheme: dark) { .expand-btn { color: #999; } .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } @@ -24,9 +22,9 @@ html.fullscreen #explorer { height: calc(100% - 150px); min-height: auto; } html.fullscreen { padding: 16px 24px 0; box-sizing: border-box; } - + `;let i=e.querySelector(".filter-pills"),n=e.querySelector(".metrics-col"),r=e.querySelector(".dimensions-grid"),s=e.querySelector(".metric-select"),o=e.querySelector(".grain-select"),a=e.querySelector(".widget-status"),l=null;function c(){return t.get("filters")||{}}function u(T){t.set("filters",T),t.save_changes()}function d(){l&&clearTimeout(l),l=setTimeout(()=>{t.set("active_dimension",""),t.save_changes()},400)}function b(){l&&(clearTimeout(l),l=null),t.set("active_dimension","")}function g(T,Z){let G={...c()},ke=G[T]||[];t.set("active_dimension",T),ke.includes(Z)?(G[T]=ke.filter(Oe=>Oe!==Z),G[T].length===0&&delete G[T]):G[T]=[...ke,Z],u(G),d()}function v(T,Z){let G={...c()},ke=G[T]||[];b(),G[T]=ke.filter(Oe=>Oe!==Z),G[T].length===0&&delete G[T],u(G)}function w(T,Z){T&&Z?(b(),t.set("brush_selection",[T,Z])):t.set("brush_selection",[]),t.save_changes()}function j(){b(),t.set("brush_selection",[]),t.save_changes()}function J(){let T=t.get("metrics_config")||[],Z=t.get("selected_metric")||"",G=t.get("date_range")||[],ke=t.get("brush_selection")||[],Oe=ke&&ke.length===2?ke:G,mt=t.get("metric_totals")||{},Ke=(t.get("transport")||"base64")==="binary"?t.get("metric_series_data_binary"):t.get("metric_series_data");if(!Ke||Ke.byteLength!==void 0&&Ke.byteLength===0){n.innerHTML="",T.forEach(()=>{n.appendChild(v0())});return}try{let $e=al(Ke);if(!$e){n.innerHTML='
Failed to parse data
';return}let oi=(t.get("config")||{}).time_series_column||Object.keys($e[0]||{}).find(rt=>rt.includes("time")||rt.includes("date")||rt==="__time"),Jd=oi?$e.map(rt=>$i(rt[oi])):[];n.innerHTML="",T.forEach(rt=>{let sa=$e.map(Wi=>Number(Wi[rt.key])||0),Wd=mt[rt.key]??sa.reduce((Wi,Yd)=>Wi+Yd,0),oa=w0(rt,sa,Wd,Oe,Z,Jd,null,w);oa.addEventListener("click",Wi=>{Wi.target.closest(".sparkline-wrap")||(t.set("selected_metric",rt.key),t.save_changes())}),n.appendChild(oa)})}catch($e){n.innerHTML=`
Error: ${$e.message}
`}}function K(){let T=t.get("dimensions_config")||[],Z=(t.get("transport")||"base64")==="binary"?t.get("dimension_data_binary")||{}:t.get("dimension_data")||{},G=t.get("selected_metric")||"",ke=c();r.innerHTML="",T.forEach(Oe=>{let mt=Z[Oe.key];if(!mt||(typeof mt=="string"?mt.length===0:mt.byteLength===0)){r.appendChild(_0(Oe.label));return}try{let Ke=al(mt);if(!Ke){let oi=document.createElement("div");oi.className="dim-card error",oi.innerHTML=`

${Oe.label}

Failed to parse data
`,r.appendChild(oi);return}let $e=S0(Oe,Ke,G,ke,g);r.appendChild($e)}catch(Ke){let $e=document.createElement("div");$e.className="dim-card error",$e.innerHTML=`

${Oe.label}

Error: ${Ke.message}
`,r.appendChild($e)}})}function Se(){let T=c(),Z=t.get("date_range")||[],G=t.get("brush_selection")||[];I0(i,T,Z,G,v,j)}function se(){let T=t.get("metrics_config")||[],Z=t.get("selected_metric")||"";s.innerHTML=T.map(G=>``).join("")}function q(){let T=t.get("time_grain_options")||[],Z=t.get("time_grain")||"";if(!T.length){o.innerHTML="",o.disabled=!0;return}o.disabled=!1,o.innerHTML=T.map(G=>{let ke=G.replace(/_/g," ").replace(/\b\w/g,Oe=>Oe.toUpperCase());return``}).join("")}function W(){let T=t.get("status")||"loading",Z=t.get("error")||"";T==="error"&&Z?(a.textContent=`Error: ${Z}`,a.className="widget-status error"):T==="loading"?(a.textContent="Loading...",a.className="widget-status loading"):(a.textContent="",a.className="widget-status")}function U(){W(),Se(),se(),q(),J(),K()}return s.addEventListener("change",T=>{b(),t.set("selected_metric",T.target.value),t.save_changes()}),o.addEventListener("change",T=>{t.set("time_grain",T.target.value),t.save_changes()}),t.on("change:metric_series_data",J),t.on("change:metric_totals",J),t.on("change:dimension_data",K),t.on("change:filters",()=>{Se(),K()}),t.on("change:brush_selection",Se),t.on("change:selected_metric",()=>{se(),J()}),t.on("change:time_grain",q),t.on("change:status",W),t.on("change:error",W),t.on("change:metrics_config",U),t.on("change:dimensions_config",U),t.on("change:time_grain_options",q),U(),()=>{t.off("change:metric_series_data",J),t.off("change:metric_totals",J),t.off("change:dimension_data",K),t.off("change:filters"),t.off("change:brush_selection",Se),t.off("change:selected_metric"),t.off("change:time_grain",q),t.off("change:status",W),t.off("change:error",W),t.off("change:metrics_config",U),t.off("change:dimensions_config",U),t.off("change:time_grain_options",q)}}var ll={render:T0};const Mi=document.getElementById("explorer");let Fs="inline";var cl;const k0=ll.render||((cl=ll.default)==null?void 0:cl.render);class O0{constructor(e){this.state={},this.listeners=new Map,this.pendingChanges=new Set,this._metricSeriesMap=null,this.app=e}get(e){return this.state[e]}set(e,i){this.state[e]=i,this.pendingChanges.add(e)}save_changes(){const e=new Set(this.pendingChanges);if(this.pendingChanges.clear(),e.has("active_dimension")){if(this.state.active_dimension)return;this.callRefreshPublic("all");return}if(e.has("filters")){this.state.active_dimension?this.callRefreshPublic("dimensions"):this.callRefreshPublic("all");return}if(e.has("brush_selection")){this.callRefreshPublic("all");return}if(e.has("selected_metric")){this.callRefreshPublic("dimensions");return}if(e.has("time_grain")){this.callRefreshPublic("metrics");return}}on(e,i){this.listeners.has(e)||this.listeners.set(e,new Set),this.listeners.get(e).add(i)}off(e,i){var n;i?(n=this.listeners.get(e))==null||n.delete(i):this.listeners.delete(e)}fireChange(e){var n;const i=`change:${e}`;(n=this.listeners.get(i))==null||n.forEach(r=>r())}applyData(e){for(const[i,n]of Object.entries(e))n!==void 0&&(this.state[i]=n,this.fireChange(i))}extractData(e){const i=e.structuredContent;if(i)return i;if(e.content){for(const n of e.content)if(n.type==="text")try{return JSON.parse(n.text)}catch{}}return null}callRefreshPublic(e){const i=JSON.stringify(this.state.filters||{}),n=JSON.stringify(this.state.brush_selection||[]),r=this.state.time_grain||"day",s=this.state.selected_metric||"";if(e==="all"||e==="metrics"){const o=this.state.metrics_config||[];for(const a of o)this.fetchMetric(a.key,r,i,n)}if(e==="all"||e==="dimensions"){const o=this.state.dimensions_config||[];for(const a of o)this.fetchDimension(a.key,s,i,n)}}async fetchMetric(e,i,n,r){try{const s=await this.app.callServerTool({name:"widget_query",arguments:{query_type:"metric",metric_key:e,time_grain:i,filters_json:n,brush_selection_json:r}}),o=this.extractData(s);if(o&&o.status!=="error"){if(o.metric_total!==void 0){const a={...this.state.metric_totals||{}};a[e]=o.metric_total,this.state.metric_totals=a,this.fireChange("metric_totals")}if(o.metric_series_data&&o.time_series_column){this.state.config={...this.state.config||{},time_series_column:o.time_series_column},this._metricSeriesMap||(this._metricSeriesMap={}),this._metricSeriesMap[e]=o.metric_series_data;const a=Object.keys(this._metricSeriesMap)[0];a&&(this.state.metric_series_data=this._metricSeriesMap[a],this.fireChange("metric_series_data"),this.fireChange("config"))}this.state.status="ready",this.fireChange("status")}}catch{}}async fetchDimension(e,i,n,r){try{const s=await this.app.callServerTool({name:"widget_query",arguments:{query_type:"dimension",dimension_key:e,selected_metric:i,filters_json:n,brush_selection_json:r}}),o=this.extractData(s);if(o&&o.dimension_data!==void 0){const a={...this.state.dimension_data||{}};a[e]=o.dimension_data,this.state.dimension_data=a,this.fireChange("dimension_data")}}catch{}}}const dt=new ev({name:"sidemantic-explorer",version:"1.0.0"},{},{autoResize:!1}),cn=new O0(dt);let Jn=null;function ra(){Jn&&(Jn(),Jn=null),Mi.innerHTML="";const t=Fs==="fullscreen";if(document.documentElement.classList.toggle("fullscreen",t),!t){const n=document.createElement("div");n.className="expand-btn",n.title="Expand to fullscreen",n.textContent="Expand ↗",n.addEventListener("click",async()=>{try{Fs=(await dt.requestDisplayMode({mode:"fullscreen"})).mode,ra()}catch{}}),Mi.appendChild(n)}const e=document.createElement("div");Mi.appendChild(e);const i=k0({model:cn,el:e});typeof i=="function"&&(Jn=i),requestAnimationFrame(()=>{if(t)dt.sendSizeChanged({height:window.innerHeight-150});else{const n=Math.max(605,document.documentElement.scrollHeight+5);dt.sendSizeChanged({height:n})}})}dt.ontoolresult=t=>{const e=cn.extractData(t);e?(cn.applyData(e),ra(),cn.callRefreshPublic("all")):Mi.innerHTML='
No explorer data in tool result
'};dt.ontoolinput=()=>{Mi.innerHTML='
Loading explorer...
'};dt.onhostcontextchanged=t=>{t.theme&&hc(t.theme),(t.displayMode==="inline"||t.displayMode==="fullscreen")&&(Fs=t.displayMode,cn.get("status")==="ready"&&ra())};dt.connect().then(()=>{const t=dt.getHostContext();t!=null&&t.theme&&hc(t.theme);const e=Mi.querySelector(".loading");e&&(e.textContent="Waiting for data..."),dt.sendSizeChanged({height:600})}); diff --git a/sidemantic/apps/web/explorer-app.ts b/sidemantic/apps/web/explorer-app.ts index cb9b6cd3..03e93a24 100644 --- a/sidemantic/apps/web/explorer-app.ts +++ b/sidemantic/apps/web/explorer-app.ts @@ -55,36 +55,34 @@ class WidgetModel { if (changed.has("active_dimension")) { const ad = this.state.active_dimension; if (ad) { - // Just set: don't query yet, widget will clear it in 400ms return; } - // Was cleared: do a full refresh - this.callRefresh("all"); + this.callRefreshPublic("all"); return; } if (changed.has("filters")) { const ad = this.state.active_dimension; if (ad) { - this.callRefresh("dimensions"); + this.callRefreshPublic("dimensions"); } else { - this.callRefresh("all"); + this.callRefreshPublic("all"); } return; } if (changed.has("brush_selection")) { - this.callRefresh("all"); + this.callRefreshPublic("all"); return; } if (changed.has("selected_metric")) { - this.callRefresh("dimensions"); + this.callRefreshPublic("dimensions"); return; } if (changed.has("time_grain")) { - this.callRefresh("metrics"); + this.callRefreshPublic("metrics"); return; } } @@ -141,37 +139,104 @@ class WidgetModel { return null; } - // Call the widget_query tool on the MCP server - private async callRefresh(queryType: string): Promise { - // Show loading state - this.state.status = "loading"; - this.fireChange("status"); + // Fetch all data by firing individual per-metric and per-dimension calls + callRefreshPublic(queryType: string): void { + const filtersJson = JSON.stringify(this.state.filters || {}); + const brushJson = JSON.stringify(this.state.brush_selection || []); + const timeGrain = this.state.time_grain || "day"; + const selectedMetric = this.state.selected_metric || ""; + + if (queryType === "all" || queryType === "metrics") { + // Fire one call per metric (series + total) + const metrics = (this.state.metrics_config || []) as Array<{ key: string }>; + for (const mc of metrics) { + this.fetchMetric(mc.key, timeGrain, filtersJson, brushJson); + } + } + if (queryType === "all" || queryType === "dimensions") { + // Fire one call per dimension + const dims = (this.state.dimensions_config || []) as Array<{ key: string }>; + for (const dc of dims) { + this.fetchDimension(dc.key, selectedMetric, filtersJson, brushJson); + } + } + } + + private async fetchMetric(metricKey: string, timeGrain: string, filtersJson: string, brushJson: string): Promise { try { const result = await this.app.callServerTool({ name: "widget_query", arguments: { - query_type: queryType, - selected_metric: this.state.selected_metric || "", - time_grain: this.state.time_grain || "day", - filters_json: JSON.stringify(this.state.filters || {}), - brush_selection_json: JSON.stringify(this.state.brush_selection || []), - active_dimension: this.state.active_dimension || "", + query_type: "metric", + metric_key: metricKey, + time_grain: timeGrain, + filters_json: filtersJson, + brush_selection_json: brushJson, }, }); + const data = this.extractData(result); + if (data && data.status !== "error") { + // Update metric total + if (data.metric_total !== undefined) { + const totals = { ...(this.state.metric_totals || {}) }; + totals[metricKey] = data.metric_total; + this.state.metric_totals = totals; + this.fireChange("metric_totals"); + } + // Update metric series (merge into combined data) + if (data.metric_series_data && data.time_series_column) { + this.state.config = { + ...(this.state.config || {}), + time_series_column: data.time_series_column, + }; + // Store per-metric series data for later merge + if (!this._metricSeriesMap) this._metricSeriesMap = {}; + this._metricSeriesMap[metricKey] = data.metric_series_data; + // Use the first available metric's series as metric_series_data + // (widget.js will parse it and render sparklines) + const firstKey = Object.keys(this._metricSeriesMap)[0]; + if (firstKey) { + this.state.metric_series_data = this._metricSeriesMap[firstKey]; + this.fireChange("metric_series_data"); + this.fireChange("config"); + } + } + // Update status + this.state.status = "ready"; + this.fireChange("status"); + } + } catch { + // Individual metric failure, don't kill the whole widget + } + } - // Extract data from tool result + private async fetchDimension(dimKey: string, selectedMetric: string, filtersJson: string, brushJson: string): Promise { + try { + const result = await this.app.callServerTool({ + name: "widget_query", + arguments: { + query_type: "dimension", + dimension_key: dimKey, + selected_metric: selectedMetric, + filters_json: filtersJson, + brush_selection_json: brushJson, + }, + }); const data = this.extractData(result); - if (data) { - this.applyData(data); + if (data && data.dimension_data !== undefined) { + const dimData = { ...(this.state.dimension_data || {}) }; + dimData[dimKey] = data.dimension_data; + this.state.dimension_data = dimData; + this.fireChange("dimension_data"); } - } catch (err) { - this.state.status = "error"; - this.state.error = err instanceof Error ? err.message : String(err); - this.fireChange("status"); - this.fireChange("error"); + } catch { + // Individual dimension failure } } + + // Storage for per-metric series Arrow IPC data + private _metricSeriesMap: Record | null = null; } // --- App setup --- @@ -195,11 +260,7 @@ function renderExplorer(): void { const isFullscreen = currentDisplayMode === "fullscreen"; document.documentElement.classList.toggle("fullscreen", isFullscreen); - // Create widget container - const widgetEl = document.createElement("div"); - container.appendChild(widgetEl); - - // Add expand button in inline mode + // Add expand button in inline mode (before widget so it layers on top) if (!isFullscreen) { const btn = document.createElement("div"); btn.className = "expand-btn"; @@ -217,6 +278,10 @@ function renderExplorer(): void { container.appendChild(btn); } + // Create widget container + const widgetEl = document.createElement("div"); + container.appendChild(widgetEl); + // Render the anywidget const result = renderWidget({ model, el: widgetEl }); if (typeof result === "function") { @@ -234,12 +299,14 @@ function renderExplorer(): void { }); } -// Handle initial tool result (from explore_metrics) +// Handle initial tool result (from explore_metrics - config only, no data) app.ontoolresult = (result: CallToolResult) => { const data = model.extractData(result); if (data) { model.applyData(data); renderExplorer(); + // Immediately fetch actual data via widget_query + model.callRefreshPublic("all"); } else { container.innerHTML = '
No explorer data in tool result
'; } diff --git a/sidemantic/apps/web/explorer.html b/sidemantic/apps/web/explorer.html index 4d52a605..fea906b2 100644 --- a/sidemantic/apps/web/explorer.html +++ b/sidemantic/apps/web/explorer.html @@ -11,12 +11,10 @@ .loading { padding: 2rem; text-align: center; color: #999; } .error { padding: 2rem; text-align: center; color: #dc2626; } .expand-btn { - position: absolute; top: 6px; right: 8px; z-index: 10; - cursor: pointer; color: #666; font-size: 13px; - line-height: 1; padding: 4px 8px; border-radius: 4px; - transition: color 0.2s, background 0.2s; + text-align: right; padding: 6px 12px; cursor: pointer; + color: #666; font-size: 13px; transition: color 0.2s; } - .expand-btn:hover { color: #333; background: rgba(0,0,0,0.06); } + .expand-btn:hover { color: #333; } @media (prefers-color-scheme: dark) { .expand-btn { color: #999; } .expand-btn:hover { color: #ddd; background: rgba(255,255,255,0.1); } diff --git a/sidemantic/mcp_server.py b/sidemantic/mcp_server.py index 9dd1a30e..88541422 100644 --- a/sidemantic/mcp_server.py +++ b/sidemantic/mcp_server.py @@ -1,6 +1,7 @@ """MCP server for Sidemantic semantic layer.""" import json +import sys from datetime import date, datetime, time from decimal import Decimal from pathlib import Path @@ -11,6 +12,12 @@ from sidemantic.core.semantic_layer import SemanticLayer from sidemantic.loaders import load_from_directory + +def _log(msg: str) -> None: + """Log to stderr (visible in Claude Desktop MCP logs).""" + print(f"[sidemantic] {msg}", file=sys.stderr, flush=True) + + # Global semantic layer instance _layer: SemanticLayer | None = None @@ -797,20 +804,65 @@ def _format_date_literal(value: str) -> str: return f"CAST('{value}' AS TIMESTAMP)" -def _detect_time_series_column(table, *, grain: str) -> str | None: +def _merge_metric_tables(tables: list, time_column: str) -> str: + """Merge per-metric Arrow tables into one table joined on the time column.""" + import pyarrow as pa + + if not tables: + return "" + if len(tables) == 1: + return _table_to_ipc_base64(tables[0], decimal_mode="float") + + # Start with the first table's time column + merged = tables[0] + for t in tables[1:]: + # Each table has time_column + one metric column. Extract the metric column. + metric_cols = [f.name for f in t.schema if f.name != time_column] + for col_name in metric_cols: + if col_name not in [f.name for f in merged.schema]: + merged = merged.append_column(col_name, pa.nulls(merged.num_rows, type=pa.float64())) + # Build a lookup from time values to metric values + time_vals = t.column(time_column).to_pylist() + metric_vals = t.column(col_name).to_pylist() + lookup = dict(zip(time_vals, metric_vals)) + # Fill in the values + merged_time = merged.column(time_column).to_pylist() + filled = [lookup.get(tv) for tv in merged_time] + idx = merged.schema.get_field_index(col_name) + merged = merged.set_column(idx, col_name, pa.array(filled, type=pa.float64())) + + return _table_to_ipc_base64(merged, decimal_mode="float") + + +def _detect_time_series_column(table, *, grain: str, time_dimension: str = "") -> str | None: """Find the time-series column name from an Arrow schema.""" import pyarrow as pa schema = getattr(table, "schema", None) if schema is None: return None - for field in schema: - if pa.types.is_date(field.type) or pa.types.is_timestamp(field.type): - return field.name + + # Prefer the grain-suffixed column (e.g. timestamp__day) suffix = f"__{grain}" for field in schema: if suffix in field.name: return field.name + + # Then try Arrow date/timestamp types + for field in schema: + if pa.types.is_date(field.type) or pa.types.is_timestamp(field.type): + return field.name + + # Then match on time_dimension name + if time_dimension: + for field in schema: + if time_dimension in field.name: + return field.name + + # Fall back to first column + if len(schema) > 0: + return schema[0].name + return None @@ -850,6 +902,8 @@ def explore_metrics( """ global _explorer_state + _log(f"explore_metrics called: model={model_name}, start={start_date}, end={end_date}") + layer = get_layer() graph = layer.graph model_names = list(graph.models.keys()) @@ -863,15 +917,15 @@ def explore_metrics( if model is None: raise ValueError(f"Model '{model_name}' not found. Available: {model_names}") - # Resolve metrics + # Resolve metrics (cap at 8 to avoid timeout from too many individual queries) if not metrics: - metrics = [f"{model_name}.{m.name}" for m in (model.metrics or [])] + metrics = [f"{model_name}.{m.name}" for m in (model.metrics or [])][:8] - # Resolve dimensions (categorical/boolean only for leaderboards) + # Resolve dimensions (categorical/boolean only, cap at 8) if not dimensions: dimensions = [ f"{model_name}.{d.name}" for d in (model.dimensions or []) if d.type in ("categorical", "boolean") - ] + ][:8] # Resolve time dimension if not time_dimension: @@ -928,9 +982,11 @@ def explore_metrics( # Default selected metric selected_metric = metrics_config[0]["key"] if metrics_config else "" - # Compute date range from the underlying table + # Compute date range: use provided dates or query the table date_range: list[str] = [] - if time_dimension and model.table: + if start_date and end_date: + date_range = [start_date, end_date] + elif time_dimension and model.table: try: from sidemantic.widget._widget import _quote_qualified_name @@ -941,7 +997,6 @@ def explore_metrics( range_result = layer.adapter.execute(range_sql).fetchone() if range_result and range_result[0] is not None and range_result[1] is not None: min_val, max_val = range_result[0], range_result[1] - # Stringify for val in (min_val, max_val): if isinstance(val, datetime): date_range.append(val.isoformat(sep=" ")) @@ -951,89 +1006,13 @@ def explore_metrics( date_range.append(str(val)) except Exception: pass + # Apply partial overrides + if start_date and len(date_range) == 2: + date_range[0] = start_date + if end_date and len(date_range) == 2: + date_range[1] = end_date - # Override date range if start_date/end_date provided - if start_date or end_date: - if start_date and end_date: - date_range = [start_date, end_date] - elif start_date and len(date_range) == 2: - date_range = [start_date, date_range[1]] - elif end_date and len(date_range) == 2: - date_range = [date_range[0], end_date] - - # Build date filters for initial queries - date_filters: list[str] = [] - if time_dimension and len(date_range) == 2: - start_literal = _format_date_literal(date_range[0]) - end_literal = _format_date_literal(date_range[1]) - date_filters.append( - f"{model_name}.{time_dimension} >= {start_literal} AND {model_name}.{time_dimension} <= {end_literal}" - ) - - # Metric series query - metric_refs = [m["ref"] for m in metrics_config] - time_dim_ref_grain = f"{model_name}.{time_dimension}__{default_grain}" if time_dimension else None - time_dim_ref = f"{model_name}.{time_dimension}" if time_dimension else None - - metric_series_data = "" - time_series_column: str | None = None - if time_dim_ref_grain and metric_refs: - try: - series_sql = layer.compile( - metrics=metric_refs, - dimensions=[time_dim_ref_grain], - filters=date_filters or None, - order_by=[time_dim_ref] if time_dim_ref else None, - limit=500, - ) - series_result = _execute_arrow(layer, series_sql) - time_series_column = _detect_time_series_column(series_result, grain=default_grain) - metric_series_data = _table_to_ipc_base64(series_result, decimal_mode="float") - except Exception: - pass - - config["time_series_column"] = time_series_column - - # Metric totals query - metric_totals: dict[str, Any] = {} - if metric_refs: - try: - totals_sql = layer.compile( - metrics=metric_refs, - dimensions=[], - filters=date_filters or None, - ) - totals_row = layer.adapter.execute(totals_sql).fetchone() - if totals_row: - for i, metric_ref in enumerate(metric_refs): - value = _convert_to_json_compatible(totals_row[i]) - if isinstance(value, Decimal): - value = str(value) - metric_totals[metric_ref.split(".")[-1]] = value - except Exception: - pass - - # Dimension leaderboards - selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" - dimension_data: dict[str, str] = {} - if selected_metric_ref and dimensions_config: - for dim_config in dimensions_config: - dim_key = dim_config["key"] - dim_ref = dim_config["ref"] - try: - dim_sql = layer.compile( - metrics=[selected_metric_ref], - dimensions=[dim_ref], - filters=date_filters or None, - order_by=[f"{selected_metric_ref} DESC"], - limit=6, - ) - dim_result = _execute_arrow(layer, dim_sql) - dimension_data[dim_key] = _table_to_ipc_base64(dim_result, decimal_mode="string") - except Exception: - dimension_data[dim_key] = "" - - # Store state for widget_query + # Store state for widget_query (data is fetched lazily via widget_query) _explorer_state = { "model_name": model_name, "time_dimension": time_dimension, @@ -1044,6 +1023,9 @@ def explore_metrics( "brush_selection": [], } + _log("explore_metrics: returning config (data will be fetched via widget_query)") + + # Return config only - widget renders skeletons immediately, then calls widget_query for data return { "config": config, "metrics_config": metrics_config, @@ -1052,10 +1034,10 @@ def explore_metrics( "time_grain": default_grain, "time_grain_options": time_grain_options, "selected_metric": selected_metric, - "metric_series_data": metric_series_data, - "metric_totals": metric_totals, - "dimension_data": dimension_data, - "status": "ready", + "metric_series_data": "", + "metric_totals": {}, + "dimension_data": {}, + "status": "loading", } @@ -1064,139 +1046,122 @@ def explore_metrics( meta={"ui": {"visibility": ["app"]}}, ) def widget_query( - query_type: str = "all", + query_type: str = "metric", + metric_key: str = "", selected_metric: str = "", time_grain: str = "day", + dimension_key: str = "", filters_json: str = "{}", brush_selection_json: str = "[]", - active_dimension: str = "", ) -> dict[str, Any]: - """Refresh explorer widget data (app-only, not visible to LLM). + """Fetch a single metric or dimension for the explorer widget (app-only). - Called by the explorer widget to fetch updated metric series, totals, - and/or dimension leaderboard data after user interactions. + query_type controls what to fetch: + - "metric": series + total for one metric (metric_key required) + - "dimension": leaderboard for one dimension (dimension_key + selected_metric required) Args: - query_type: What to refresh: "metrics", "dimensions", or "all" - selected_metric: Active metric key for dimension leaderboards + query_type: "metric" or "dimension" + metric_key: Metric key to query (for query_type="metric") + selected_metric: Active metric key (for query_type="dimension" leaderboards) time_grain: Time granularity for metric series - filters_json: JSON-encoded dimension filters ({dim_key: [values]}) - brush_selection_json: JSON-encoded brush selection ([start, end] or []) - active_dimension: If set, only refresh this single dimension leaderboard - - Returns: - Updated data matching the requested query_type. + dimension_key: Dimension key to query (for query_type="dimension") + filters_json: JSON-encoded dimension filters + brush_selection_json: JSON-encoded brush selection """ global _explorer_state if _explorer_state is None: - raise RuntimeError("Explorer not initialized. Call explore_metrics first.") + return {"status": "error", "error": "Explorer not initialized. Use explore_metrics first."} - try: - layer = get_layer() - model_name = _explorer_state["model_name"] - time_dimension = _explorer_state["time_dimension"] - metrics_config = _explorer_state["metrics_config"] - dimensions_config = _explorer_state["dimensions_config"] - - filters = json.loads(filters_json) if filters_json else {} - brush_selection = json.loads(brush_selection_json) if brush_selection_json else [] - - # Update stored state - _explorer_state["filters"] = filters - _explorer_state["brush_selection"] = brush_selection - - # Build state dict for filter builder - state = { - "model_name": model_name, - "time_dimension": time_dimension, - "filters": filters, - "date_range": _explorer_state.get("date_range", []), - "brush_selection": brush_selection, - } + layer = get_layer() + model_name = _explorer_state["model_name"] + time_dimension = _explorer_state["time_dimension"] + metrics_config = _explorer_state["metrics_config"] + dimensions_config = _explorer_state["dimensions_config"] + + filters = json.loads(filters_json) if filters_json else {} + brush_selection = json.loads(brush_selection_json) if brush_selection_json else [] + _explorer_state["filters"] = filters + _explorer_state["brush_selection"] = brush_selection + + state = { + "model_name": model_name, + "time_dimension": time_dimension, + "filters": filters, + "date_range": _explorer_state.get("date_range", []), + "brush_selection": brush_selection, + } + date_filters = _build_explorer_filters(state) - result: dict[str, Any] = {"status": "ready"} + if query_type == "metric" and metric_key: + # Find the metric config + mc = next((m for m in metrics_config if m["key"] == metric_key), None) + if not mc: + return {"status": "error", "error": f"Unknown metric: {metric_key}"} - # --- Metrics --- - if query_type in ("metrics", "all"): - metric_refs = [m["ref"] for m in metrics_config] - time_dim_ref_grain = f"{model_name}.{time_dimension}__{time_grain}" if time_dimension else None - time_dim_ref = f"{model_name}.{time_dimension}" if time_dimension else None - date_filters = _build_explorer_filters(state) + metric_ref = mc["ref"] + time_dim_ref_grain = f"{model_name}.{time_dimension}__{time_grain}" if time_dimension else None + result: dict[str, Any] = {"metric_key": metric_key} - # Metric series - metric_series_data = "" - time_series_column: str | None = None - if time_dim_ref_grain and metric_refs: + # Series + if time_dim_ref_grain: + try: series_sql = layer.compile( - metrics=metric_refs, + metrics=[metric_ref], dimensions=[time_dim_ref_grain], filters=date_filters or None, - order_by=[time_dim_ref] if time_dim_ref else None, + order_by=[time_dim_ref_grain], limit=500, ) series_table = _execute_arrow(layer, series_sql) - time_series_column = _detect_time_series_column(series_table, grain=time_grain) - metric_series_data = _table_to_ipc_base64(series_table, decimal_mode="float") - - result["metric_series_data"] = metric_series_data - result["config"] = { - "model_name": model_name, - "time_dimension": time_dimension, - "time_dimension_ref": f"{model_name}.{time_dimension}" if time_dimension else None, - "time_series_column": time_series_column, - } + ts_col = _detect_time_series_column(series_table, grain=time_grain, time_dimension=time_dimension) + result["metric_series_data"] = _table_to_ipc_base64(series_table, decimal_mode="float") + result["time_series_column"] = ts_col + except Exception as e: + _log(f" metric series FAIL {metric_key}: {e}") - # Metric totals - metric_totals: dict[str, Any] = {} - if metric_refs: - totals_sql = layer.compile( - metrics=metric_refs, - dimensions=[], - filters=date_filters or None, - ) - totals_row = layer.adapter.execute(totals_sql).fetchone() - if totals_row: - for i, metric_ref in enumerate(metric_refs): - value = _convert_to_json_compatible(totals_row[i]) - if isinstance(value, Decimal): - value = str(value) - metric_totals[metric_ref.split(".")[-1]] = value - result["metric_totals"] = metric_totals - - # --- Dimensions --- - if query_type in ("dimensions", "all"): - selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" - dimension_data: dict[str, str] = {} - - dims_to_query = dimensions_config - if active_dimension: - dims_to_query = [d for d in dimensions_config if d["key"] == active_dimension] - - if selected_metric_ref and dims_to_query: - for dim_config in dims_to_query: - dim_key = dim_config["key"] - dim_ref = dim_config["ref"] - dim_filters = _build_explorer_filters(state, exclude_dimension=dim_key) - try: - dim_sql = layer.compile( - metrics=[selected_metric_ref], - dimensions=[dim_ref], - filters=dim_filters or None, - order_by=[f"{selected_metric_ref} DESC"], - limit=6, - ) - dim_table = _execute_arrow(layer, dim_sql) - dimension_data[dim_key] = _table_to_ipc_base64(dim_table, decimal_mode="string") - except Exception: - dimension_data[dim_key] = "" - - result["dimension_data"] = dimension_data + # Total + try: + totals_sql = layer.compile(metrics=[metric_ref], dimensions=[], filters=date_filters or None) + totals_row = layer.adapter.execute(totals_sql).fetchone() + if totals_row: + value = _convert_to_json_compatible(totals_row[0]) + if isinstance(value, Decimal): + value = str(value) + result["metric_total"] = value + except Exception: + pass + + result["status"] = "ready" + return result + + if query_type == "dimension" and dimension_key: + dc = next((d for d in dimensions_config if d["key"] == dimension_key), None) + if not dc: + return {"status": "error", "error": f"Unknown dimension: {dimension_key}"} + + selected_metric_ref = f"{model_name}.{selected_metric}" if selected_metric else "" + dim_filters = _build_explorer_filters(state, exclude_dimension=dimension_key) + result = {"dimension_key": dimension_key} + + try: + dim_sql = layer.compile( + metrics=[selected_metric_ref], + dimensions=[dc["ref"]], + filters=dim_filters or None, + order_by=[f"{selected_metric_ref} DESC"], + limit=6, + ) + dim_table = _execute_arrow(layer, dim_sql) + result["dimension_data"] = _table_to_ipc_base64(dim_table, decimal_mode="string") + except Exception: + result["dimension_data"] = "" + result["status"] = "ready" return result - except Exception as e: - return {"status": "error", "error": str(e)} + return {"status": "error", "error": f"Invalid query_type: {query_type}"} # --- MCP Resources ---