|
| 1 | +"""anywidget shim for the Visual Builder. |
| 2 | +
|
| 3 | +The widget mounts the bundled React app (built by ``npm run build:widget`` |
| 4 | +in ``vbgui/``) and routes JSON-RPC envelopes through the in-kernel |
| 5 | +dispatcher — no HTTP server needed when running inside a notebook. |
| 6 | +
|
| 7 | +Traitlet contract (kernel ↔ frontend): |
| 8 | + - ``spec`` (Dict): the canonical model spec — graph, loss, optim, |
| 9 | + sharding, rewriters. Frontend mutates, kernel observes. |
| 10 | + - ``last_result`` (Dict): the most recent verify/probe/pipeline |
| 11 | + response, written by the kernel. |
| 12 | + - ``schema_version`` (Unicode, read-only): bumps when the wire |
| 13 | + format changes; frontend reloads. |
| 14 | +
|
| 15 | +Frontend-initiated work travels as ``custom_msg`` envelopes shaped like |
| 16 | +JSON-RPC 2.0 requests; the kernel runs the same |
| 17 | +:func:`cppmega_v4.jsonrpc.dispatch` and sends back a JsonRpcResponse via |
| 18 | +``self.send``. |
| 19 | +""" |
| 20 | + |
| 21 | +from __future__ import annotations |
| 22 | + |
| 23 | +import logging |
| 24 | +from pathlib import Path |
| 25 | +from typing import Any |
| 26 | + |
| 27 | +import anywidget |
| 28 | +import traitlets |
| 29 | + |
| 30 | +from cppmega_v4.jsonrpc import SCHEMA_VERSION, LRUCache, dispatch |
| 31 | + |
| 32 | +_log = logging.getLogger(__name__) |
| 33 | + |
| 34 | + |
| 35 | +STATIC_DIR: Path = Path(__file__).parent / "static" |
| 36 | +_ESM_PATH = STATIC_DIR / "widget.mjs" |
| 37 | +_CSS_PATH = STATIC_DIR / "widget.css" |
| 38 | + |
| 39 | + |
| 40 | +def widget_assets_exist() -> bool: |
| 41 | + """True iff the Vite-built widget bundle is present on disk.""" |
| 42 | + return _ESM_PATH.is_file() and _CSS_PATH.is_file() |
| 43 | + |
| 44 | + |
| 45 | +def _load_text(path: Path, missing_hint: str) -> str: |
| 46 | + if not path.is_file(): |
| 47 | + raise FileNotFoundError( |
| 48 | + f"widget asset not found: {path}\n" |
| 49 | + f"hint: {missing_hint}", |
| 50 | + ) |
| 51 | + return path.read_text(encoding="utf-8") |
| 52 | + |
| 53 | + |
| 54 | +class VisualBuilderWidget(anywidget.AnyWidget): |
| 55 | + """Jupyter widget wrapping the cppmega Visual Builder UI.""" |
| 56 | + |
| 57 | + _esm = traitlets.Unicode().tag(sync=False) |
| 58 | + _css = traitlets.Unicode().tag(sync=False) |
| 59 | + |
| 60 | + spec = traitlets.Dict().tag(sync=True) |
| 61 | + last_result = traitlets.Dict().tag(sync=True) |
| 62 | + schema_version = traitlets.Unicode(SCHEMA_VERSION, read_only=True).tag(sync=True) |
| 63 | + |
| 64 | + def __init__( |
| 65 | + self, |
| 66 | + *, |
| 67 | + spec: dict[str, Any] | None = None, |
| 68 | + cache_capacity: int = 50, |
| 69 | + **kwargs: Any, |
| 70 | + ) -> None: |
| 71 | + # Read bundle once at construction; raises with a useful hint if |
| 72 | + # the Vite build hasn't been run. |
| 73 | + kwargs.setdefault("_esm", _load_text( |
| 74 | + _ESM_PATH, |
| 75 | + "run `npm run build:widget` from vbgui/ to populate " |
| 76 | + "cppmega_v4/widget/static/widget.mjs", |
| 77 | + )) |
| 78 | + kwargs.setdefault("_css", _load_text( |
| 79 | + _CSS_PATH, |
| 80 | + "run `npm run build:widget` from vbgui/ to populate " |
| 81 | + "cppmega_v4/widget/static/widget.css", |
| 82 | + )) |
| 83 | + super().__init__(**kwargs) |
| 84 | + if spec is not None: |
| 85 | + self.spec = dict(spec) |
| 86 | + self._cache = LRUCache(capacity=cache_capacity) |
| 87 | + self.on_msg(self._on_msg) |
| 88 | + |
| 89 | + def _on_msg(self, _: object, content: Any, _buffers: list[bytes]) -> None: |
| 90 | + """Route a kernel-bound JSON-RPC envelope to the dispatcher.""" |
| 91 | + if not isinstance(content, dict): |
| 92 | + _log.warning("VisualBuilderWidget: dropped non-dict msg %r", content) |
| 93 | + return |
| 94 | + response = dispatch(content, cache=self._cache) |
| 95 | + self.send(response.model_dump(mode="json", exclude_none=True)) |
| 96 | + |
| 97 | + @property |
| 98 | + def cache_stats(self) -> dict[str, int]: |
| 99 | + return self._cache.stats() |
0 commit comments