Newer
Older
navi-1 / navi / mcp / ui_server / components / registry.py
"""Component registry and discovery for navi_ui."""

from __future__ import annotations

from pathlib import Path

import structlog

from .base import UIComponent

log = structlog.get_logger()


class ComponentRegistry:
    """Discovers and holds UIComponent subclasses."""

    def __init__(self) -> None:
        self._components: dict[str, type[UIComponent]] = {}

    def register(self, component_cls: type[UIComponent]) -> type[UIComponent]:
        """Register a component class."""
        if not component_cls.name:
            raise ValueError("UIComponent must define a non-empty name")
        self._components[component_cls.name] = component_cls
        return component_cls

    def get(self, name: str) -> type[UIComponent] | None:
        return self._components.get(name)

    def list_names(self) -> list[str]:
        return sorted(self._components.keys())

    def instructions(self) -> str:
        parts = [
            "## Supported components\n",
            "Call render_component(component_name, payload, session_id). "
            "Only the following component_name values are supported.\n",
        ]
        for name in self.list_names():
            parts.append(self._components[name].instructions())
        parts.append(
            "\nHard rules:\n"
            "1. component_name must be one of the supported values above.\n"
            "2. session_id is injected automatically by the agent.\n"
            "3. Keep payload compact. If more data is available, say so in text and ask before rendering more.\n"
            "4. After calling render_component, still provide a short text summary and offer next steps.\n"
            "5. If validation fails, fix the payload and retry.\n"
        )
        return "\n".join(parts)


def discover_components(registry: ComponentRegistry | None = None) -> ComponentRegistry:
    """Import all modules next to this file and collect UIComponent subclasses."""
    if registry is None:
        registry = ComponentRegistry()

    package_dir = Path(__file__).parent
    for path in sorted(package_dir.glob("*.py")):
        if path.name.startswith("_"):
            continue
        module_name = f"navi.mcp.ui_server.components.{path.stem}"
        try:
            __import__(module_name)
        except Exception as exc:  # noqa: BLE001
            # Keep discovery resilient; broken modules are not fatal.
            log.warning("ui_component_discovery_failed", module=module_name, error=str(exc))
            continue

    # Walk the full inheritance tree so intermediate subclasses (e.g.
    # RealtorCardGrid extending CardGrid) are still discovered.
    def _walk(base: type[UIComponent]) -> None:
        for cls in base.__subclasses__():
            if cls.name and cls.name not in registry._components:
                registry.register(cls)
            _walk(cls)

    _walk(UIComponent)
    return registry