diff --git a/README.md b/README.md
index 4bf13fb2..cff324c6 100644
--- a/README.md
+++ b/README.md
@@ -185,6 +185,29 @@ nanobot agent
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
+## 🧪 WebUI (Development)
+
+> [!NOTE]
+> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs, build steps, and release notes.
+
+
+
+
+
+**1. Start the gateway**
+
+```bash
+nanobot gateway
+```
+
+**2. Start the webui dev server**
+
+```bash
+cd webui
+bun install
+bun run dev
+```
+
## 🏗️ Architecture
diff --git a/images/nanobot_webui.png b/images/nanobot_webui.png
new file mode 100644
index 00000000..b074281d
Binary files /dev/null and b/images/nanobot_webui.png differ
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index c0622a27..f8f2c1d8 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -3,7 +3,8 @@
from __future__ import annotations
import asyncio
-from typing import Any
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
from loguru import logger
@@ -13,6 +14,19 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
+if TYPE_CHECKING:
+ from nanobot.session.manager import SessionManager
+
+
+def _default_webui_dist() -> Path | None:
+ """Return the absolute path to the bundled webui dist directory if it exists."""
+ try:
+ import nanobot.web as web_pkg # type: ignore[import-not-found]
+ except ImportError:
+ return None
+ candidate = Path(web_pkg.__file__).resolve().parent / "dist"
+ return candidate if candidate.is_dir() else None
+
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
_SEND_RETRY_DELAYS = (1, 2, 4)
@@ -27,9 +41,16 @@ class ChannelManager:
- Route outbound messages
"""
- def __init__(self, config: Config, bus: MessageBus):
+ def __init__(
+ self,
+ config: Config,
+ bus: MessageBus,
+ *,
+ session_manager: "SessionManager | None" = None,
+ ):
self.config = config
self.bus = bus
+ self._session_manager = session_manager
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
@@ -55,7 +76,15 @@ class ChannelManager:
if not enabled:
continue
try:
- channel = cls(section, self.bus)
+ kwargs: dict[str, Any] = {}
+ # Only the WebSocket channel currently hosts the embedded webui
+ # surface; other channels stay oblivious to these knobs.
+ if cls.name == "websocket" and self._session_manager is not None:
+ kwargs["session_manager"] = self._session_manager
+ static_path = _default_webui_dist()
+ if static_path is not None:
+ kwargs["static_dist_path"] = static_path
+ channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key
channel.transcription_api_base = transcription_base
diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py
index 882793ae..639519a4 100644
--- a/nanobot/channels/websocket.py
+++ b/nanobot/channels/websocket.py
@@ -7,13 +7,15 @@ import email.utils
import hmac
import http
import json
+import mimetypes
import re
import secrets
import ssl
import time
import uuid
-from typing import Any, Self
-from urllib.parse import parse_qs, urlparse
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Self
+from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger
from pydantic import Field, field_validator, model_validator
@@ -28,6 +30,9 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Base
+if TYPE_CHECKING:
+ from nanobot.session.manager import SessionManager
+
def _strip_trailing_slash(path: str) -> str:
if len(path) > 1 and path.endswith("/"):
@@ -116,6 +121,18 @@ def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
return Response(status, reason, headers, body)
+def _read_webui_model_name() -> str | None:
+ """Return the configured default model for readonly webui display."""
+ try:
+ from nanobot.config.loader import load_config
+
+ model = load_config().agents.defaults.model.strip()
+ return model or None
+ except Exception as e:
+ logger.debug("webui bootstrap could not load model name: {}", e)
+ return None
+
+
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
@@ -189,6 +206,78 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
return data
+_LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
+
+# Matches the legacy chat-id pattern but allows file-system-safe stems too,
+# so the API can address sessions whose keys came from non-WebSocket channels.
+_API_KEY_RE = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
+
+
+def _decode_api_key(raw_key: str) -> str | None:
+ """Decode a percent-encoded API path segment, then validate the result."""
+ key = unquote(raw_key)
+ if _API_KEY_RE.match(key) is None:
+ return None
+ return key
+
+
+def _is_localhost(connection: Any) -> bool:
+ """Return True if *connection* originated from the loopback interface."""
+ addr = getattr(connection, "remote_address", None)
+ if not addr:
+ return False
+ host = addr[0] if isinstance(addr, tuple) else addr
+ if not isinstance(host, str):
+ return False
+ # ``::ffff:127.0.0.1`` is loopback in IPv6-mapped form.
+ if host.startswith("::ffff:"):
+ host = host[7:]
+ return host in _LOCALHOSTS
+
+
+def _http_response(
+ body: bytes,
+ *,
+ status: int = 200,
+ content_type: str = "text/plain; charset=utf-8",
+ extra_headers: list[tuple[str, str]] | None = None,
+) -> Response:
+ headers = [
+ ("Date", email.utils.formatdate(usegmt=True)),
+ ("Connection", "close"),
+ ("Content-Length", str(len(body))),
+ ("Content-Type", content_type),
+ ]
+ if extra_headers:
+ headers.extend(extra_headers)
+ reason = http.HTTPStatus(status).phrase
+ return Response(status, reason, Headers(headers), body)
+
+
+def _http_error(status: int, message: str | None = None) -> Response:
+ body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
+ return _http_response(body, status=status)
+
+
+def _bearer_token(headers: Any) -> str | None:
+ """Pull a Bearer token out of standard or query-style headers."""
+ auth = headers.get("Authorization") or headers.get("authorization")
+ if auth and auth.lower().startswith("bearer "):
+ return auth[7:].strip() or None
+ return None
+
+
+def _is_websocket_upgrade(request: WsRequest) -> bool:
+ """Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through."""
+ upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade")
+ connection = request.headers.get("Connection") or request.headers.get("connection")
+ if not upgrade or "websocket" not in upgrade.lower():
+ return False
+ if not connection or "upgrade" not in connection.lower():
+ return False
+ return True
+
+
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret:
@@ -209,7 +298,14 @@ class WebSocketChannel(BaseChannel):
name = "websocket"
display_name = "WebSocket"
- def __init__(self, config: Any, bus: MessageBus):
+ def __init__(
+ self,
+ config: Any,
+ bus: MessageBus,
+ *,
+ session_manager: "SessionManager | None" = None,
+ static_dist_path: Path | None = None,
+ ):
if isinstance(config, dict):
config = WebSocketConfig.model_validate(config)
super().__init__(config, bus)
@@ -220,9 +316,16 @@ class WebSocketChannel(BaseChannel):
self._conn_chats: dict[Any, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {}
+ # Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {}
+ # Multi-use tokens for the embedded webui's REST surface; checked but not consumed.
+ self._api_tokens: dict[str, float] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
+ self._session_manager = session_manager
+ self._static_dist_path: Path | None = (
+ static_dist_path.resolve() if static_dist_path is not None else None
+ )
# -- Subscription bookkeeping -------------------------------------------
@@ -324,6 +427,209 @@ class WebSocketChannel(BaseChannel):
{"token": token_value, "expires_in": self.config.token_ttl_s}
)
+ # -- HTTP dispatch ------------------------------------------------------
+
+ async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any:
+ """Route an inbound HTTP request to a handler or to the WS upgrade path."""
+ got, query = _parse_request_path(request.path)
+
+ # 1. Token issue endpoint (legacy, optional, gated by configured secret).
+ if self.config.token_issue_path:
+ issue_expected = _normalize_config_path(self.config.token_issue_path)
+ if got == issue_expected:
+ return self._handle_token_issue_http(connection, request)
+
+ # 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI.
+ if got == "/webui/bootstrap":
+ return self._handle_webui_bootstrap(connection)
+
+ # 3. REST surface for the embedded UI.
+ if got == "/api/sessions":
+ return self._handle_sessions_list(request)
+
+ m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
+ if m:
+ return self._handle_session_messages(request, m.group(1))
+
+ # NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
+ # true ``DELETE`` verb. The action is folded into the path instead.
+ m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
+ if m:
+ return self._handle_session_delete(request, m.group(1))
+
+ # 4. WebSocket upgrade (the channel's primary purpose). Only run the
+ # handshake gate on requests that actually ask to upgrade; otherwise
+ # a bare ``GET /`` from the browser would be rejected as an
+ # unauthorized WS handshake instead of serving the SPA's index.html.
+ expected_ws = self._expected_path()
+ if got == expected_ws and _is_websocket_upgrade(request):
+ client_id = _query_first(query, "client_id") or ""
+ if len(client_id) > 128:
+ client_id = client_id[:128]
+ if not self.is_allowed(client_id):
+ return connection.respond(403, "Forbidden")
+ return self._authorize_websocket_handshake(connection, query)
+
+ # 5. Static SPA serving (only if a build directory was wired in).
+ if self._static_dist_path is not None:
+ response = self._serve_static(got)
+ if response is not None:
+ return response
+
+ return connection.respond(404, "Not Found")
+
+ # -- HTTP route handlers ------------------------------------------------
+
+ def _check_api_token(self, request: WsRequest) -> bool:
+ """Validate a request against the API token pool (multi-use, TTL-bound)."""
+ self._purge_expired_api_tokens()
+ token = _bearer_token(request.headers) or _query_first(
+ _parse_query(request.path), "token"
+ )
+ if not token:
+ return False
+ expiry = self._api_tokens.get(token)
+ if expiry is None or time.monotonic() > expiry:
+ self._api_tokens.pop(token, None)
+ return False
+ return True
+
+ def _purge_expired_api_tokens(self) -> None:
+ now = time.monotonic()
+ for token_key, expiry in list(self._api_tokens.items()):
+ if now > expiry:
+ self._api_tokens.pop(token_key, None)
+
+ def _handle_webui_bootstrap(self, connection: Any) -> Response:
+ if not _is_localhost(connection):
+ return _http_error(403, "webui bootstrap is localhost-only")
+ # Cap outstanding tokens to avoid runaway growth from a misbehaving client.
+ self._purge_expired_issued_tokens()
+ self._purge_expired_api_tokens()
+ if (
+ len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS
+ or len(self._api_tokens) >= self._MAX_ISSUED_TOKENS
+ ):
+ return _http_response(
+ json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
+ status=429,
+ content_type="application/json; charset=utf-8",
+ )
+ token = f"nbwt_{secrets.token_urlsafe(32)}"
+ expiry = time.monotonic() + float(self.config.token_ttl_s)
+ # Same string registered in both pools: the WS handshake consumes one copy
+ # while the REST surface keeps validating the other until TTL expiry.
+ self._issued_tokens[token] = expiry
+ self._api_tokens[token] = expiry
+ return _http_json_response(
+ {
+ "token": token,
+ "ws_path": self._expected_path(),
+ "expires_in": self.config.token_ttl_s,
+ "model_name": _read_webui_model_name(),
+ }
+ )
+
+ def _handle_sessions_list(self, request: WsRequest) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ if self._session_manager is None:
+ return _http_error(503, "session manager unavailable")
+ sessions = self._session_manager.list_sessions()
+ # The webui is only meaningful for websocket-channel chats — CLI /
+ # Slack / Lark / Discord sessions can't be resumed from the browser,
+ # so leaking them into the sidebar is just noise. Filter to the
+ # ``websocket:`` prefix and strip absolute paths on the way out.
+ cleaned = [
+ {k: v for k, v in s.items() if k != "path"}
+ for s in sessions
+ if isinstance(s.get("key"), str) and s["key"].startswith("websocket:")
+ ]
+ return _http_json_response({"sessions": cleaned})
+
+ @staticmethod
+ def _is_webui_session_key(key: str) -> bool:
+ """Return True when *key* belongs to the webui's websocket-only surface."""
+ return key.startswith("websocket:")
+
+ def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ if self._session_manager is None:
+ return _http_error(503, "session manager unavailable")
+ decoded_key = _decode_api_key(key)
+ if decoded_key is None:
+ return _http_error(400, "invalid session key")
+ # The embedded webui only understands websocket-channel sessions. Keep
+ # its read surface aligned with ``/api/sessions`` instead of letting a
+ # caller probe arbitrary CLI / Slack / Lark history by handcrafted URL.
+ if not self._is_webui_session_key(decoded_key):
+ return _http_error(404, "session not found")
+ data = self._session_manager.read_session_file(decoded_key)
+ if data is None:
+ return _http_error(404, "session not found")
+ return _http_json_response(data)
+
+ def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
+ if not self._check_api_token(request):
+ return _http_error(401, "Unauthorized")
+ if self._session_manager is None:
+ return _http_error(503, "session manager unavailable")
+ decoded_key = _decode_api_key(key)
+ if decoded_key is None:
+ return _http_error(400, "invalid session key")
+ # Same boundary as ``_handle_session_messages``: the webui may only
+ # mutate websocket sessions, and deletion really does unlink the local
+ # JSONL, so keep the blast radius narrow and explicit.
+ if not self._is_webui_session_key(decoded_key):
+ return _http_error(404, "session not found")
+ deleted = self._session_manager.delete_session(decoded_key)
+ return _http_json_response({"deleted": bool(deleted)})
+
+ def _serve_static(self, request_path: str) -> Response | None:
+ """Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
+ assert self._static_dist_path is not None
+ rel = request_path.lstrip("/")
+ if not rel:
+ rel = "index.html"
+ # Reject path-traversal attempts and absolute targets.
+ if ".." in rel.split("/") or rel.startswith("/"):
+ return _http_error(403, "Forbidden")
+ candidate = (self._static_dist_path / rel).resolve()
+ try:
+ candidate.relative_to(self._static_dist_path)
+ except ValueError:
+ return _http_error(403, "Forbidden")
+ if not candidate.is_file():
+ # SPA history-mode fallback: unknown routes serve index.html so the
+ # client-side router can render them.
+ index = self._static_dist_path / "index.html"
+ if index.is_file():
+ candidate = index
+ else:
+ return None
+ try:
+ body = candidate.read_bytes()
+ except OSError as e:
+ logger.warning("websocket static: failed to read {}: {}", candidate, e)
+ return _http_error(500, "Internal Server Error")
+ ctype, _ = mimetypes.guess_type(candidate.name)
+ if ctype is None:
+ ctype = "application/octet-stream"
+ if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}:
+ ctype = f"{ctype}; charset=utf-8"
+ # Hash-named build assets are cache-friendly; index.html must stay fresh.
+ if candidate.name == "index.html":
+ cache = "no-cache"
+ else:
+ cache = "public, max-age=31536000, immutable"
+ return _http_response(
+ body,
+ status=200,
+ content_type=ctype,
+ extra_headers=[("Cache-Control", cache)],
+ )
+
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
supplied = _query_first(query, "token")
static_token = self.config.token.strip()
@@ -355,24 +661,7 @@ class WebSocketChannel(BaseChannel):
connection: ServerConnection,
request: WsRequest,
) -> Any:
- got, _ = _parse_request_path(request.path)
- if self.config.token_issue_path:
- issue_expected = _normalize_config_path(self.config.token_issue_path)
- if got == issue_expected:
- return self._handle_token_issue_http(connection, request)
-
- expected_ws = self._expected_path()
- if got != expected_ws:
- return connection.respond(404, "Not Found")
- # Early reject before WebSocket upgrade to avoid unnecessary overhead;
- # _handle_message() performs a second check as defense-in-depth.
- query = _parse_query(request.path)
- client_id = _query_first(query, "client_id") or ""
- if len(client_id) > 128:
- client_id = client_id[:128]
- if not self.is_allowed(client_id):
- return connection.respond(403, "Forbidden")
- return self._authorize_websocket_handshake(connection, query)
+ return await self._dispatch_http(connection, request)
async def handler(connection: ServerConnection) -> None:
await self._connection_loop(connection)
@@ -523,6 +812,7 @@ class WebSocketChannel(BaseChannel):
self._conn_chats.clear()
self._conn_default.clear()
self._issued_tokens.clear()
+ self._api_tokens.clear()
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
@@ -550,6 +840,13 @@ class WebSocketChannel(BaseChannel):
payload["media"] = msg.media
if msg.reply_to:
payload["reply_to"] = msg.reply_to
+ # Mark intermediate agent breadcrumbs (tool-call hints, generic
+ # progress strings) so WS clients can render them as subordinate
+ # trace rows rather than conversational replies.
+ if msg.metadata.get("_tool_hint"):
+ payload["kind"] = "tool_hint"
+ elif msg.metadata.get("_progress"):
+ payload["kind"] = "progress"
raw = json.dumps(payload, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 5f043050..f3b72a72 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -636,6 +636,21 @@ def gateway(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the nanobot gateway."""
+ if verbose:
+ import logging
+
+ logging.basicConfig(level=logging.DEBUG)
+ cfg = _load_runtime_config(config, workspace)
+ _run_gateway(cfg, port=port)
+
+
+def _run_gateway(
+ config: Config,
+ *,
+ port: int | None = None,
+ open_browser_url: str | None = None,
+) -> None:
+ """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
@@ -644,12 +659,6 @@ def gateway(
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
- if verbose:
- import logging
-
- logging.basicConfig(level=logging.DEBUG)
-
- config = _load_runtime_config(config, workspace)
port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
@@ -749,8 +758,9 @@ def gateway(
cron.on_job = on_cron_job
- # Create channel manager
- channels = ChannelManager(config, bus)
+ # Create channel manager (forwards SessionManager so the WebSocket channel
+ # can serve the embedded webui's REST surface).
+ channels = ChannelManager(config, bus, session_manager=session_manager)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
@@ -881,15 +891,43 @@ def gateway(
))
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
+ async def _open_browser_when_ready() -> None:
+ """Wait for the gateway to bind, then point the user's browser at the webui."""
+ if not open_browser_url:
+ return
+ import webbrowser
+ # Channels start asynchronously; a short poll lets us avoid racing the bind.
+ for _ in range(40): # ~4s max
+ try:
+ reader, writer = await asyncio.open_connection(
+ config.gateway.host or "127.0.0.1", port
+ )
+ writer.close()
+ try:
+ await writer.wait_closed()
+ except Exception:
+ pass
+ break
+ except OSError:
+ await asyncio.sleep(0.1)
+ try:
+ webbrowser.open(open_browser_url)
+ console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
+ except Exception as e:
+ console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
+
async def run():
try:
await cron.start()
await heartbeat.start()
- await asyncio.gather(
+ tasks = [
agent.run(),
channels.start_all(),
_health_server(config.gateway.host, port),
- )
+ ]
+ if open_browser_url:
+ tasks.append(_open_browser_when_ready())
+ await asyncio.gather(*tasks)
except KeyboardInterrupt:
console.print("\nShutting down...")
except Exception:
@@ -907,6 +945,68 @@ def gateway(
asyncio.run(run())
+@app.command()
+def web(
+ port: int | None = typer.Option(None, "--port", "-p", help="WebSocket port for the webui"),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser when ready"),
+):
+ """Start the gateway with the embedded webui and (by default) open a browser."""
+ if verbose:
+ import logging
+
+ logging.basicConfig(level=logging.DEBUG)
+
+ cfg = _load_runtime_config(config, workspace)
+
+ # Force the websocket channel on with token-gated auth so the webui is functional.
+ # ``--port`` applies to the webui's websocket/HTTP port, not the gateway's
+ # management port, since that's the only surface users visit.
+ ws_section = cfg.channels.websocket
+ if isinstance(ws_section, dict):
+ ws_section.setdefault("host", "127.0.0.1")
+ ws_section["enabled"] = True
+ ws_section["websocketRequiresToken"] = True
+ if port is not None:
+ ws_section["port"] = port
+ ws_host = ws_section.get("host", "127.0.0.1")
+ ws_port = ws_section.get("port", 8765)
+ ws_path = ws_section.get("path", "/")
+ else:
+ ws_section.enabled = True
+ if hasattr(ws_section, "websocket_requires_token"):
+ ws_section.websocket_requires_token = True
+ if port is not None:
+ ws_section.port = port
+ ws_host = getattr(ws_section, "host", "127.0.0.1") or "127.0.0.1"
+ ws_port = getattr(ws_section, "port", 8765)
+ ws_path = getattr(ws_section, "path", "/") or "/"
+
+ # Confirm the bundled SPA exists before promising the user a browser launch.
+ from nanobot.channels.manager import _default_webui_dist
+
+ dist = _default_webui_dist()
+ if dist is None:
+ console.print(
+ "[yellow]Warning: webui assets not found at nanobot/web/dist/. "
+ "Run `cd webui && bun install && bun run build` from a source checkout.[/yellow]"
+ )
+
+ scheme = "http"
+ # Browsers refuse cookies/JS on 0.0.0.0 — collapse to loopback for the visit URL.
+ visit_host = "127.0.0.1" if ws_host in {"0.0.0.0", "::"} else ws_host
+ open_url = f"{scheme}://{visit_host}:{ws_port}{ws_path if ws_path != '/' else ''}/"
+
+ # The gateway's management port is separate from the webui port; leave it
+ # on its configured default so --port only moves the visible surface.
+ _run_gateway(
+ cfg,
+ open_browser_url=open_url if open_browser else None,
+ )
+
+
# ============================================================================
# Agent Commands
# ============================================================================
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 2ed0624a..c91eabcb 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -106,15 +106,18 @@ class SessionManager:
self.legacy_sessions_dir = get_legacy_sessions_dir()
self._cache: dict[str, Session] = {}
+ @staticmethod
+ def safe_key(key: str) -> str:
+ """Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
+ return safe_filename(key.replace(":", "_"))
+
def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session."""
- safe_key = safe_filename(key.replace(":", "_"))
- return self.sessions_dir / f"{safe_key}.jsonl"
+ return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/)."""
- safe_key = safe_filename(key.replace(":", "_"))
- return self.legacy_sessions_dir / f"{safe_key}.jsonl"
+ return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
def get_or_create(self, key: str) -> Session:
"""
@@ -209,6 +212,61 @@ class SessionManager:
"""Remove a session from the in-memory cache."""
self._cache.pop(key, None)
+ def delete_session(self, key: str) -> bool:
+ """Remove a session from disk and the in-memory cache.
+
+ Returns True if a JSONL file was found and unlinked.
+ """
+ path = self._get_session_path(key)
+ self.invalidate(key)
+ if not path.exists():
+ return False
+ try:
+ path.unlink()
+ return True
+ except OSError as e:
+ logger.warning("Failed to delete session file {}: {}", path, e)
+ return False
+
+ def read_session_file(self, key: str) -> dict[str, Any] | None:
+ """Load a session from disk without caching; intended for read-only HTTP endpoints.
+
+ Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
+ ``None`` when the session file does not exist or fails to parse.
+ """
+ path = self._get_session_path(key)
+ if not path.exists():
+ return None
+ try:
+ messages: list[dict[str, Any]] = []
+ metadata: dict[str, Any] = {}
+ created_at: str | None = None
+ updated_at: str | None = None
+ stored_key: str | None = None
+ with open(path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ data = json.loads(line)
+ if data.get("_type") == "metadata":
+ metadata = data.get("metadata", {})
+ created_at = data.get("created_at")
+ updated_at = data.get("updated_at")
+ stored_key = data.get("key")
+ else:
+ messages.append(data)
+ return {
+ "key": stored_key or key,
+ "created_at": created_at,
+ "updated_at": updated_at,
+ "metadata": metadata,
+ "messages": messages,
+ }
+ except Exception as e:
+ logger.warning("Failed to read session {}: {}", key, e)
+ return None
+
def list_sessions(self) -> list[dict[str, Any]]:
"""
List all sessions.
diff --git a/nanobot/web/__init__.py b/nanobot/web/__init__.py
new file mode 100644
index 00000000..7a08932f
--- /dev/null
+++ b/nanobot/web/__init__.py
@@ -0,0 +1,6 @@
+"""Embedded web UI assets.
+
+The ``dist/`` subdirectory is populated by ``cd webui && bun run build`` and
+is shipped in the wheel; it stays empty in source checkouts until that command
+has been run.
+"""
diff --git a/pyproject.toml b/pyproject.toml
index 1e55fed4..5878570a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -113,6 +113,7 @@ include = [
"nanobot/templates/**/*.md",
"nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh",
+ "nanobot/web/dist/**/*",
]
[tool.hatch.build.targets.wheel]
diff --git a/tests/agent/test_session_delete.py b/tests/agent/test_session_delete.py
new file mode 100644
index 00000000..aa3296d9
--- /dev/null
+++ b/tests/agent/test_session_delete.py
@@ -0,0 +1,65 @@
+"""Tests for SessionManager.delete_session and read_session_file."""
+
+from pathlib import Path
+
+from nanobot.session.manager import Session, SessionManager
+
+
+def _seed(workspace: Path, key: str = "telegram:abc") -> SessionManager:
+ sm = SessionManager(workspace)
+ session = Session(key=key)
+ session.add_message("user", "hello")
+ session.add_message("assistant", "hi back")
+ sm.save(session)
+ return sm
+
+
+def test_delete_session_removes_file_and_invalidates_cache(tmp_path: Path) -> None:
+ sm = _seed(tmp_path, "telegram:abc")
+ file_path = sm._get_session_path("telegram:abc")
+ assert file_path.exists()
+ # Populate cache as a real consumer would.
+ cached = sm.get_or_create("telegram:abc")
+ assert cached.messages
+
+ assert sm.delete_session("telegram:abc") is True
+ assert not file_path.exists()
+ # Subsequent get_or_create returns a fresh, empty Session (no stale cache).
+ fresh = sm.get_or_create("telegram:abc")
+ assert fresh.messages == []
+
+
+def test_delete_session_returns_false_when_missing(tmp_path: Path) -> None:
+ sm = SessionManager(tmp_path)
+ assert sm.delete_session("nope:none") is False
+
+
+def test_read_session_file_returns_metadata_and_messages(tmp_path: Path) -> None:
+ sm = _seed(tmp_path, "telegram:abc")
+ data = sm.read_session_file("telegram:abc")
+ assert data is not None
+ assert data["key"] == "telegram:abc"
+ assert isinstance(data["messages"], list)
+ assert [m["role"] for m in data["messages"]] == ["user", "assistant"]
+ assert data["created_at"]
+ assert data["updated_at"]
+
+
+def test_read_session_file_does_not_populate_cache(tmp_path: Path) -> None:
+ sm = _seed(tmp_path, "telegram:abc")
+ sm.invalidate("telegram:abc")
+ assert "telegram:abc" not in sm._cache
+ sm.read_session_file("telegram:abc")
+ assert "telegram:abc" not in sm._cache
+
+
+def test_read_session_file_missing(tmp_path: Path) -> None:
+ sm = SessionManager(tmp_path)
+ assert sm.read_session_file("nope:none") is None
+
+
+def test_safe_key_matches_internal_path(tmp_path: Path) -> None:
+ sm = SessionManager(tmp_path)
+ key = "telegram:abc/def"
+ expected = sm._get_session_path(key).name
+ assert SessionManager.safe_key(key) + ".jsonl" == expected
diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py
new file mode 100644
index 00000000..51fd50f4
--- /dev/null
+++ b/tests/channels/test_websocket_http_routes.py
@@ -0,0 +1,381 @@
+"""End-to-end tests for the embedded webui's HTTP routes on the WebSocket channel."""
+
+import asyncio
+import functools
+import json
+from pathlib import Path
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+import httpx
+import pytest
+
+from nanobot.channels.websocket import WebSocketChannel
+from nanobot.session.manager import Session, SessionManager
+
+_PORT = 29900
+
+
+def _ch(
+ bus: Any,
+ *,
+ session_manager: SessionManager | None = None,
+ static_dist_path: Path | None = None,
+ port: int = _PORT,
+ **extra: Any,
+) -> WebSocketChannel:
+ cfg: dict[str, Any] = {
+ "enabled": True,
+ "allowFrom": ["*"],
+ "host": "127.0.0.1",
+ "port": port,
+ "path": "/",
+ "websocketRequiresToken": False,
+ }
+ cfg.update(extra)
+ return WebSocketChannel(
+ cfg,
+ bus,
+ session_manager=session_manager,
+ static_dist_path=static_dist_path,
+ )
+
+
+@pytest.fixture()
+def bus() -> MagicMock:
+ b = MagicMock()
+ b.publish_inbound = AsyncMock()
+ return b
+
+
+async def _http_get(
+ url: str, headers: dict[str, str] | None = None
+) -> httpx.Response:
+ return await asyncio.to_thread(
+ functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
+ )
+
+
+def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
+ sm = SessionManager(workspace)
+ s = Session(key=key)
+ s.add_message("user", "hi")
+ s.add_message("assistant", "hello back")
+ sm.save(s)
+ return sm
+
+
+def _seed_many(workspace: Path, keys: list[str]) -> SessionManager:
+ sm = SessionManager(workspace)
+ for k in keys:
+ s = Session(key=k)
+ s.add_message("user", f"hi from {k}")
+ sm.save(s)
+ return sm
+
+
+@pytest.mark.asyncio
+async def test_bootstrap_returns_token_for_localhost(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ sm = _seed_session(tmp_path)
+ channel = _ch(bus, session_manager=sm, port=29901)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["token"].startswith("nbwt_")
+ assert body["ws_path"] == "/"
+ assert body["expires_in"] > 0
+ assert isinstance(body.get("model_name"), str)
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_sessions_routes_require_bearer_token(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ sm = _seed_session(tmp_path, key="websocket:abc")
+ channel = _ch(bus, session_manager=sm, port=29902)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ # Unauthenticated → 401.
+ deny = await _http_get("http://127.0.0.1:29902/api/sessions")
+ assert deny.status_code == 401
+
+ # Mint a token via bootstrap, then call the API with it.
+ boot = await _http_get("http://127.0.0.1:29902/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ listing = await _http_get("http://127.0.0.1:29902/api/sessions", headers=auth)
+ assert listing.status_code == 200
+ keys = [s["key"] for s in listing.json()["sessions"]]
+ assert "websocket:abc" in keys
+ # Server stays an opaque source: filesystem paths must not leak to the wire.
+ assert all("path" not in s for s in listing.json()["sessions"])
+
+ msgs = await _http_get(
+ "http://127.0.0.1:29902/api/sessions/websocket:abc/messages",
+ headers=auth,
+ )
+ assert msgs.status_code == 200
+ body = msgs.json()
+ assert body["key"] == "websocket:abc"
+ assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_sessions_list_only_returns_websocket_sessions_by_default(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ # Seed a realistic multi-channel disk state: CLI, Slack, Lark and
+ # websocket sessions all live in the same ``sessions/`` directory.
+ sm = _seed_many(
+ tmp_path,
+ [
+ "cli:direct",
+ "slack:C123",
+ "lark:oc_abc",
+ "websocket:alpha",
+ "websocket:beta",
+ ],
+ )
+ channel = _ch(bus, session_manager=sm, port=29906)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ boot = await _http_get("http://127.0.0.1:29906/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ listing = await _http_get(
+ "http://127.0.0.1:29906/api/sessions", headers=auth
+ )
+ assert listing.status_code == 200
+ keys = {s["key"] for s in listing.json()["sessions"]}
+ # Only websocket-channel sessions are part of the webui surface; CLI /
+ # Slack / Lark rows would be non-resumable from the browser.
+ assert keys == {"websocket:alpha", "websocket:beta"}
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> None:
+ sm = _seed_session(tmp_path, key="websocket:doomed")
+ channel = _ch(bus, session_manager=sm, port=29903)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ boot = await _http_get("http://127.0.0.1:29903/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ path = sm._get_session_path("websocket:doomed")
+ assert path.exists()
+ resp = await _http_get(
+ "http://127.0.0.1:29903/api/sessions/websocket:doomed/delete",
+ headers=auth,
+ )
+ assert resp.status_code == 200
+ assert resp.json()["deleted"] is True
+ assert not path.exists()
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_session_routes_accept_percent_encoded_websocket_keys(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ sm = _seed_session(tmp_path, key="websocket:encoded-key")
+ channel = _ch(bus, session_manager=sm, port=29910)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ boot = await _http_get("http://127.0.0.1:29910/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ msgs = await _http_get(
+ "http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/messages",
+ headers=auth,
+ )
+ assert msgs.status_code == 200
+ assert msgs.json()["key"] == "websocket:encoded-key"
+
+ path = sm._get_session_path("websocket:encoded-key")
+ assert path.exists()
+ deleted = await _http_get(
+ "http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/delete",
+ headers=auth,
+ )
+ assert deleted.status_code == 200
+ assert deleted.json()["deleted"] is True
+ assert not path.exists()
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_session_routes_reject_non_websocket_keys(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ sm = _seed_many(
+ tmp_path,
+ [
+ "websocket:kept",
+ "cli:direct",
+ "slack:C123",
+ ],
+ )
+ channel = _ch(bus, session_manager=sm, port=29909)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ boot = await _http_get("http://127.0.0.1:29909/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ # The webui list already hides non-websocket sessions; handcrafted URLs
+ # should hit the same boundary rather than exposing or deleting them.
+ msgs = await _http_get(
+ "http://127.0.0.1:29909/api/sessions/cli:direct/messages",
+ headers=auth,
+ )
+ assert msgs.status_code == 404
+
+ doomed = sm._get_session_path("slack:C123")
+ assert doomed.exists()
+ deny_delete = await _http_get(
+ "http://127.0.0.1:29909/api/sessions/slack:C123/delete",
+ headers=auth,
+ )
+ assert deny_delete.status_code == 404
+ assert doomed.exists()
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_session_routes_reject_invalid_key(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ sm = _seed_session(tmp_path)
+ channel = _ch(bus, session_manager=sm, port=29904)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ boot = await _http_get("http://127.0.0.1:29904/webui/bootstrap")
+ token = boot.json()["token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ # Invalid characters in the key -> regex match fails -> 404
+ # (route doesn't match, falls through to channel 404).
+ resp = await _http_get(
+ "http://127.0.0.1:29904/api/sessions/bad%20key/messages",
+ headers=auth,
+ )
+ assert resp.status_code in {400, 404}
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_static_serves_index_when_dist_present(
+ bus: MagicMock, tmp_path: Path
+) -> None:
+ dist = tmp_path / "dist"
+ dist.mkdir()
+ (dist / "index.html").write_text("
nbweb ")
+ (dist / "favicon.svg").write_text(" ")
+ sm = _seed_session(tmp_path / "ws_state")
+ channel = _ch(bus, session_manager=sm, static_dist_path=dist, port=29905)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ # Bare ``GET /`` is a browser opening the app: it must return the SPA
+ # index.html, not the WS-upgrade handler's 401/426.
+ root = await _http_get("http://127.0.0.1:29905/")
+ assert root.status_code == 200
+ assert "nbweb" in root.text
+ asset = await _http_get("http://127.0.0.1:29905/favicon.svg")
+ assert asset.status_code == 200
+ assert " None:
+ dist = tmp_path / "dist"
+ dist.mkdir()
+ (dist / "index.html").write_text("ok")
+ secret = tmp_path / "secret.txt"
+ secret.write_text("classified")
+ channel = _ch(bus, static_dist_path=dist, port=29906)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ resp = await _http_get("http://127.0.0.1:29906/../secret.txt")
+ # Normalized by httpx into /secret.txt → falls back to index.html, not 'classified'.
+ assert "classified" not in resp.text
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_unknown_route_returns_404(bus: MagicMock) -> None:
+ channel = _ch(bus, port=29907)
+ server_task = asyncio.create_task(channel.start())
+ await asyncio.sleep(0.3)
+ try:
+ resp = await _http_get("http://127.0.0.1:29907/api/unknown")
+ assert resp.status_code == 404
+ finally:
+ await channel.stop()
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) -> None:
+ sm = _seed_session(tmp_path)
+ channel = _ch(bus, session_manager=sm, port=29908)
+ # Don't start a server — directly inject and validate.
+ import time as _time
+ channel._api_tokens["expired"] = _time.monotonic() - 1
+ channel._api_tokens["live"] = _time.monotonic() + 60
+
+ class _FakeReq:
+ path = "/api/sessions"
+ headers = {"Authorization": "Bearer expired"}
+
+ assert channel._check_api_token(_FakeReq()) is False
+
+ class _LiveReq:
+ path = "/api/sessions"
+ headers = {"Authorization": "Bearer live"}
+
+ assert channel._check_api_token(_LiveReq()) is True
diff --git a/tests/channels/test_websocket_integration.py b/tests/channels/test_websocket_integration.py
index ff0d5085..8e98aa48 100644
--- a/tests/channels/test_websocket_integration.py
+++ b/tests/channels/test_websocket_integration.py
@@ -189,6 +189,45 @@ async def test_server_send_message(bus: MagicMock) -> None:
await ch.stop(); await t
+@pytest.mark.asyncio
+async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
+ """``_tool_hint`` metadata must surface as ``kind: "tool_hint"`` so WS
+ clients render breadcrumbs separately from conversational replies."""
+ ch = _ch(bus, 29919)
+ t = asyncio.create_task(ch.start())
+ await asyncio.sleep(0.3)
+ try:
+ async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
+ ready = await c.recv_ready()
+ # Plain reply: no "kind" field.
+ await ch.send(OutboundMessage(
+ channel="websocket", chat_id=ready.chat_id, content="hi",
+ ))
+ plain = await c.recv_message()
+ assert plain.raw.get("kind") is None
+
+ # Tool-hint breadcrumb: kind == "tool_hint".
+ await ch.send(OutboundMessage(
+ channel="websocket", chat_id=ready.chat_id,
+ content='weather("get")',
+ metadata={"_progress": True, "_tool_hint": True},
+ ))
+ hint = await c.recv_message()
+ assert hint.raw.get("kind") == "tool_hint"
+ assert hint.text == 'weather("get")'
+
+ # Generic progress (non-tool-hint) gets the softer "progress" label.
+ await ch.send(OutboundMessage(
+ channel="websocket", chat_id=ready.chat_id,
+ content="thinking…",
+ metadata={"_progress": True},
+ ))
+ prog = await c.recv_message()
+ assert prog.raw.get("kind") == "progress"
+ finally:
+ await ch.stop(); await t
+
+
@pytest.mark.asyncio
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
ch = _ch(bus, 29910)
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index 5966d520..b970d920 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -1212,7 +1212,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
return None
class _FakeChannelManager:
- def __init__(self, _config, _bus) -> None:
+ def __init__(self, _config, _bus, **_kwargs) -> None:
self.enabled_channels = ["telegram", "discord"]
async def start_all(self) -> None:
diff --git a/webui/.gitignore b/webui/.gitignore
new file mode 100644
index 00000000..cd8c35d5
--- /dev/null
+++ b/webui/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+.vite/
+coverage/
+*.tsbuildinfo
+.env
+.env.*
+!.env.example
diff --git a/webui/README.md b/webui/README.md
new file mode 100644
index 00000000..9d3591d2
--- /dev/null
+++ b/webui/README.md
@@ -0,0 +1,90 @@
+# nanobot webui
+
+The browser front-end for `nanobot web`. It is built with Vite + React 18 +
+TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket
+multiplex protocol, and reads session metadata from the embedded REST surface
+on the same port.
+
+For the project overview, install guide, and general docs map, see the root
+[`README.md`](../README.md).
+
+## Current status
+
+> [!NOTE]
+> The standalone WebUI development workflow currently requires a source
+> checkout.
+>
+> WebUI changes in the GitHub repository may land before they are included in
+> the next packaged release, so source installs and published package versions
+> are not yet guaranteed to move in lockstep.
+
+## Layout
+
+```text
+webui/ source tree (this directory)
+nanobot/web/dist/ build output consumed by `nanobot web`
+```
+
+## Develop from source
+
+### 1. Install nanobot from source
+
+From the repository root:
+
+```bash
+pip install -e .
+```
+
+### 2. Start the gateway
+
+In one terminal:
+
+```bash
+nanobot gateway
+```
+
+### 3. Start the WebUI dev server
+
+In another terminal:
+
+```bash
+cd webui
+bun install # npm install also works
+bun run dev
+```
+
+Then open `http://127.0.0.1:5173`.
+
+By default, the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket
+traffic to `http://127.0.0.1:8765`.
+
+If your gateway listens on a non-default port, point the dev server at it:
+
+```bash
+NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
+```
+
+## Build for packaged runtime
+
+```bash
+cd webui
+bun run build
+```
+
+This writes the production assets to `../nanobot/web/dist`, which is the
+directory served by `nanobot web` and bundled into the Python wheel.
+
+If you are cutting a release, run the build before packaging so the published
+wheel contains the current WebUI assets.
+
+## Test
+
+```bash
+cd webui
+bun run test
+```
+
+## Acknowledgements
+
+- [`agent-chat-ui`](https://github.com/langchain-ai/agent-chat-ui) for UI and
+ interaction inspiration across the chat surface.
diff --git a/webui/bun.lock b/webui/bun.lock
new file mode 100644
index 00000000..e71f2dc5
--- /dev/null
+++ b/webui/bun.lock
@@ -0,0 +1,951 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "nanobot-webui",
+ "dependencies": {
+ "@radix-ui/react-alert-dialog": "^1.1.4",
+ "@radix-ui/react-avatar": "^1.1.2",
+ "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-scroll-area": "^1.2.2",
+ "@radix-ui/react-separator": "^1.1.1",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tooltip": "^1.1.6",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.469.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-markdown": "^9.0.1",
+ "react-syntax-highlighter": "^15.6.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.0",
+ "remark-math": "^6.0.0",
+ "tailwind-merge": "^2.6.0",
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.19",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^22.10.5",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "happy-dom": "^16.3.0",
+ "katex": "^0.16.21",
+ "postcss": "^8.5.0",
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vitest": "^2.1.8",
+ },
+ },
+ },
+ "packages": {
+ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
+
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
+
+ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
+
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
+
+ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
+
+ "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
+
+ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+
+ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
+
+ "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
+
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
+
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
+ "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
+
+ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
+
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
+
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
+
+ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
+
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
+
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
+ "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+
+ "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+
+ "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
+
+ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+ "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
+ "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
+
+ "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
+
+ "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+
+ "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+
+ "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="],
+
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
+
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+
+ "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
+
+ "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
+
+ "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
+
+ "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
+
+ "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
+
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
+ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
+
+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+
+ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
+
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
+ "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
+
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="],
+
+ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
+
+ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+
+ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
+
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+ "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
+
+ "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
+
+ "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
+
+ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+ "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
+ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
+ "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
+
+ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
+
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
+
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
+ "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+
+ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
+
+ "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
+
+ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
+
+ "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
+
+ "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
+
+ "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
+
+ "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="],
+
+ "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
+
+ "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
+
+ "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
+
+ "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+ "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+ "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
+
+ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
+
+ "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
+
+ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
+ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+
+ "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
+
+ "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
+
+ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
+
+ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
+ "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
+
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
+
+ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
+
+ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+
+ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+
+ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
+
+ "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
+
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
+ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+ "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+ "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+ "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
+
+ "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+ "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
+
+ "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+ "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+ "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
+ "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+ "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+ "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+ "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+ "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
+
+ "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+ "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
+
+ "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
+
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
+ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
+
+ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+
+ "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
+
+ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
+
+ "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
+
+ "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
+
+ "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
+
+ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
+
+ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
+
+ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
+
+ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
+ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
+
+ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+
+ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+
+ "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+
+ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
+ "react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
+
+ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
+
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
+ "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="],
+
+ "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
+
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
+ "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
+
+ "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
+
+ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+ "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
+
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+
+ "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
+
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
+ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
+ "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+
+ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
+
+ "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
+
+ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
+
+ "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
+
+ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
+
+ "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
+
+ "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
+
+ "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
+ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
+ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+ "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
+
+ "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
+
+ "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
+
+ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+
+ "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
+ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
+
+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
+ "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
+
+ "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
+ "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
+ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
+ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
+
+ "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
+
+ "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
+
+ "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
+
+ "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="],
+
+ "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
+
+ "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
+ "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+
+ "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
+
+ "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
+
+ "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+
+ "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+
+ "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
+
+ "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
+
+ "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+ }
+}
diff --git a/webui/components.json b/webui/components.json
new file mode 100644
index 00000000..3db62b9c
--- /dev/null
+++ b/webui/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "hooks": "@/hooks",
+ "lib": "@/lib"
+ }
+}
diff --git a/webui/index.html b/webui/index.html
new file mode 100644
index 00000000..24b775cc
--- /dev/null
+++ b/webui/index.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ nanobot
+
+
+
+
+
+
+ Loading nanobot…
+
+
+
+
+
+
diff --git a/webui/package-lock.json b/webui/package-lock.json
new file mode 100644
index 00000000..2ee7152a
--- /dev/null
+++ b/webui/package-lock.json
@@ -0,0 +1,5309 @@
+{
+ "name": "nanobot-webui",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "nanobot-webui",
+ "version": "0.1.0",
+ "dependencies": {
+ "@radix-ui/react-alert-dialog": "^1.1.4",
+ "@radix-ui/react-avatar": "^1.1.2",
+ "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-scroll-area": "^1.2.2",
+ "@radix-ui/react-separator": "^1.1.1",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tooltip": "^1.1.6",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "i18next": "^26.0.6",
+ "lucide-react": "^0.469.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-i18next": "^17.0.4",
+ "react-markdown": "^9.0.1",
+ "react-syntax-highlighter": "^15.6.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.0",
+ "remark-math": "^6.0.0",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.19",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^22.10.5",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "happy-dom": "^16.3.0",
+ "katex": "^0.16.21",
+ "postcss": "^8.5.0",
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vitest": "^2.1.8"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.8",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.15",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.1.11",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.4",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-is-hydrated": "0.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.16",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.16",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.10",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.8",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.1",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.1",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.19",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "license": "MIT"
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.17",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/react-syntax-highlighter": {
+ "version": "15.5.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.19",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001788",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "1.1.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "1.1.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "8.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/decode-named-character-reference/node_modules/character-entities": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.340",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fault": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "format": "^0.2.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/format": {
+ "version": "0.2.2",
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/happy-dom": {
+ "version": "16.8.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "webidl-conversions": "^7.0.0",
+ "whatwg-mimetype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hast-util-from-dom": {
+ "version": "5.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hastscript": "^9.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-dom/node_modules/hastscript": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-dom/node_modules/hastscript/node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.1.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "parse5": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html-isomorphic": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-from-dom": "^5.0.0",
+ "hast-util-from-html": "^2.0.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "devlop": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "property-information": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-location": "^5.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5/node_modules/hastscript": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5/node_modules/hastscript/node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "2.2.5",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "6.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript/node_modules/@types/hast": {
+ "version": "2.3.10",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2"
+ }
+ },
+ "node_modules/hastscript/node_modules/@types/hast/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/hastscript/node_modules/comma-separated-tokens": {
+ "version": "1.0.8",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hastscript/node_modules/property-information": {
+ "version": "5.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hastscript/node_modules/space-separated-tokens": {
+ "version": "1.1.5",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "10.7.3",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/highlightjs-vue": {
+ "version": "1.0.0",
+ "license": "CC0-1.0"
+ },
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "26.0.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
+ "integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2"
+ },
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/katex": {
+ "version": "0.16.45",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lowlight": {
+ "version": "1.20.0",
+ "license": "MIT",
+ "dependencies": {
+ "fault": "^1.0.0",
+ "highlight.js": "~10.7.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.469.0",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-math": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.1.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-alphanumerical/node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-decimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-math": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/katex": "^0.16.0",
+ "devlop": "^1.0.0",
+ "katex": "^0.16.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-nested/node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-i18next": {
+ "version": "17.0.4",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
+ "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.0.1",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-markdown": {
+ "version": "9.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-syntax-highlighter": {
+ "version": "15.6.6",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "highlight.js": "^10.4.1",
+ "highlightjs-vue": "^1.0.0",
+ "lowlight": "^1.17.0",
+ "prismjs": "^1.30.0",
+ "refractor": "^3.6.0"
+ },
+ "peerDependencies": {
+ "react": ">= 0.14.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/readdirp/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/refractor": {
+ "version": "3.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "hastscript": "^6.0.0",
+ "parse-entities": "^2.0.0",
+ "prismjs": "~1.27.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/prismjs": {
+ "version": "1.27.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rehype-katex": {
+ "version": "7.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/katex": "^0.16.0",
+ "hast-util-from-html-isomorphic": "^2.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "katex": "^0.16.0",
+ "unist-util-visit-parents": "^6.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-math": {
+ "version": "6.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-math": "^3.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities/node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/sucrase/node_modules/commander": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss-animate": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-location": {
+ "version": "5.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.7",
+ "es-module-lexer": "^1.5.4",
+ "pathe": "^1.1.2",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "2.1.9",
+ "@vitest/mocker": "2.1.9",
+ "@vitest/pretty-format": "^2.1.9",
+ "@vitest/runner": "2.1.9",
+ "@vitest/snapshot": "2.1.9",
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
+ "tinyrainbow": "^1.2.0",
+ "vite": "^5.0.0",
+ "vite-node": "2.1.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/webui/package.json b/webui/package.json
new file mode 100644
index 00000000..ee666f05
--- /dev/null
+++ b/webui/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "nanobot-webui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -p tsconfig.build.json && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "eslint src --max-warnings 0"
+ },
+ "dependencies": {
+ "@radix-ui/react-alert-dialog": "^1.1.4",
+ "@radix-ui/react-avatar": "^1.1.2",
+ "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-scroll-area": "^1.2.2",
+ "@radix-ui/react-separator": "^1.1.1",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tooltip": "^1.1.6",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "i18next": "^26.0.6",
+ "lucide-react": "^0.469.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-i18next": "^17.0.4",
+ "react-markdown": "^9.0.1",
+ "react-syntax-highlighter": "^15.6.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.0",
+ "remark-math": "^6.0.0",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.19",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^22.10.5",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "happy-dom": "^16.3.0",
+ "katex": "^0.16.21",
+ "postcss": "^8.5.0",
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/webui/postcss.config.js b/webui/postcss.config.js
new file mode 100644
index 00000000..2aa7205d
--- /dev/null
+++ b/webui/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/webui/public/brand/nanobot_apple_touch.png b/webui/public/brand/nanobot_apple_touch.png
new file mode 100644
index 00000000..43ee168b
Binary files /dev/null and b/webui/public/brand/nanobot_apple_touch.png differ
diff --git a/webui/public/brand/nanobot_favicon_32.png b/webui/public/brand/nanobot_favicon_32.png
new file mode 100644
index 00000000..7222bbf2
Binary files /dev/null and b/webui/public/brand/nanobot_favicon_32.png differ
diff --git a/webui/public/brand/nanobot_icon.png b/webui/public/brand/nanobot_icon.png
new file mode 100644
index 00000000..046086ef
Binary files /dev/null and b/webui/public/brand/nanobot_icon.png differ
diff --git a/webui/public/brand/nanobot_logo.png b/webui/public/brand/nanobot_logo.png
new file mode 100644
index 00000000..f519cbcf
Binary files /dev/null and b/webui/public/brand/nanobot_logo.png differ
diff --git a/webui/public/brand/nanobot_logo.webp b/webui/public/brand/nanobot_logo.webp
new file mode 100644
index 00000000..cb39dc44
Binary files /dev/null and b/webui/public/brand/nanobot_logo.webp differ
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
new file mode 100644
index 00000000..43beae9a
--- /dev/null
+++ b/webui/src/App.tsx
@@ -0,0 +1,324 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { DeleteConfirm } from "@/components/DeleteConfirm";
+import { Sidebar } from "@/components/Sidebar";
+import { ThreadShell } from "@/components/thread/ThreadShell";
+import { Sheet, SheetContent } from "@/components/ui/sheet";
+import { preloadMarkdownText } from "@/components/MarkdownText";
+import { useSessions } from "@/hooks/useSessions";
+import { useTheme } from "@/hooks/useTheme";
+import { cn } from "@/lib/utils";
+import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
+import { NanobotClient } from "@/lib/nanobot-client";
+import { ClientProvider } from "@/providers/ClientProvider";
+import type { ChatSummary } from "@/lib/types";
+
+type BootState =
+ | { status: "loading" }
+ | { status: "error"; message: string }
+ | {
+ status: "ready";
+ client: NanobotClient;
+ token: string;
+ modelName: string | null;
+ };
+
+const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
+const SIDEBAR_WIDTH = 279;
+
+function readSidebarOpen(): boolean {
+ if (typeof window === "undefined") return true;
+ try {
+ const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
+ if (raw === null) return true;
+ return raw === "1";
+ } catch {
+ return true;
+ }
+}
+
+export default function App() {
+ const { t } = useTranslation();
+ const [state, setState] = useState({ status: "loading" });
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const boot = await fetchBootstrap();
+ if (cancelled) return;
+ const url = deriveWsUrl(boot.ws_path, boot.token);
+ const client = new NanobotClient({
+ url,
+ onReauth: async () => {
+ try {
+ const refreshed = await fetchBootstrap();
+ return deriveWsUrl(refreshed.ws_path, refreshed.token);
+ } catch {
+ return null;
+ }
+ },
+ });
+ client.connect();
+ setState({
+ status: "ready",
+ client,
+ token: boot.token,
+ modelName: boot.model_name ?? null,
+ });
+ } catch (e) {
+ if (cancelled) return;
+ setState({ status: "error", message: (e as Error).message });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ const warm = () => preloadMarkdownText();
+ const win = globalThis as typeof globalThis & {
+ requestIdleCallback?: (
+ callback: IdleRequestCallback,
+ options?: IdleRequestOptions,
+ ) => number;
+ cancelIdleCallback?: (handle: number) => void;
+ };
+ if (typeof win.requestIdleCallback === "function") {
+ const id = win.requestIdleCallback(warm, { timeout: 1500 });
+ return () => win.cancelIdleCallback?.(id);
+ }
+ const id = globalThis.setTimeout(warm, 250);
+ return () => globalThis.clearTimeout(id);
+ }, []);
+
+ if (state.status === "loading") {
+ return (
+
+
+
+
+
+
+
+
+ {t("app.loading.connecting")}
+
+
+
+ );
+ }
+ if (state.status === "error") {
+ return (
+
+
+
+
{t("app.error.title")}
+
{state.message}
+
+ {t("app.error.gatewayHint")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function Shell() {
+ const { t, i18n } = useTranslation();
+ const { theme, toggle } = useTheme();
+ const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
+ const [activeKey, setActiveKey] = useState(null);
+ const [desktopSidebarOpen, setDesktopSidebarOpen] =
+ useState(readSidebarOpen);
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
+ const [pendingDelete, setPendingDelete] = useState<{
+ key: string;
+ label: string;
+ } | null>(null);
+ const lastSessionsLen = useRef(0);
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(
+ SIDEBAR_STORAGE_KEY,
+ desktopSidebarOpen ? "1" : "0",
+ );
+ } catch {
+ // ignore storage errors (private mode, etc.)
+ }
+ }, [desktopSidebarOpen]);
+
+ useEffect(() => {
+ if (activeKey) return;
+ if (sessions.length > 0 && lastSessionsLen.current === 0) {
+ setActiveKey(sessions[0].key);
+ }
+ lastSessionsLen.current = sessions.length;
+ }, [sessions, activeKey]);
+
+ const activeSession = useMemo(() => {
+ if (!activeKey) return null;
+ return sessions.find((s) => s.key === activeKey) ?? null;
+ }, [sessions, activeKey]);
+
+ const closeDesktopSidebar = useCallback(() => {
+ setDesktopSidebarOpen(false);
+ }, []);
+
+ const closeMobileSidebar = useCallback(() => {
+ setMobileSidebarOpen(false);
+ }, []);
+
+ const toggleSidebar = useCallback(() => {
+ const isDesktop =
+ typeof window !== "undefined" &&
+ window.matchMedia("(min-width: 1024px)").matches;
+ if (isDesktop) {
+ setDesktopSidebarOpen((v) => !v);
+ } else {
+ setMobileSidebarOpen((v) => !v);
+ }
+ }, []);
+
+ const onNewChat = useCallback(async () => {
+ try {
+ const chatId = await createChat();
+ setActiveKey(`websocket:${chatId}`);
+ setMobileSidebarOpen(false);
+ return chatId;
+ } catch (e) {
+ console.error("Failed to create chat", e);
+ return null;
+ }
+ }, [createChat]);
+
+ const onSelectChat = useCallback(
+ (key: string) => {
+ setActiveKey(key);
+ setMobileSidebarOpen(false);
+ },
+ [],
+ );
+
+ const onConfirmDelete = useCallback(async () => {
+ if (!pendingDelete) return;
+ const key = pendingDelete.key;
+ const deletingActive = activeKey === key;
+ const currentIndex = sessions.findIndex((s) => s.key === key);
+ const fallbackKey = deletingActive
+ ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
+ : activeKey;
+ setPendingDelete(null);
+ if (deletingActive) setActiveKey(fallbackKey);
+ try {
+ await deleteChat(key);
+ } catch (e) {
+ if (deletingActive) setActiveKey(key);
+ console.error("Failed to delete session", e);
+ }
+ }, [pendingDelete, deleteChat, activeKey, sessions]);
+
+ const headerTitle = activeSession
+ ? activeSession.preview ||
+ t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
+ : t("app.brand");
+
+ useEffect(() => {
+ document.title = activeSession
+ ? t("app.documentTitle.chat", { title: headerTitle })
+ : t("app.documentTitle.base");
+ }, [activeSession, headerTitle, i18n.resolvedLanguage, t]);
+
+ const sidebarProps = {
+ sessions,
+ activeKey,
+ loading,
+ theme,
+ onToggleTheme: toggle,
+ onNewChat: () => {
+ void onNewChat();
+ },
+ onSelect: onSelectChat,
+ onRefresh: () => void refresh(),
+ onRequestDelete: (key: string, label: string) =>
+ setPendingDelete({ key, label }),
+ };
+
+ return (
+
+ {/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
+
+
+
setMobileSidebarOpen(open)}
+ >
+
+
+
+
+
+
+ setActiveKey(null)}
+ onNewChat={onNewChat}
+ hideSidebarToggleOnDesktop={desktopSidebarOpen}
+ />
+
+
+
setPendingDelete(null)}
+ onConfirm={onConfirmDelete}
+ />
+
+ );
+}
diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx
new file mode 100644
index 00000000..f77f7c1b
--- /dev/null
+++ b/webui/src/components/ChatList.tsx
@@ -0,0 +1,116 @@
+import { MoreHorizontal, Trash2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { relativeTime } from "@/lib/format";
+import { cn } from "@/lib/utils";
+import type { ChatSummary } from "@/lib/types";
+
+interface ChatListProps {
+ sessions: ChatSummary[];
+ activeKey: string | null;
+ onSelect: (key: string) => void;
+ onRequestDelete: (key: string, label: string) => void;
+ loading?: boolean;
+}
+
+function titleFor(s: ChatSummary, fallbackTitle: string): string {
+ const p = s.preview?.trim();
+ if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p;
+ return fallbackTitle;
+}
+
+export function ChatList({
+ sessions,
+ activeKey,
+ onSelect,
+ onRequestDelete,
+ loading,
+}: ChatListProps) {
+ const { t } = useTranslation();
+ if (loading && sessions.length === 0) {
+ return (
+
+ {t("chat.loading")}
+
+ );
+ }
+
+ if (sessions.length === 0) {
+ return (
+
+ {t("chat.noSessions")}
+
+ );
+ }
+
+ return (
+
+
+ {sessions.map((s) => {
+ const active = s.key === activeKey;
+ const title = titleFor(
+ s,
+ t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
+ );
+ return (
+
+
+ onSelect(s.key)}
+ className="flex min-w-0 flex-1 flex-col items-start text-left"
+ >
+ {title}
+
+ {relativeTime(s.updatedAt ?? s.createdAt) || "—"}
+
+
+
+
+
+
+ event.preventDefault()}
+ >
+ {
+ window.setTimeout(() => onRequestDelete(s.key, title), 0);
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t("chat.delete")}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx
new file mode 100644
index 00000000..29d0df49
--- /dev/null
+++ b/webui/src/components/ChatPane.tsx
@@ -0,0 +1,126 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { Composer } from "@/components/Composer";
+import { MessageList } from "@/components/MessageList";
+import { useClient } from "@/providers/ClientProvider";
+import { useNanobotStream } from "@/hooks/useNanobotStream";
+import { useSessionHistory } from "@/hooks/useSessions";
+import type { ChatSummary } from "@/lib/types";
+
+interface ChatPaneProps {
+ session: ChatSummary | null;
+ /** Provision a new chat and mark it active. Returns the new chat_id or null. */
+ onNewChat: () => Promise;
+}
+
+/**
+ * The chat surface: persisted history on top, live stream below, composer
+ * pinned at the bottom. When no session is active we render a centered
+ * welcome card with a fully-functional composer — typing a first message
+ * quietly provisions a new chat and routes the message through.
+ */
+export function ChatPane({ session, onNewChat }: ChatPaneProps) {
+ const chatId = session?.chatId ?? null;
+ const historyKey = session?.key ?? null;
+ const { messages: historical, loading } = useSessionHistory(historyKey);
+ const { client } = useClient();
+ const [booting, setBooting] = useState(false);
+ const pendingFirstRef = useRef(null);
+
+ const initial = useMemo(() => historical, [historical]);
+ const { messages, isStreaming, send, setMessages } = useNanobotStream(
+ chatId,
+ initial,
+ );
+
+ useEffect(() => {
+ if (!loading && chatId) setMessages(historical);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loading, chatId, historical]);
+
+ // Once a session becomes active, flush any first-message stashed from the
+ // welcome composer so the user's keystroke "just sends".
+ useEffect(() => {
+ if (!chatId) return;
+ const pending = pendingFirstRef.current;
+ if (!pending) return;
+ pendingFirstRef.current = null;
+ client.sendMessage(chatId, pending);
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ role: "user",
+ content: pending,
+ createdAt: Date.now(),
+ },
+ ]);
+ setBooting(false);
+ }, [chatId, client, setMessages]);
+
+ const handleWelcomeSend = useCallback(
+ async (content: string) => {
+ if (booting) return;
+ setBooting(true);
+ pendingFirstRef.current = content;
+ const newId = await onNewChat();
+ if (!newId) {
+ // Creation failed — release the lock so the user can retry.
+ pendingFirstRef.current = null;
+ setBooting(false);
+ }
+ },
+ [booting, onNewChat],
+ );
+
+ if (!session) {
+ return (
+
+
+
+
+
+
+
+
+ What's on your mind?
+
+
+ Your conversations are persisted locally under the nanobot
+ workspace. Start typing and I'll open a new chat.
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx
new file mode 100644
index 00000000..68032d29
--- /dev/null
+++ b/webui/src/components/CodeBlock.tsx
@@ -0,0 +1,72 @@
+import { useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import {
+ oneDark,
+ oneLight,
+} from "react-syntax-highlighter/dist/esm/styles/prism";
+
+import { cn } from "@/lib/utils";
+
+interface CodeBlockProps {
+ language?: string;
+ code: string;
+ className?: string;
+}
+
+export function CodeBlock({ language, code, className }: CodeBlockProps) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+
+ const onCopy = () => {
+ if (!navigator.clipboard) return;
+ navigator.clipboard.writeText(code).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1_500);
+ });
+ };
+
+ const isDark =
+ typeof window !== "undefined"
+ ? document.documentElement.classList.contains("dark")
+ : true;
+
+ return (
+
+
+
+ {language || t("code.fallbackLanguage")}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? t("code.copied") : t("code.copy")}
+
+
+
+ {code}
+
+
+ );
+}
diff --git a/webui/src/components/Composer.tsx b/webui/src/components/Composer.tsx
new file mode 100644
index 00000000..3d6c8f65
--- /dev/null
+++ b/webui/src/components/Composer.tsx
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ArrowUp } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface ComposerProps {
+ onSend: (content: string) => void;
+ disabled?: boolean;
+ placeholder?: string;
+ /** Visually collapse the outer padding when embedded inside a welcome screen. */
+ compact?: boolean;
+}
+
+/**
+ * Rounded, shadowed composer with an embedded send button — modeled after the
+ * agent-chat-ui input: a single surface that looks like one interactive unit
+ * rather than a textarea + button pair.
+ */
+export function Composer({
+ onSend,
+ disabled,
+ placeholder = "Type your message…",
+ compact = false,
+}: ComposerProps) {
+ const [value, setValue] = useState("");
+ const textareaRef = useRef(null);
+
+ // Autofocus on mount — coming back to a chat, switching sessions, or
+ // opening the welcome screen should always land the caret in the box.
+ useEffect(() => {
+ if (disabled) return;
+ const el = textareaRef.current;
+ if (!el) return;
+ // Defer so layout settles first (important during enter animations).
+ const id = requestAnimationFrame(() => el.focus());
+ return () => cancelAnimationFrame(id);
+ }, [disabled]);
+
+ const submit = useCallback(() => {
+ const trimmed = value.trim();
+ if (!trimmed || disabled) return;
+ onSend(trimmed);
+ setValue("");
+ requestAnimationFrame(() => {
+ const el = textareaRef.current;
+ if (el) {
+ el.style.height = "auto";
+ el.focus();
+ }
+ });
+ }, [disabled, onSend, value]);
+
+ const onKeyDown: React.KeyboardEventHandler = (e) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
+ e.preventDefault();
+ submit();
+ }
+ };
+
+ const onInput: React.FormEventHandler = (e) => {
+ const el = e.currentTarget;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
+ };
+
+ return (
+
+ );
+}
diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx
new file mode 100644
index 00000000..354be976
--- /dev/null
+++ b/webui/src/components/ConnectionBadge.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { cn } from "@/lib/utils";
+import { useClient } from "@/providers/ClientProvider";
+import type { ConnectionStatus } from "@/lib/types";
+
+const COPY: Record = {
+ idle: { color: "bg-card/40 text-muted-foreground" },
+ connecting: {
+ color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ },
+ open: {
+ color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
+ },
+ reconnecting: {
+ color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ },
+ closed: {
+ color: "bg-card/40 text-muted-foreground",
+ },
+ error: {
+ color: "bg-destructive/10 text-destructive",
+ },
+};
+
+export function ConnectionBadge() {
+ const { t } = useTranslation();
+ const { client } = useClient();
+ const [status, setStatus] = useState(client.status);
+
+ useEffect(() => client.onStatus(setStatus), [client]);
+
+ const meta = COPY[status];
+ const pulsing =
+ status === "connecting" ||
+ status === "reconnecting" ||
+ status === "error";
+ return (
+
+
+ {pulsing && (
+
+ )}
+
+
+ {t(`connection.${status}`)}
+
+ );
+}
diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx
new file mode 100644
index 00000000..3342bbd3
--- /dev/null
+++ b/webui/src/components/DeleteConfirm.tsx
@@ -0,0 +1,52 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useTranslation } from "react-i18next";
+
+interface DeleteConfirmProps {
+ open: boolean;
+ title: string;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+export function DeleteConfirm({
+ open,
+ title,
+ onCancel,
+ onConfirm,
+}: DeleteConfirmProps) {
+ const { t } = useTranslation();
+ return (
+ (!o ? onCancel() : undefined)}>
+
+
+
+ {t("deleteConfirm.title", { title })}
+
+
+ {t("deleteConfirm.description")}
+
+
+
+
+ {t("deleteConfirm.cancel")}
+
+
+ {t("deleteConfirm.confirm")}
+
+
+
+
+ );
+}
diff --git a/webui/src/components/EmptyState.tsx b/webui/src/components/EmptyState.tsx
new file mode 100644
index 00000000..bbd06aaf
--- /dev/null
+++ b/webui/src/components/EmptyState.tsx
@@ -0,0 +1,26 @@
+import { MessageSquarePlus } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+
+export function EmptyState({
+ onNewChat,
+}: {
+ onNewChat: () => void;
+}) {
+ return (
+
+
+
+
No chats yet
+
+ Start a conversation — your sessions are stored locally on the nanobot
+ workspace and stay available across reloads.
+
+
+
New chat
+
+ );
+}
diff --git a/webui/src/components/LanguageSwitcher.tsx b/webui/src/components/LanguageSwitcher.tsx
new file mode 100644
index 00000000..c778aa16
--- /dev/null
+++ b/webui/src/components/LanguageSwitcher.tsx
@@ -0,0 +1,67 @@
+import { Globe } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { setAppLanguage } from "@/i18n";
+import {
+ currentLocale,
+} from "@/i18n";
+import {
+ localeOption,
+ supportedLocales,
+ type SupportedLocale,
+} from "@/i18n/config";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function LanguageSwitcher() {
+ const { t } = useTranslation();
+ const locale = currentLocale();
+ const selected = localeOption(locale);
+
+ return (
+
+
+
+
+ {selected.nativeLabel}
+
+
+
+ {t("sidebar.language.label")}
+
+ {
+ void setAppLanguage(value as SupportedLocale);
+ }}
+ >
+ {supportedLocales.map((option) => (
+
+
+ {option.nativeLabel}
+ {option.nativeLabel !== option.label ? (
+
+ {option.label}
+
+ ) : null}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx
new file mode 100644
index 00000000..11115896
--- /dev/null
+++ b/webui/src/components/MarkdownText.tsx
@@ -0,0 +1,39 @@
+import { Suspense, lazy } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface MarkdownTextProps {
+ children: string;
+ className?: string;
+}
+
+const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
+const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
+
+export function preloadMarkdownText(): void {
+ void loadMarkdownRenderer();
+}
+
+/**
+ * Lightweight markdown renderer mirroring agent-chat-ui: GFM + math via
+ * ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
+ * ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
+ */
+export function MarkdownText({ children, className }: MarkdownTextProps) {
+ return (
+
+ {children}
+
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx
new file mode 100644
index 00000000..fce32a98
--- /dev/null
+++ b/webui/src/components/MarkdownTextRenderer.tsx
@@ -0,0 +1,86 @@
+import ReactMarkdown from "react-markdown";
+import rehypeKatex from "rehype-katex";
+import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
+
+import { CodeBlock } from "@/components/CodeBlock";
+import { cn } from "@/lib/utils";
+
+import "katex/dist/katex.min.css";
+
+interface MarkdownTextRendererProps {
+ children: string;
+ className?: string;
+}
+
+/**
+ * Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
+ * separate chunk so the app shell can paint sooner on refresh.
+ */
+export default function MarkdownTextRenderer({
+ children,
+ className,
+}: MarkdownTextRendererProps) {
+ return (
+
+
+ {kids}
+
+ );
+ }
+ const code = String(kids).replace(/\n$/, "");
+ return ;
+ },
+ pre({ children: markdownChildren }) {
+ return <>{markdownChildren}>;
+ },
+ a({ href, children: markdownChildren, ...props }) {
+ return (
+
+ {markdownChildren}
+
+ );
+ },
+ }}
+ >
+ {children}
+
+
+ );
+}
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
new file mode 100644
index 00000000..6d974046
--- /dev/null
+++ b/webui/src/components/MessageBubble.tsx
@@ -0,0 +1,165 @@
+import { useState } from "react";
+import { ChevronRight, Wrench } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { MarkdownText } from "@/components/MarkdownText";
+import { cn } from "@/lib/utils";
+import type { UIMessage } from "@/lib/types";
+
+interface MessageBubbleProps {
+ message: UIMessage;
+}
+
+/**
+ * Render a single message. Following agent-chat-ui: user turns are a rounded
+ * "pill" right-aligned with a muted fill; assistant turns render as bare
+ * markdown so prose/code read like a document rather than a chat bubble.
+ * Each turn fades+slides in for a touch of motion polish.
+ *
+ * Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
+ * collapsible group so intermediate steps never masquerade as replies.
+ */
+export function MessageBubble({ message }: MessageBubbleProps) {
+ const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
+
+ if (message.kind === "trace") {
+ return ;
+ }
+
+ if (message.role === "user") {
+ return (
+
+
+ {message.content}
+
+
+ );
+ }
+
+ const empty = message.content.trim().length === 0;
+ return (
+
+ {empty && message.isStreaming ? (
+
+ ) : (
+ <>
+ {message.content}
+ {message.isStreaming && }
+ >
+ )}
+
+ );
+}
+
+/** Blinking cursor appended at the end of streaming text. */
+function StreamCursor() {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
+/** Pre-token-arrival placeholder: three bouncing dots. */
+function TypingDots() {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+ );
+}
+
+function Dot({ delay }: { delay: string }) {
+ return (
+
+ );
+}
+
+interface TraceGroupProps {
+ message: UIMessage;
+ animClass: string;
+}
+
+/**
+ * Collapsible group of tool-call / progress breadcrumbs. Defaults to
+ * expanded for discoverability; a single click on the header folds the
+ * group down to a one-line summary so it never dominates the thread.
+ */
+function TraceGroup({ message, animClass }: TraceGroupProps) {
+ const { t } = useTranslation();
+ const lines = message.traces ?? [message.content];
+ const count = lines.length;
+ const [open, setOpen] = useState(true);
+ return (
+
+
setOpen((v) => !v)}
+ className={cn(
+ "group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
+ "text-xs text-muted-foreground transition-colors hover:bg-muted/45",
+ )}
+ aria-expanded={open}
+ >
+
+
+ {count === 1
+ ? t("message.toolSingle")
+ : t("message.toolMany", { count })}
+
+
+
+ {open && (
+
+ {lines.map((line, i) => (
+
+ {line}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/webui/src/components/MessageList.tsx b/webui/src/components/MessageList.tsx
new file mode 100644
index 00000000..9ef9de65
--- /dev/null
+++ b/webui/src/components/MessageList.tsx
@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ArrowDown } from "lucide-react";
+
+import { MessageBubble } from "@/components/MessageBubble";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { UIMessage } from "@/lib/types";
+
+interface MessageListProps {
+ messages: UIMessage[];
+ isStreaming: boolean;
+}
+
+const NEAR_BOTTOM_PX = 48;
+
+/**
+ * Scrollable message log. Auto-sticks to the bottom as new content arrives,
+ * but only when the user was already at the bottom — preserving scroll
+ * position when they've scrolled up to read earlier turns. A floating
+ * "scroll to bottom" button appears whenever we're detached from the bottom.
+ */
+export function MessageList({ messages, isStreaming }: MessageListProps) {
+ const scrollRef = useRef(null);
+ const [atBottom, setAtBottom] = useState(true);
+
+ const scrollToBottom = useCallback((smooth = false) => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.scrollTo({
+ top: el.scrollHeight,
+ behavior: smooth ? "smooth" : "auto",
+ });
+ }, []);
+
+ // Keep the viewport pinned to the bottom as long as the user hasn't
+ // scrolled up. During streaming we do instant jumps (smooth scrolling each
+ // token fights the incoming animations); on settled updates we animate.
+ useEffect(() => {
+ if (!atBottom) return;
+ scrollToBottom(!isStreaming);
+ }, [messages, isStreaming, atBottom, scrollToBottom]);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const onScroll = () => {
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
+ setAtBottom(distance < NEAR_BOTTOM_PX);
+ };
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => el.removeEventListener("scroll", onScroll);
+ }, []);
+
+ if (messages.length === 0) {
+ return (
+
+ Say hi to get started.
+
+ );
+ }
+
+ return (
+
+
+
+ {messages.map((m) => (
+
+ ))}
+
+
+
+ {/* Top fade so messages slide under the header gracefully. */}
+
+ {/* Bottom fade so messages fade out behind the composer. */}
+
+
+ {!atBottom && (
+
scrollToBottom(true)}
+ className={cn(
+ "absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
+ "bg-background/90 backdrop-blur",
+ "animate-in fade-in-0 zoom-in-95",
+ )}
+ aria-label="Scroll to bottom"
+ >
+
+
+ )}
+
+ );
+}
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx
new file mode 100644
index 00000000..28f0bdab
--- /dev/null
+++ b/webui/src/components/Sidebar.tsx
@@ -0,0 +1,91 @@
+import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { ChatList } from "@/components/ChatList";
+import { ConnectionBadge } from "@/components/ConnectionBadge";
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import type { ChatSummary } from "@/lib/types";
+
+interface SidebarProps {
+ sessions: ChatSummary[];
+ activeKey: string | null;
+ loading: boolean;
+ theme: "light" | "dark";
+ onToggleTheme: () => void;
+ onNewChat: () => void;
+ onSelect: (key: string) => void;
+ onRefresh: () => void;
+ onRequestDelete: (key: string, label: string) => void;
+ onCollapse: () => void;
+}
+
+export function Sidebar(props: SidebarProps) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
new file mode 100644
index 00000000..35350fef
--- /dev/null
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -0,0 +1,148 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ArrowUp } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface ThreadComposerProps {
+ onSend: (content: string) => void;
+ disabled?: boolean;
+ placeholder?: string;
+ modelLabel?: string | null;
+ variant?: "thread" | "hero";
+}
+
+export function ThreadComposer({
+ onSend,
+ disabled,
+ placeholder,
+ modelLabel = null,
+ variant = "thread",
+}: ThreadComposerProps) {
+ const { t } = useTranslation();
+ const [value, setValue] = useState("");
+ const textareaRef = useRef(null);
+ const isHero = variant === "hero";
+ const resolvedPlaceholder =
+ placeholder ?? t("thread.composer.placeholderThread");
+
+ useEffect(() => {
+ if (disabled) return;
+ const el = textareaRef.current;
+ if (!el) return;
+ const id = requestAnimationFrame(() => el.focus());
+ return () => cancelAnimationFrame(id);
+ }, [disabled]);
+
+ const submit = useCallback(() => {
+ const trimmed = value.trim();
+ if (!trimmed || disabled) return;
+ onSend(trimmed);
+ setValue("");
+ requestAnimationFrame(() => {
+ const el = textareaRef.current;
+ if (el) {
+ el.style.height = "auto";
+ el.focus();
+ }
+ });
+ }, [disabled, onSend, value]);
+
+ const onKeyDown: React.KeyboardEventHandler = (e) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
+ e.preventDefault();
+ submit();
+ }
+ };
+
+ const onInput: React.FormEventHandler = (e) => {
+ const el = e.currentTarget;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
+ };
+
+ return (
+ {
+ e.preventDefault();
+ submit();
+ }}
+ className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
+ >
+
+
setValue(e.target.value)}
+ onInput={onInput}
+ onKeyDown={onKeyDown}
+ rows={1}
+ placeholder={resolvedPlaceholder}
+ disabled={disabled}
+ aria-label={t("thread.composer.inputAria")}
+ className={cn(
+ "w-full resize-none bg-transparent",
+ isHero
+ ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
+ : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
+ "placeholder:text-muted-foreground",
+ "focus:outline-none focus-visible:outline-none",
+ "disabled:cursor-not-allowed",
+ )}
+ />
+
+
+ {modelLabel ? (
+
+
+ {modelLabel}
+
+ ) : null}
+
+ {t("thread.composer.sendHint")}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx
new file mode 100644
index 00000000..bdc00ac2
--- /dev/null
+++ b/webui/src/components/thread/ThreadHeader.tsx
@@ -0,0 +1,54 @@
+import { PanelLeftOpen } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface ThreadHeaderProps {
+ title: string;
+ onToggleSidebar: () => void;
+ onGoHome: () => void;
+ hideSidebarToggleOnDesktop?: boolean;
+}
+
+export function ThreadHeader({
+ title,
+ onToggleSidebar,
+ onGoHome,
+ hideSidebarToggleOnDesktop = false,
+}: ThreadHeaderProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ );
+}
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
new file mode 100644
index 00000000..1ef5c864
--- /dev/null
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -0,0 +1,16 @@
+import { MessageBubble } from "@/components/MessageBubble";
+import type { UIMessage } from "@/lib/types";
+
+interface ThreadMessagesProps {
+ messages: UIMessage[];
+}
+
+export function ThreadMessages({ messages }: ThreadMessagesProps) {
+ return (
+
+ {messages.map((message) => (
+
+ ))}
+
+ );
+}
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
new file mode 100644
index 00000000..1b0bd6c8
--- /dev/null
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -0,0 +1,172 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { ThreadComposer } from "@/components/thread/ThreadComposer";
+import { ThreadHeader } from "@/components/thread/ThreadHeader";
+import { ThreadViewport } from "@/components/thread/ThreadViewport";
+import { useNanobotStream } from "@/hooks/useNanobotStream";
+import { useSessionHistory } from "@/hooks/useSessions";
+import type { ChatSummary, UIMessage } from "@/lib/types";
+import { useClient } from "@/providers/ClientProvider";
+
+interface ThreadShellProps {
+ session: ChatSummary | null;
+ title: string;
+ onToggleSidebar: () => void;
+ onGoHome: () => void;
+ onNewChat: () => Promise;
+ hideSidebarToggleOnDesktop?: boolean;
+}
+
+function toModelBadgeLabel(modelName: string | null): string | null {
+ if (!modelName) return null;
+ const trimmed = modelName.trim();
+ if (!trimmed) return null;
+ const leaf = trimmed.split("/").pop() ?? trimmed;
+ return leaf || trimmed;
+}
+
+export function ThreadShell({
+ session,
+ title,
+ onToggleSidebar,
+ onGoHome,
+ onNewChat,
+ hideSidebarToggleOnDesktop = false,
+}: ThreadShellProps) {
+ const { t } = useTranslation();
+ const chatId = session?.chatId ?? null;
+ const historyKey = session?.key ?? null;
+ const { messages: historical, loading } = useSessionHistory(historyKey);
+ const { client, modelName } = useClient();
+ const [booting, setBooting] = useState(false);
+ const pendingFirstRef = useRef(null);
+ const messageCacheRef = useRef>(new Map());
+
+ const initial = useMemo(() => {
+ if (!chatId) return historical;
+ return messageCacheRef.current.get(chatId) ?? historical;
+ }, [chatId, historical]);
+ const { messages, isStreaming, send, setMessages } = useNanobotStream(
+ chatId,
+ initial,
+ );
+ const showHeroComposer = messages.length === 0 && !loading;
+
+ useEffect(() => {
+ if (!chatId || loading) return;
+ const cached = messageCacheRef.current.get(chatId);
+ // When the user switches away and back, keep the local in-memory thread
+ // state (including not-yet-persisted messages) instead of replacing it with
+ // whatever the history endpoint currently knows about.
+ setMessages(cached && cached.length > 0 ? cached : historical);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loading, chatId, historical]);
+
+ useEffect(() => {
+ if (chatId) return;
+ setMessages(historical);
+ }, [chatId, historical, setMessages]);
+
+ useEffect(() => {
+ if (!chatId) return;
+ messageCacheRef.current.set(chatId, messages);
+ }, [chatId, messages]);
+
+ useEffect(() => {
+ if (!chatId) return;
+ const pending = pendingFirstRef.current;
+ if (!pending) return;
+ pendingFirstRef.current = null;
+ client.sendMessage(chatId, pending);
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ role: "user",
+ content: pending,
+ createdAt: Date.now(),
+ },
+ ]);
+ setBooting(false);
+ }, [chatId, client, setMessages]);
+
+ const handleWelcomeSend = useCallback(
+ async (content: string) => {
+ if (booting) return;
+ setBooting(true);
+ pendingFirstRef.current = content;
+ const newId = await onNewChat();
+ if (!newId) {
+ pendingFirstRef.current = null;
+ setBooting(false);
+ }
+ },
+ [booting, onNewChat],
+ );
+
+ const emptyState = loading ? (
+
+ {t("thread.loadingConversation")}
+
+ ) : (
+
+
+
+
nanobot
+
+
+ {t("thread.empty.description")}
+
+
+ );
+
+ return (
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+ );
+}
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
new file mode 100644
index 00000000..4ad43282
--- /dev/null
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -0,0 +1,116 @@
+import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
+import { ArrowDown } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { ThreadMessages } from "@/components/thread/ThreadMessages";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { UIMessage } from "@/lib/types";
+
+interface ThreadViewportProps {
+ messages: UIMessage[];
+ isStreaming: boolean;
+ composer: ReactNode;
+ emptyState?: ReactNode;
+}
+
+const NEAR_BOTTOM_PX = 48;
+
+export function ThreadViewport({
+ messages,
+ isStreaming,
+ composer,
+ emptyState,
+}: ThreadViewportProps) {
+ const { t } = useTranslation();
+ const scrollRef = useRef(null);
+ const [atBottom, setAtBottom] = useState(true);
+ const hasMessages = messages.length > 0;
+
+ const scrollToBottom = useCallback((smooth = false) => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.scrollTo({
+ top: el.scrollHeight,
+ behavior: smooth ? "smooth" : "auto",
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!atBottom) return;
+ scrollToBottom(!isStreaming);
+ }, [messages, isStreaming, atBottom, scrollToBottom]);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+
+ const onScroll = () => {
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
+ setAtBottom(distance < NEAR_BOTTOM_PX);
+ };
+
+ onScroll();
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => el.removeEventListener("scroll", onScroll);
+ }, []);
+
+ return (
+
+
+ {hasMessages ? (
+
+ ) : (
+
+
+
+ {emptyState}
+
{composer}
+
+
+
+ )}
+
+
+
+
+ {!atBottom && (
+
scrollToBottom(true)}
+ className={cn(
+ "absolute bottom-28 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
+ "bg-background/90 backdrop-blur",
+ "animate-in fade-in-0 zoom-in-95",
+ )}
+ aria-label={t("thread.scrollToBottom")}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/webui/src/components/ui/alert-dialog.tsx b/webui/src/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..4796a604
--- /dev/null
+++ b/webui/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,133 @@
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/webui/src/components/ui/avatar.tsx b/webui/src/components/ui/avatar.tsx
new file mode 100644
index 00000000..eb87cf6c
--- /dev/null
+++ b/webui/src/components/ui/avatar.tsx
@@ -0,0 +1,48 @@
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "@/lib/utils";
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarFallback, AvatarImage };
diff --git a/webui/src/components/ui/button.tsx b/webui/src/components/ui/button.tsx
new file mode 100644
index 00000000..f656699b
--- /dev/null
+++ b/webui/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/webui/src/components/ui/dialog.tsx b/webui/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..d4ed442c
--- /dev/null
+++ b/webui/src/components/ui/dialog.tsx
@@ -0,0 +1,116 @@
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+const DialogPortal = DialogPrimitive.Portal;
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..bd32944e
--- /dev/null
+++ b/webui/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,177 @@
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/webui/src/components/ui/input.tsx b/webui/src/components/ui/input.tsx
new file mode 100644
index 00000000..7de2a032
--- /dev/null
+++ b/webui/src/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export type InputProps = React.InputHTMLAttributes;
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/webui/src/components/ui/scroll-area.tsx b/webui/src/components/ui/scroll-area.tsx
new file mode 100644
index 00000000..ffc5a828
--- /dev/null
+++ b/webui/src/components/ui/scroll-area.tsx
@@ -0,0 +1,46 @@
+import * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+
+import { cn } from "@/lib/utils";
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/webui/src/components/ui/separator.tsx b/webui/src/components/ui/separator.tsx
new file mode 100644
index 00000000..4407ae5f
--- /dev/null
+++ b/webui/src/components/ui/separator.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) => (
+
+ ),
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/webui/src/components/ui/sheet.tsx b/webui/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..e964a35f
--- /dev/null
+++ b/webui/src/components/ui/sheet.tsx
@@ -0,0 +1,113 @@
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const Sheet = DialogPrimitive.Root;
+const SheetTrigger = DialogPrimitive.Trigger;
+const SheetClose = DialogPrimitive.Close;
+const SheetPortal = DialogPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out",
+ {
+ variants: {
+ side: {
+ top: cn(
+ "inset-x-0 top-0 border-b",
+ "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ ),
+ bottom: cn(
+ "inset-x-0 bottom-0 border-t",
+ "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ ),
+ left: cn(
+ "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
+ "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
+ ),
+ right: cn(
+ "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
+ "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
+ ),
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ },
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {
+ showCloseButton?: boolean;
+}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => (
+
+
+
+ {children}
+ {showCloseButton ? (
+
+
+ Close
+
+ ) : null}
+
+
+));
+SheetContent.displayName = DialogPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = DialogPrimitive.Title.displayName;
+
+export { Sheet, SheetTrigger, SheetClose, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetTitle };
diff --git a/webui/src/components/ui/textarea.tsx b/webui/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..fd57c06b
--- /dev/null
+++ b/webui/src/components/ui/textarea.tsx
@@ -0,0 +1,23 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export type TextareaProps = React.TextareaHTMLAttributes;
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/webui/src/components/ui/tooltip.tsx b/webui/src/components/ui/tooltip.tsx
new file mode 100644
index 00000000..95f7960c
--- /dev/null
+++ b/webui/src/components/ui/tooltip.tsx
@@ -0,0 +1,26 @@
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "@/lib/utils";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+const Tooltip = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/webui/src/globals.css b/webui/src/globals.css
new file mode 100644
index 00000000..d2a24fd1
--- /dev/null
+++ b/webui/src/globals.css
@@ -0,0 +1,113 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
+ --radius: 0.4375rem;
+ --sidebar: 0 0% 98%;
+ --sidebar-foreground: 0 0% 3.9%;
+ --sidebar-accent: 0 0% 96.1%;
+ --sidebar-accent-foreground: 0 0% 9%;
+ --sidebar-border: 0 0% 89.8%;
+ }
+
+ .dark {
+ --background: 0 0% 10%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 12%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 12%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 12%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 13%;
+ --muted-foreground: 0 0% 60%;
+ --accent: 0 0% 15%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 18%;
+ --input: 0 0% 18%;
+ --ring: 0 0% 83.1%;
+ --sidebar: 0 0% 12%;
+ --sidebar-foreground: 0 0% 98%;
+ --sidebar-accent: 0 0% 16%;
+ --sidebar-accent-foreground: 0 0% 98%;
+ --sidebar-border: 0 0% 18%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ html,
+ body,
+ #root {
+ @apply h-full;
+ }
+
+ body {
+ @apply bg-background text-foreground antialiased;
+ font-family:
+ ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji";
+ }
+
+ ::selection {
+ @apply bg-primary/15;
+ }
+}
+
+@layer utilities {
+ .shadow-inner-right {
+ box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
+ }
+
+ /* Markdown body styles, ported from agent-chat-ui's markdown-styles.css. */
+ .markdown-content > :first-child {
+ @apply mt-0;
+ }
+ .markdown-content > :last-child {
+ @apply mb-0;
+ }
+
+ /* Subtle scrollbar that doesn't fight the dark background. */
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
+ }
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: hsl(var(--muted-foreground) / 0.4);
+ border-radius: 9999px;
+ }
+}
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
new file mode 100644
index 00000000..45a7ab01
--- /dev/null
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -0,0 +1,165 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { useClient } from "@/providers/ClientProvider";
+import type { InboundEvent, UIMessage } from "@/lib/types";
+
+interface StreamBuffer {
+ /** ID of the assistant message currently receiving deltas. */
+ messageId: string;
+ /** Sequence of deltas accumulated in order. */
+ parts: string[];
+}
+
+/**
+ * Subscribe to a chat by ID. Returns the in-memory message list for the chat,
+ * a streaming flag, and a ``send`` function. Initial history must be seeded
+ * separately (e.g. via ``fetchSessionMessages``) since the server only replays
+ * live events.
+ */
+export function useNanobotStream(
+ chatId: string | null,
+ initialMessages: UIMessage[] = [],
+): {
+ messages: UIMessage[];
+ isStreaming: boolean;
+ send: (content: string) => void;
+ setMessages: React.Dispatch>;
+} {
+ const { client } = useClient();
+ const [messages, setMessages] = useState(initialMessages);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const buffer = useRef(null);
+
+ // Reset local state when switching chats.
+ useEffect(() => {
+ setMessages(initialMessages);
+ setIsStreaming(false);
+ buffer.current = null;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chatId]);
+
+ useEffect(() => {
+ if (!chatId) return;
+
+ const handle = (ev: InboundEvent) => {
+ if (ev.event === "delta") {
+ const id = buffer.current?.messageId ?? crypto.randomUUID();
+ if (!buffer.current) {
+ buffer.current = { messageId: id, parts: [] };
+ setMessages((prev) => [
+ ...prev,
+ {
+ id,
+ role: "assistant",
+ content: "",
+ isStreaming: true,
+ createdAt: Date.now(),
+ },
+ ]);
+ setIsStreaming(true);
+ }
+ buffer.current.parts.push(ev.text);
+ const combined = buffer.current.parts.join("");
+ const targetId = buffer.current.messageId;
+ setMessages((prev) =>
+ prev.map((m) => (m.id === targetId ? { ...m, content: combined } : m)),
+ );
+ return;
+ }
+
+ if (ev.event === "stream_end") {
+ if (!buffer.current) {
+ setIsStreaming(false);
+ return;
+ }
+ const finalId = buffer.current.messageId;
+ buffer.current = null;
+ setIsStreaming(false);
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === finalId ? { ...m, isStreaming: false } : m,
+ ),
+ );
+ return;
+ }
+
+ if (ev.event === "message") {
+ // Intermediate agent breadcrumbs (tool-call hints, raw progress).
+ // Attach them to the last trace row if it was the last emitted item
+ // so a sequence of calls collapses into one compact trace group.
+ if (ev.kind === "tool_hint" || ev.kind === "progress") {
+ const line = ev.text;
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ if (last && last.kind === "trace" && !last.isStreaming) {
+ const merged: UIMessage = {
+ ...last,
+ traces: [...(last.traces ?? [last.content]), line],
+ content: line,
+ };
+ return [...prev.slice(0, -1), merged];
+ }
+ return [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ role: "tool",
+ kind: "trace",
+ content: line,
+ traces: [line],
+ createdAt: Date.now(),
+ },
+ ];
+ });
+ return;
+ }
+
+ // A complete (non-streamed) assistant message. If a stream was in
+ // flight, drop the placeholder so we don't render the text twice.
+ const activeId = buffer.current?.messageId;
+ buffer.current = null;
+ setIsStreaming(false);
+ setMessages((prev) => {
+ const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
+ return [
+ ...filtered,
+ {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: ev.text,
+ createdAt: Date.now(),
+ },
+ ];
+ });
+ return;
+ }
+ // ``attached`` / ``error`` frames aren't actionable here; the client
+ // shell handles them separately.
+ };
+
+ const unsub = client.onChat(chatId, handle);
+ return () => {
+ unsub();
+ buffer.current = null;
+ };
+ }, [chatId, client]);
+
+ const send = useCallback(
+ (content: string) => {
+ if (!chatId || !content.trim()) return;
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ role: "user",
+ content,
+ createdAt: Date.now(),
+ },
+ ]);
+ client.sendMessage(chatId, content);
+ },
+ [chatId, client],
+ );
+
+ return { messages, isStreaming, send, setMessages };
+}
diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts
new file mode 100644
index 00000000..b627f860
--- /dev/null
+++ b/webui/src/hooks/useSessions.ts
@@ -0,0 +1,193 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { useClient } from "@/providers/ClientProvider";
+import i18n from "@/i18n";
+import {
+ ApiError,
+ deleteSession as apiDeleteSession,
+ fetchSessionMessages,
+ listSessions,
+} from "@/lib/api";
+import { deriveTitle } from "@/lib/format";
+import type { ChatSummary, UIMessage } from "@/lib/types";
+
+const EMPTY_MESSAGES: UIMessage[] = [];
+
+/** Sidebar state: fetches the full session list and exposes create / delete actions. */
+export function useSessions(): {
+ sessions: ChatSummary[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ createChat: () => Promise;
+ deleteChat: (key: string) => Promise;
+} {
+ const { client, token } = useClient();
+ const [sessions, setSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const tokenRef = useRef(token);
+ tokenRef.current = token;
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ const rows = await listSessions(tokenRef.current);
+ setSessions(rows);
+ setError(null);
+ } catch (e) {
+ const msg =
+ e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const createChat = useCallback(async (): Promise => {
+ const chatId = await client.newChat();
+ const key = `websocket:${chatId}`;
+ // Optimistic insert; a subsequent refresh will replace it with the
+ // authoritative row once the server persists the session.
+ setSessions((prev) => [
+ {
+ key,
+ channel: "websocket",
+ chatId,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ preview: "",
+ },
+ ...prev.filter((s) => s.key !== key),
+ ]);
+ return chatId;
+ }, [client]);
+
+ const deleteChat = useCallback(
+ async (key: string) => {
+ await apiDeleteSession(tokenRef.current, key);
+ setSessions((prev) => prev.filter((s) => s.key !== key));
+ },
+ [],
+ );
+
+ return { sessions, loading, error, refresh, createChat, deleteChat };
+}
+
+/** Lazy-load a session's on-disk messages the first time the UI displays it. */
+export function useSessionHistory(key: string | null): {
+ messages: UIMessage[];
+ loading: boolean;
+ error: string | null;
+} {
+ const { token } = useClient();
+ const [state, setState] = useState<{
+ key: string | null;
+ messages: UIMessage[];
+ loading: boolean;
+ error: string | null;
+ }>({
+ key: null,
+ messages: [],
+ loading: false,
+ error: null,
+ });
+
+ useEffect(() => {
+ if (!key) {
+ setState({
+ key: null,
+ messages: [],
+ loading: false,
+ error: null,
+ });
+ return;
+ }
+ let cancelled = false;
+ // Mark the new key as loading immediately so callers never see stale
+ // messages from the previous session during the render right after a switch.
+ setState({
+ key,
+ messages: [],
+ loading: true,
+ error: null,
+ });
+ (async () => {
+ try {
+ const body = await fetchSessionMessages(token, key);
+ if (cancelled) return;
+ const ui: UIMessage[] = body.messages.flatMap((m, idx) => {
+ if (m.role !== "user" && m.role !== "assistant") return [];
+ if (typeof m.content !== "string") return [];
+ return [
+ {
+ id: `hist-${idx}`,
+ role: m.role,
+ content: m.content,
+ createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(),
+ },
+ ];
+ });
+ setState({
+ key,
+ messages: ui,
+ loading: false,
+ error: null,
+ });
+ } catch (e) {
+ if (cancelled) return;
+ // A 404 just means the session hasn't been persisted yet (brand-new
+ // chat, first message not sent). That's a normal state, not an error.
+ if (e instanceof ApiError && e.status === 404) {
+ setState({
+ key,
+ messages: [],
+ loading: false,
+ error: null,
+ });
+ } else {
+ setState({
+ key,
+ messages: [],
+ loading: false,
+ error: (e as Error).message,
+ });
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [key, token]);
+
+ if (!key) {
+ return { messages: EMPTY_MESSAGES, loading: false, error: null };
+ }
+
+ // Even before the effect above commits its loading state, never surface the
+ // previous session's payload for a brand-new key.
+ if (state.key !== key) {
+ return { messages: EMPTY_MESSAGES, loading: true, error: null };
+ }
+
+ return {
+ messages: state.messages,
+ loading: state.loading,
+ error: state.error,
+ };
+}
+
+/** Produce a compact display title for a session. */
+export function sessionTitle(
+ session: ChatSummary,
+ firstUserMessage?: string,
+): string {
+ return deriveTitle(
+ firstUserMessage || session.preview,
+ i18n.t("chat.newChat"),
+ );
+}
diff --git a/webui/src/hooks/useTheme.ts b/webui/src/hooks/useTheme.ts
new file mode 100644
index 00000000..7d7b4811
--- /dev/null
+++ b/webui/src/hooks/useTheme.ts
@@ -0,0 +1,48 @@
+import { useCallback, useEffect, useState } from "react";
+
+type Theme = "light" | "dark";
+const STORAGE_KEY = "nanobot-webui.theme";
+
+function readStored(): Theme | null {
+ try {
+ const v = localStorage.getItem(STORAGE_KEY);
+ return v === "light" || v === "dark" ? v : null;
+ } catch {
+ return null;
+ }
+}
+
+function applyTheme(theme: Theme): void {
+ const root = document.documentElement;
+ if (theme === "dark") root.classList.add("dark");
+ else root.classList.remove("dark");
+}
+
+export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
+ const [theme, setThemeState] = useState(() => {
+ const stored = readStored();
+ if (stored) return stored;
+ if (typeof window !== "undefined" && window.matchMedia) {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+ return "light";
+ });
+
+ useEffect(() => {
+ applyTheme(theme);
+ try {
+ localStorage.setItem(STORAGE_KEY, theme);
+ } catch {
+ // ignore
+ }
+ }, [theme]);
+
+ const setTheme = useCallback((t: Theme) => setThemeState(t), []);
+ const toggle = useCallback(
+ () => setThemeState((t) => (t === "dark" ? "light" : "dark")),
+ [],
+ );
+ return { theme, toggle, setTheme };
+}
diff --git a/webui/src/i18n/config.ts b/webui/src/i18n/config.ts
new file mode 100644
index 00000000..f4c98bb3
--- /dev/null
+++ b/webui/src/i18n/config.ts
@@ -0,0 +1,93 @@
+export const LOCALE_STORAGE_KEY = "nanobot.locale";
+
+export const supportedLocales = [
+ { code: "en", label: "English", nativeLabel: "English" },
+ { code: "zh-CN", label: "Chinese (Simplified)", nativeLabel: "简体中文" },
+ { code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" },
+ { code: "fr", label: "French", nativeLabel: "Français" },
+ { code: "ja", label: "Japanese", nativeLabel: "日本語" },
+ { code: "ko", label: "Korean", nativeLabel: "한국어" },
+ { code: "es", label: "Spanish", nativeLabel: "Español" },
+ { code: "vi", label: "Vietnamese", nativeLabel: "Tiếng Việt" },
+ { code: "id", label: "Indonesian", nativeLabel: "Bahasa Indonesia" },
+] as const;
+
+export type SupportedLocale = (typeof supportedLocales)[number]["code"];
+
+export const defaultLocale: SupportedLocale = "en";
+export const fallbackLocale: SupportedLocale = "en";
+
+export function normalizeLocale(
+ input: string | null | undefined,
+): SupportedLocale {
+ if (!input) return defaultLocale;
+ const trimmed = input.trim();
+ if (!trimmed) return defaultLocale;
+
+ const exact = supportedLocales.find((locale) => locale.code === trimmed);
+ if (exact) return exact.code;
+
+ const lower = trimmed.toLowerCase();
+ if (lower === "zh" || lower.startsWith("zh-cn") || lower.startsWith("zh-sg")) {
+ return "zh-CN";
+ }
+ if (
+ lower.startsWith("zh-tw") ||
+ lower.startsWith("zh-hk") ||
+ lower.startsWith("zh-mo") ||
+ lower.startsWith("zh-hant")
+ ) {
+ return "zh-TW";
+ }
+
+ const base = lower.split("-")[0];
+ const baseMatch = supportedLocales.find(
+ (locale) => locale.code.toLowerCase() === base,
+ );
+ return baseMatch?.code ?? defaultLocale;
+}
+
+export function readStoredLocale(): SupportedLocale | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ return raw ? normalizeLocale(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function detectNavigatorLocale(): SupportedLocale {
+ if (typeof navigator === "undefined") return defaultLocale;
+ const candidates = [
+ ...(navigator.languages ?? []),
+ navigator.language,
+ ].filter(Boolean);
+ for (const locale of candidates) {
+ const normalized = normalizeLocale(locale);
+ if (normalized) return normalized;
+ }
+ return defaultLocale;
+}
+
+export function resolveInitialLocale(): SupportedLocale {
+ return readStoredLocale() ?? detectNavigatorLocale();
+}
+
+export function persistLocale(locale: SupportedLocale): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
+ } catch {
+ // ignore storage errors
+ }
+}
+
+export function applyDocumentLocale(locale: SupportedLocale): void {
+ if (typeof document === "undefined") return;
+ document.documentElement.lang = locale;
+}
+
+export function localeOption(locale: SupportedLocale) {
+ return supportedLocales.find((entry) => entry.code === locale) ?? supportedLocales[0];
+}
diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts
new file mode 100644
index 00000000..64f713bb
--- /dev/null
+++ b/webui/src/i18n/index.ts
@@ -0,0 +1,72 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import {
+ applyDocumentLocale,
+ defaultLocale,
+ fallbackLocale,
+ LOCALE_STORAGE_KEY,
+ normalizeLocale,
+ persistLocale,
+ resolveInitialLocale,
+ type SupportedLocale,
+} from "./config";
+
+import enCommon from "./locales/en/common.json";
+import zhCNCommon from "./locales/zh-CN/common.json";
+import zhTWCommon from "./locales/zh-TW/common.json";
+import frCommon from "./locales/fr/common.json";
+import jaCommon from "./locales/ja/common.json";
+import koCommon from "./locales/ko/common.json";
+import esCommon from "./locales/es/common.json";
+import viCommon from "./locales/vi/common.json";
+import idCommon from "./locales/id/common.json";
+
+export const resources = {
+ en: { common: enCommon },
+ "zh-CN": { common: zhCNCommon },
+ "zh-TW": { common: zhTWCommon },
+ fr: { common: frCommon },
+ ja: { common: jaCommon },
+ ko: { common: koCommon },
+ es: { common: esCommon },
+ vi: { common: viCommon },
+ id: { common: idCommon },
+} as const;
+
+export function currentLocale(): SupportedLocale {
+ return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
+}
+
+export async function setAppLanguage(locale: SupportedLocale): Promise {
+ await i18n.changeLanguage(locale);
+}
+
+if (!i18n.isInitialized) {
+ void i18n
+ .use(initReactI18next)
+ .init({
+ resources,
+ lng: resolveInitialLocale(),
+ fallbackLng: fallbackLocale,
+ defaultNS: "common",
+ ns: ["common"],
+ interpolation: {
+ escapeValue: false,
+ },
+ returnNull: false,
+ supportedLngs: Object.keys(resources),
+ });
+}
+
+const syncLocaleSideEffects = (language: string) => {
+ const locale = normalizeLocale(language);
+ applyDocumentLocale(locale);
+ persistLocale(locale);
+};
+
+syncLocaleSideEffects(currentLocale());
+i18n.on("languageChanged", syncLocaleSideEffects);
+
+export { LOCALE_STORAGE_KEY };
+export default i18n;
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
new file mode 100644
index 00000000..9fc01663
--- /dev/null
+++ b/webui/src/i18n/locales/en/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Connecting to nanobot…",
+ "boot": "Loading nanobot…"
+ },
+ "error": {
+ "title": "Couldn't reach nanobot",
+ "gatewayHint": "Make sure the gateway is running (`nanobot web`) and that this page is open on the same machine."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot web UI — chat with your nanobot workspace."
+ }
+ },
+ "sidebar": {
+ "collapse": "Collapse sidebar",
+ "toggleTheme": "Toggle theme",
+ "newChat": "New chat",
+ "recent": "Recent",
+ "refreshSessions": "Refresh sessions",
+ "language": {
+ "label": "Language",
+ "ariaLabel": "Change language"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Chat {{id}}",
+ "loading": "Loading…",
+ "noSessions": "No sessions yet.",
+ "actions": "Chat actions for {{title}}",
+ "delete": "Delete",
+ "newChat": "New chat"
+ },
+ "deleteConfirm": {
+ "title": "Delete “{{title}}”?",
+ "description": "The session file will be removed from disk. This cannot be undone.",
+ "cancel": "Cancel",
+ "confirm": "Delete"
+ },
+ "connection": {
+ "idle": "Idle",
+ "connecting": "Connecting…",
+ "open": "Connected",
+ "reconnecting": "Reconnecting…",
+ "closed": "Disconnected",
+ "error": "Connection error"
+ },
+ "thread": {
+ "loadingConversation": "Loading conversation…",
+ "empty": {
+ "description": "Ask questions, continue local work, or start a new thread."
+ },
+ "header": {
+ "toggleSidebar": "Toggle sidebar"
+ },
+ "composer": {
+ "placeholderThread": "Type your message…",
+ "placeholderHero": "What's on your mind?",
+ "placeholderOpening": "Opening a new chat…",
+ "inputAria": "Message input",
+ "sendHint": "Enter to send · Shift+Enter for newline",
+ "send": "Send message"
+ },
+ "scrollToBottom": "Scroll to bottom"
+ },
+ "message": {
+ "streaming": "streaming",
+ "assistantTyping": "Assistant is typing",
+ "toolSingle": "Using a tool",
+ "toolMany": "Used {{count}} tools"
+ },
+ "code": {
+ "fallbackLanguage": "code",
+ "copyAria": "Copy code",
+ "copy": "Copy",
+ "copied": "Copied"
+ }
+}
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
new file mode 100644
index 00000000..1c099364
--- /dev/null
+++ b/webui/src/i18n/locales/es/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Conectando con nanobot…",
+ "boot": "Cargando nanobot…"
+ },
+ "error": {
+ "title": "No se pudo conectar con nanobot",
+ "gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot web`) y de que esta página esté abierta en la misma máquina."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
+ }
+ },
+ "sidebar": {
+ "collapse": "Contraer barra lateral",
+ "toggleTheme": "Cambiar tema",
+ "newChat": "Nuevo chat",
+ "recent": "Recientes",
+ "refreshSessions": "Actualizar sesiones",
+ "language": {
+ "label": "Idioma",
+ "ariaLabel": "Cambiar idioma"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Chat {{id}}",
+ "loading": "Cargando…",
+ "noSessions": "Todavía no hay sesiones.",
+ "actions": "Acciones del chat {{title}}",
+ "delete": "Eliminar",
+ "newChat": "Nuevo chat"
+ },
+ "deleteConfirm": {
+ "title": "¿Eliminar “{{title}}”?",
+ "description": "El archivo de sesión se eliminará del disco. Esta acción no se puede deshacer.",
+ "cancel": "Cancelar",
+ "confirm": "Eliminar"
+ },
+ "connection": {
+ "idle": "Inactivo",
+ "connecting": "Conectando…",
+ "open": "Conectado",
+ "reconnecting": "Reconectando…",
+ "closed": "Desconectado",
+ "error": "Error de conexión"
+ },
+ "thread": {
+ "loadingConversation": "Cargando conversación…",
+ "empty": {
+ "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
+ },
+ "header": {
+ "toggleSidebar": "Mostrar u ocultar la barra lateral"
+ },
+ "composer": {
+ "placeholderThread": "Escribe tu mensaje…",
+ "placeholderHero": "¿Qué tienes en mente?",
+ "placeholderOpening": "Abriendo un nuevo chat…",
+ "inputAria": "Entrada de mensaje",
+ "sendHint": "Enter para enviar · Shift+Enter para nueva línea",
+ "send": "Enviar mensaje"
+ },
+ "scrollToBottom": "Desplazarse al final"
+ },
+ "message": {
+ "streaming": "transmitiendo",
+ "assistantTyping": "El asistente está escribiendo",
+ "toolSingle": "Usando una herramienta",
+ "toolMany": "Se usaron {{count}} herramientas"
+ },
+ "code": {
+ "fallbackLanguage": "código",
+ "copyAria": "Copiar código",
+ "copy": "Copiar",
+ "copied": "Copiado"
+ }
+}
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
new file mode 100644
index 00000000..75e4753d
--- /dev/null
+++ b/webui/src/i18n/locales/fr/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Connexion à nanobot…",
+ "boot": "Chargement de nanobot…"
+ },
+ "error": {
+ "title": "Impossible de joindre nanobot",
+ "gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot web`) et que cette page est ouverte sur la même machine."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "Interface web nanobot — discutez avec votre espace de travail nanobot."
+ }
+ },
+ "sidebar": {
+ "collapse": "Réduire la barre latérale",
+ "toggleTheme": "Changer de thème",
+ "newChat": "Nouvelle discussion",
+ "recent": "Récentes",
+ "refreshSessions": "Actualiser les sessions",
+ "language": {
+ "label": "Langue",
+ "ariaLabel": "Changer de langue"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Discussion {{id}}",
+ "loading": "Chargement…",
+ "noSessions": "Aucune session pour le moment.",
+ "actions": "Actions de la discussion {{title}}",
+ "delete": "Supprimer",
+ "newChat": "Nouvelle discussion"
+ },
+ "deleteConfirm": {
+ "title": "Supprimer « {{title}} » ?",
+ "description": "Le fichier de session sera supprimé du disque. Cette action est irréversible.",
+ "cancel": "Annuler",
+ "confirm": "Supprimer"
+ },
+ "connection": {
+ "idle": "Inactif",
+ "connecting": "Connexion…",
+ "open": "Connecté",
+ "reconnecting": "Reconnexion…",
+ "closed": "Déconnecté",
+ "error": "Erreur de connexion"
+ },
+ "thread": {
+ "loadingConversation": "Chargement de la conversation…",
+ "empty": {
+ "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
+ },
+ "header": {
+ "toggleSidebar": "Afficher ou masquer la barre latérale"
+ },
+ "composer": {
+ "placeholderThread": "Saisissez votre message…",
+ "placeholderHero": "Qu’avez-vous en tête ?",
+ "placeholderOpening": "Ouverture d’une nouvelle discussion…",
+ "inputAria": "Champ de message",
+ "sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
+ "send": "Envoyer le message"
+ },
+ "scrollToBottom": "Faire défiler vers le bas"
+ },
+ "message": {
+ "streaming": "en cours de génération",
+ "assistantTyping": "L’assistant est en train d’écrire",
+ "toolSingle": "Utilisation d’un outil",
+ "toolMany": "{{count}} outils utilisés"
+ },
+ "code": {
+ "fallbackLanguage": "code",
+ "copyAria": "Copier le code",
+ "copy": "Copier",
+ "copied": "Copié"
+ }
+}
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
new file mode 100644
index 00000000..6085046e
--- /dev/null
+++ b/webui/src/i18n/locales/id/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Menghubungkan ke nanobot…",
+ "boot": "Memuat nanobot…"
+ },
+ "error": {
+ "title": "Tidak dapat menjangkau nanobot",
+ "gatewayHint": "Pastikan gateway sedang berjalan (`nanobot web`) dan halaman ini dibuka pada mesin yang sama."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "UI web nanobot — ngobrol dengan workspace nanobot Anda."
+ }
+ },
+ "sidebar": {
+ "collapse": "Ciutkan sidebar",
+ "toggleTheme": "Ganti tema",
+ "newChat": "Obrolan baru",
+ "recent": "Terbaru",
+ "refreshSessions": "Segarkan sesi",
+ "language": {
+ "label": "Bahasa",
+ "ariaLabel": "Ganti bahasa"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Obrolan {{id}}",
+ "loading": "Memuat…",
+ "noSessions": "Belum ada sesi.",
+ "actions": "Aksi obrolan untuk {{title}}",
+ "delete": "Hapus",
+ "newChat": "Obrolan baru"
+ },
+ "deleteConfirm": {
+ "title": "Hapus “{{title}}”?",
+ "description": "File sesi akan dihapus dari disk. Tindakan ini tidak dapat dibatalkan.",
+ "cancel": "Batal",
+ "confirm": "Hapus"
+ },
+ "connection": {
+ "idle": "Idle",
+ "connecting": "Menghubungkan…",
+ "open": "Terhubung",
+ "reconnecting": "Menyambung ulang…",
+ "closed": "Terputus",
+ "error": "Kesalahan koneksi"
+ },
+ "thread": {
+ "loadingConversation": "Memuat percakapan…",
+ "empty": {
+ "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
+ },
+ "header": {
+ "toggleSidebar": "Tampilkan atau sembunyikan sidebar"
+ },
+ "composer": {
+ "placeholderThread": "Ketik pesan Anda…",
+ "placeholderHero": "Apa yang sedang Anda pikirkan?",
+ "placeholderOpening": "Membuka obrolan baru…",
+ "inputAria": "Input pesan",
+ "sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
+ "send": "Kirim pesan"
+ },
+ "scrollToBottom": "Gulir ke bawah"
+ },
+ "message": {
+ "streaming": "sedang mengalir",
+ "assistantTyping": "Asisten sedang mengetik",
+ "toolSingle": "Menggunakan sebuah alat",
+ "toolMany": "Menggunakan {{count}} alat"
+ },
+ "code": {
+ "fallbackLanguage": "kode",
+ "copyAria": "Salin kode",
+ "copy": "Salin",
+ "copied": "Tersalin"
+ }
+}
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
new file mode 100644
index 00000000..5f76ac0c
--- /dev/null
+++ b/webui/src/i18n/locales/ja/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "nanobot に接続中…",
+ "boot": "nanobot を読み込み中…"
+ },
+ "error": {
+ "title": "nanobot に接続できませんでした",
+ "gatewayHint": "gateway(`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot Web UI — nanobot ワークスペースと会話します。"
+ }
+ },
+ "sidebar": {
+ "collapse": "サイドバーを閉じる",
+ "toggleTheme": "テーマを切り替える",
+ "newChat": "新しいチャット",
+ "recent": "最近のチャット",
+ "refreshSessions": "セッションを更新",
+ "language": {
+ "label": "言語",
+ "ariaLabel": "言語を変更"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "チャット {{id}}",
+ "loading": "読み込み中…",
+ "noSessions": "まだセッションがありません。",
+ "actions": "「{{title}}」のチャット操作",
+ "delete": "削除",
+ "newChat": "新しいチャット"
+ },
+ "deleteConfirm": {
+ "title": "「{{title}}」を削除しますか?",
+ "description": "セッションファイルはディスクから削除されます。この操作は元に戻せません。",
+ "cancel": "キャンセル",
+ "confirm": "削除"
+ },
+ "connection": {
+ "idle": "待機中",
+ "connecting": "接続中…",
+ "open": "接続済み",
+ "reconnecting": "再接続中…",
+ "closed": "切断済み",
+ "error": "接続エラー"
+ },
+ "thread": {
+ "loadingConversation": "会話を読み込み中…",
+ "empty": {
+ "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
+ },
+ "header": {
+ "toggleSidebar": "サイドバーを切り替える"
+ },
+ "composer": {
+ "placeholderThread": "メッセージを入力…",
+ "placeholderHero": "何を考えていますか?",
+ "placeholderOpening": "新しいチャットを開いています…",
+ "inputAria": "メッセージ入力欄",
+ "sendHint": "Enter で送信 · Shift+Enter で改行",
+ "send": "メッセージを送信"
+ },
+ "scrollToBottom": "一番下へスクロール"
+ },
+ "message": {
+ "streaming": "生成中",
+ "assistantTyping": "アシスタントが入力中",
+ "toolSingle": "ツールを使用中",
+ "toolMany": "{{count}} 個のツールを使用"
+ },
+ "code": {
+ "fallbackLanguage": "コード",
+ "copyAria": "コードをコピー",
+ "copy": "コピー",
+ "copied": "コピーしました"
+ }
+}
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
new file mode 100644
index 00000000..bc840764
--- /dev/null
+++ b/webui/src/i18n/locales/ko/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "nanobot에 연결 중…",
+ "boot": "nanobot 불러오는 중…"
+ },
+ "error": {
+ "title": "nanobot에 연결할 수 없습니다",
+ "gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
+ }
+ },
+ "sidebar": {
+ "collapse": "사이드바 접기",
+ "toggleTheme": "테마 전환",
+ "newChat": "새 채팅",
+ "recent": "최근 대화",
+ "refreshSessions": "세션 새로고침",
+ "language": {
+ "label": "언어",
+ "ariaLabel": "언어 변경"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "채팅 {{id}}",
+ "loading": "불러오는 중…",
+ "noSessions": "아직 세션이 없습니다.",
+ "actions": "{{title}} 채팅 작업",
+ "delete": "삭제",
+ "newChat": "새 채팅"
+ },
+ "deleteConfirm": {
+ "title": "“{{title}}”을(를) 삭제할까요?",
+ "description": "세션 파일이 디스크에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.",
+ "cancel": "취소",
+ "confirm": "삭제"
+ },
+ "connection": {
+ "idle": "대기 중",
+ "connecting": "연결 중…",
+ "open": "연결됨",
+ "reconnecting": "재연결 중…",
+ "closed": "연결 끊김",
+ "error": "연결 오류"
+ },
+ "thread": {
+ "loadingConversation": "대화 불러오는 중…",
+ "empty": {
+ "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
+ },
+ "header": {
+ "toggleSidebar": "사이드바 전환"
+ },
+ "composer": {
+ "placeholderThread": "메시지를 입력하세요…",
+ "placeholderHero": "무슨 생각을 하고 있나요?",
+ "placeholderOpening": "새 채팅을 여는 중…",
+ "inputAria": "메시지 입력",
+ "sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
+ "send": "메시지 보내기"
+ },
+ "scrollToBottom": "맨 아래로 스크롤"
+ },
+ "message": {
+ "streaming": "생성 중",
+ "assistantTyping": "도우미가 입력 중",
+ "toolSingle": "도구 사용 중",
+ "toolMany": "도구 {{count}}개 사용됨"
+ },
+ "code": {
+ "fallbackLanguage": "코드",
+ "copyAria": "코드 복사",
+ "copy": "복사",
+ "copied": "복사됨"
+ }
+}
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
new file mode 100644
index 00000000..d648f60e
--- /dev/null
+++ b/webui/src/i18n/locales/vi/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Đang kết nối tới nanobot…",
+ "boot": "Đang tải nanobot…"
+ },
+ "error": {
+ "title": "Không thể kết nối tới nanobot",
+ "gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot web`) và trang này được mở trên cùng máy."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
+ }
+ },
+ "sidebar": {
+ "collapse": "Thu gọn thanh bên",
+ "toggleTheme": "Chuyển giao diện",
+ "newChat": "Cuộc trò chuyện mới",
+ "recent": "Gần đây",
+ "refreshSessions": "Làm mới phiên",
+ "language": {
+ "label": "Ngôn ngữ",
+ "ariaLabel": "Đổi ngôn ngữ"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Trò chuyện {{id}}",
+ "loading": "Đang tải…",
+ "noSessions": "Chưa có phiên nào.",
+ "actions": "Tác vụ cho cuộc trò chuyện {{title}}",
+ "delete": "Xóa",
+ "newChat": "Cuộc trò chuyện mới"
+ },
+ "deleteConfirm": {
+ "title": "Xóa “{{title}}”?",
+ "description": "Tệp phiên sẽ bị xóa khỏi đĩa. Không thể hoàn tác thao tác này.",
+ "cancel": "Hủy",
+ "confirm": "Xóa"
+ },
+ "connection": {
+ "idle": "Rảnh",
+ "connecting": "Đang kết nối…",
+ "open": "Đã kết nối",
+ "reconnecting": "Đang kết nối lại…",
+ "closed": "Đã ngắt kết nối",
+ "error": "Lỗi kết nối"
+ },
+ "thread": {
+ "loadingConversation": "Đang tải cuộc trò chuyện…",
+ "empty": {
+ "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
+ },
+ "header": {
+ "toggleSidebar": "Bật/tắt thanh bên"
+ },
+ "composer": {
+ "placeholderThread": "Nhập tin nhắn…",
+ "placeholderHero": "Bạn đang nghĩ gì?",
+ "placeholderOpening": "Đang mở cuộc trò chuyện mới…",
+ "inputAria": "Ô nhập tin nhắn",
+ "sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
+ "send": "Gửi tin nhắn"
+ },
+ "scrollToBottom": "Cuộn xuống cuối"
+ },
+ "message": {
+ "streaming": "đang truyền",
+ "assistantTyping": "Trợ lý đang nhập",
+ "toolSingle": "Đang dùng một công cụ",
+ "toolMany": "Đã dùng {{count}} công cụ"
+ },
+ "code": {
+ "fallbackLanguage": "mã",
+ "copyAria": "Sao chép mã",
+ "copy": "Sao chép",
+ "copied": "Đã sao chép"
+ }
+}
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
new file mode 100644
index 00000000..67a12f3f
--- /dev/null
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "正在连接 nanobot…",
+ "boot": "正在加载 nanobot…"
+ },
+ "error": {
+ "title": "无法连接到 nanobot",
+ "gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot Web UI —— 与你的 nanobot 工作区对话。"
+ }
+ },
+ "sidebar": {
+ "collapse": "收起侧边栏",
+ "toggleTheme": "切换主题",
+ "newChat": "新建对话",
+ "recent": "最近对话",
+ "refreshSessions": "刷新会话",
+ "language": {
+ "label": "语言",
+ "ariaLabel": "切换语言"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "对话 {{id}}",
+ "loading": "加载中…",
+ "noSessions": "还没有会话。",
+ "actions": "“{{title}}” 的会话操作",
+ "delete": "删除",
+ "newChat": "新建对话"
+ },
+ "deleteConfirm": {
+ "title": "删除“{{title}}”?",
+ "description": "这个会话文件会从磁盘中删除,且无法撤销。",
+ "cancel": "取消",
+ "confirm": "删除"
+ },
+ "connection": {
+ "idle": "空闲",
+ "connecting": "连接中…",
+ "open": "已连接",
+ "reconnecting": "重连中…",
+ "closed": "已断开",
+ "error": "连接出错"
+ },
+ "thread": {
+ "loadingConversation": "正在加载对话…",
+ "empty": {
+ "description": "可以提问、继续本地工作,或者开启一个新线程。"
+ },
+ "header": {
+ "toggleSidebar": "切换侧边栏"
+ },
+ "composer": {
+ "placeholderThread": "输入消息…",
+ "placeholderHero": "你在想什么?",
+ "placeholderOpening": "正在打开新对话…",
+ "inputAria": "消息输入框",
+ "sendHint": "Enter 发送 · Shift+Enter 换行",
+ "send": "发送消息"
+ },
+ "scrollToBottom": "滚动到底部"
+ },
+ "message": {
+ "streaming": "流式输出中",
+ "assistantTyping": "助手正在输入",
+ "toolSingle": "正在使用工具",
+ "toolMany": "已使用 {{count}} 个工具"
+ },
+ "code": {
+ "fallbackLanguage": "代码",
+ "copyAria": "复制代码",
+ "copy": "复制",
+ "copied": "已复制"
+ }
+}
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
new file mode 100644
index 00000000..743ca587
--- /dev/null
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "正在連線到 nanobot…",
+ "boot": "正在載入 nanobot…"
+ },
+ "error": {
+ "title": "無法連線到 nanobot",
+ "gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot Web UI —— 與你的 nanobot 工作區對話。"
+ }
+ },
+ "sidebar": {
+ "collapse": "收合側邊欄",
+ "toggleTheme": "切換主題",
+ "newChat": "新增對話",
+ "recent": "最近對話",
+ "refreshSessions": "重新整理會話",
+ "language": {
+ "label": "語言",
+ "ariaLabel": "切換語言"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "對話 {{id}}",
+ "loading": "載入中…",
+ "noSessions": "目前還沒有會話。",
+ "actions": "「{{title}}」的會話操作",
+ "delete": "刪除",
+ "newChat": "新增對話"
+ },
+ "deleteConfirm": {
+ "title": "刪除「{{title}}」?",
+ "description": "這個會話檔案會從磁碟中移除,而且無法復原。",
+ "cancel": "取消",
+ "confirm": "刪除"
+ },
+ "connection": {
+ "idle": "閒置",
+ "connecting": "連線中…",
+ "open": "已連線",
+ "reconnecting": "重新連線中…",
+ "closed": "已中斷",
+ "error": "連線錯誤"
+ },
+ "thread": {
+ "loadingConversation": "正在載入對話…",
+ "empty": {
+ "description": "你可以提問、延續本地工作,或是開始新的執行緒。"
+ },
+ "header": {
+ "toggleSidebar": "切換側邊欄"
+ },
+ "composer": {
+ "placeholderThread": "輸入訊息…",
+ "placeholderHero": "你在想什麼?",
+ "placeholderOpening": "正在開啟新對話…",
+ "inputAria": "訊息輸入框",
+ "sendHint": "Enter 送出 · Shift+Enter 換行",
+ "send": "送出訊息"
+ },
+ "scrollToBottom": "捲動到底部"
+ },
+ "message": {
+ "streaming": "串流輸出中",
+ "assistantTyping": "助理正在輸入",
+ "toolSingle": "正在使用工具",
+ "toolMany": "已使用 {{count}} 個工具"
+ },
+ "code": {
+ "fallbackLanguage": "程式碼",
+ "copyAria": "複製程式碼",
+ "copy": "複製",
+ "copied": "已複製"
+ }
+}
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
new file mode 100644
index 00000000..5f3e5bed
--- /dev/null
+++ b/webui/src/lib/api.ts
@@ -0,0 +1,93 @@
+import type { ChatSummary } from "./types";
+
+export class ApiError extends Error {
+ status: number;
+ constructor(status: number, message: string) {
+ super(message);
+ this.status = status;
+ this.name = "ApiError";
+ }
+}
+
+async function request(
+ url: string,
+ token: string,
+ init?: RequestInit,
+): Promise {
+ const res = await fetch(url, {
+ ...(init ?? {}),
+ headers: {
+ ...(init?.headers ?? {}),
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: "same-origin",
+ });
+ if (!res.ok) {
+ throw new ApiError(res.status, `HTTP ${res.status}`);
+ }
+ return (await res.json()) as T;
+}
+
+function splitKey(key: string): { channel: string; chatId: string } {
+ const idx = key.indexOf(":");
+ if (idx === -1) return { channel: "", chatId: key };
+ return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
+}
+
+export async function listSessions(
+ token: string,
+ base: string = "",
+): Promise {
+ type Row = {
+ key: string;
+ created_at: string | null;
+ updated_at: string | null;
+ preview?: string;
+ };
+ const body = await request<{ sessions: Row[] }>(
+ `${base}/api/sessions`,
+ token,
+ );
+ return body.sessions.map((s) => ({
+ key: s.key,
+ ...splitKey(s.key),
+ createdAt: s.created_at,
+ updatedAt: s.updated_at,
+ preview: s.preview ?? "",
+ }));
+}
+
+export async function fetchSessionMessages(
+ token: string,
+ key: string,
+ base: string = "",
+): Promise<{
+ key: string;
+ created_at: string | null;
+ updated_at: string | null;
+ messages: Array<{
+ role: string;
+ content: string;
+ timestamp?: string;
+ tool_calls?: unknown;
+ tool_call_id?: string;
+ name?: string;
+ }>;
+}> {
+ return request(
+ `${base}/api/sessions/${encodeURIComponent(key)}/messages`,
+ token,
+ );
+}
+
+export async function deleteSession(
+ token: string,
+ key: string,
+ base: string = "",
+): Promise {
+ const body = await request<{ deleted: boolean }>(
+ `${base}/api/sessions/${encodeURIComponent(key)}/delete`,
+ token,
+ );
+ return body.deleted;
+}
diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts
new file mode 100644
index 00000000..66d2b595
--- /dev/null
+++ b/webui/src/lib/bootstrap.ts
@@ -0,0 +1,40 @@
+import type { BootstrapResponse } from "./types";
+
+/**
+ * Fetch a short-lived token + the WebSocket path from the gateway's
+ * ``/webui/bootstrap`` endpoint. Localhost-only on the server side.
+ */
+export async function fetchBootstrap(
+ baseUrl: string = "",
+): Promise {
+ const res = await fetch(`${baseUrl}/webui/bootstrap`, {
+ method: "GET",
+ credentials: "same-origin",
+ });
+ if (!res.ok) {
+ throw new Error(`bootstrap failed: HTTP ${res.status}`);
+ }
+ const body = (await res.json()) as BootstrapResponse;
+ if (!body.token || !body.ws_path) {
+ throw new Error("bootstrap response missing token or ws_path");
+ }
+ return body;
+}
+
+/** Derive a WebSocket URL from the current window location and the server-provided path.
+ *
+ * Keeps the path segment exactly as the server registered it: the root ``/``
+ * stays ``/`` and non-root paths are not given an extra trailing slash. This
+ * matters because some WS servers dispatch handshakes based on the literal
+ * path, not a normalised form.
+ */
+export function deriveWsUrl(wsPath: string, token: string): string {
+ const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
+ const query = `?token=${encodeURIComponent(token)}`;
+ if (typeof window === "undefined") {
+ return `ws://127.0.0.1:8765${path}${query}`;
+ }
+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
+ const host = window.location.host;
+ return `${scheme}://${host}${path}${query}`;
+}
diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts
new file mode 100644
index 00000000..fd5c43a9
--- /dev/null
+++ b/webui/src/lib/format.ts
@@ -0,0 +1,77 @@
+import i18n, { currentLocale } from "@/i18n";
+
+/** Truncate the first user message into a chat title. */
+export function deriveTitle(preview: string | undefined, fallback: string): string {
+ if (!preview) return fallback;
+ const oneLine = preview.replace(/\s+/g, " ").trim();
+ if (!oneLine) return fallback;
+ return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
+}
+
+/** Loose ISO-or-epoch parser; returns ``null`` for missing/invalid input. */
+function parseDate(value: string | number | null | undefined): Date | null {
+ if (value === null || value === undefined || value === "") return null;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
+ [60, "second"],
+ [60, "minute"],
+ [24, "hour"],
+ [7, "day"],
+ [4.345, "week"],
+ [12, "month"],
+ [Number.POSITIVE_INFINITY, "year"],
+];
+
+const relativeTimeFormatters = new Map();
+const dateTimeFormatters = new Map();
+
+function activeLocale(locale?: string): string {
+ return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
+}
+
+function relativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
+ const existing = relativeTimeFormatters.get(locale);
+ if (existing) return existing;
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
+ relativeTimeFormatters.set(locale, formatter);
+ return formatter;
+}
+
+function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
+ const existing = dateTimeFormatters.get(locale);
+ if (existing) return existing;
+ const formatter = new Intl.DateTimeFormat(locale, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+ dateTimeFormatters.set(locale, formatter);
+ return formatter;
+}
+
+export function relativeTime(
+ value: string | number | null | undefined,
+ locale?: string,
+): string {
+ const date = parseDate(value);
+ if (!date) return "";
+ let delta = (date.getTime() - Date.now()) / 1000;
+ const formatter = relativeTimeFormatter(activeLocale(locale));
+ for (const [step, unit] of RELATIVE_THRESHOLDS) {
+ if (Math.abs(delta) < step) {
+ return formatter.format(Math.round(delta), unit);
+ }
+ delta /= step;
+ }
+ return formatter.format(Math.round(delta), "year");
+}
+
+export function fmtDateTime(
+ value: string | number | null | undefined,
+ locale?: string,
+): string {
+ const date = parseDate(value);
+ return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
+}
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
new file mode 100644
index 00000000..d504ef21
--- /dev/null
+++ b/webui/src/lib/nanobot-client.ts
@@ -0,0 +1,264 @@
+import type { ConnectionStatus, InboundEvent, Outbound } from "./types";
+
+/** WebSocket readyState constants, referenced by value to stay portable
+ * across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
+const WS_OPEN = 1;
+const WS_CLOSING = 2;
+
+type Unsubscribe = () => void;
+type EventHandler = (ev: InboundEvent) => void;
+type StatusHandler = (status: ConnectionStatus) => void;
+
+interface PendingNewChat {
+ resolve: (chatId: string) => void;
+ reject: (err: Error) => void;
+ timer: ReturnType;
+}
+
+export interface NanobotClientOptions {
+ url: string;
+ reconnect?: boolean;
+ /** Called when a connection drops so the app can refresh its token. */
+ onReauth?: () => Promise;
+ /** Inject a custom WebSocket factory (used by unit tests). */
+ socketFactory?: (url: string) => WebSocket;
+ /** Delay-cap for reconnect backoff (ms). */
+ maxBackoffMs?: number;
+}
+
+/**
+ * Singleton WebSocket client that multiplexes chat streams.
+ *
+ * One socket carries many chat_ids: the server tags every outbound event with
+ * ``chat_id``, and this class fans those events out to handlers registered
+ * per chat. Reconnects are transparent and re-attach every known chat_id.
+ */
+export class NanobotClient {
+ private socket: WebSocket | null = null;
+ private statusHandlers = new Set();
+ // chat_id -> handlers listening on it
+ private chatHandlers = new Map>();
+ // chat_ids we've attached to since connect; re-attached after reconnects
+ private knownChats = new Set();
+ private pendingNewChat: PendingNewChat | null = null;
+ // Frames queued while the socket is not yet OPEN
+ private sendQueue: Outbound[] = [];
+ private reconnectAttempts = 0;
+ private reconnectTimer: ReturnType | null = null;
+ private readonly shouldReconnect: boolean;
+ private readonly maxBackoffMs: number;
+ private readonly socketFactory: (url: string) => WebSocket;
+ private currentUrl: string;
+ private status_: ConnectionStatus = "idle";
+ private readyChatId: string | null = null;
+ // Set by ``close()`` so the onclose handler knows the drop was intentional
+ // and must not schedule a reconnect or flip status back to "reconnecting".
+ private intentionallyClosed = false;
+
+ constructor(private options: NanobotClientOptions) {
+ this.shouldReconnect = options.reconnect ?? true;
+ this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
+ this.socketFactory =
+ options.socketFactory ?? ((url) => new WebSocket(url));
+ this.currentUrl = options.url;
+ }
+
+ get status(): ConnectionStatus {
+ return this.status_;
+ }
+
+ get defaultChatId(): string | null {
+ return this.readyChatId;
+ }
+
+ /** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
+ updateUrl(url: string): void {
+ this.currentUrl = url;
+ }
+
+ onStatus(handler: StatusHandler): Unsubscribe {
+ this.statusHandlers.add(handler);
+ handler(this.status_);
+ return () => {
+ this.statusHandlers.delete(handler);
+ };
+ }
+
+ /** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
+ onChat(chatId: string, handler: EventHandler): Unsubscribe {
+ let handlers = this.chatHandlers.get(chatId);
+ if (!handlers) {
+ handlers = new Set();
+ this.chatHandlers.set(chatId, handlers);
+ }
+ handlers.add(handler);
+ this.attach(chatId);
+ return () => {
+ const current = this.chatHandlers.get(chatId);
+ if (!current) return;
+ current.delete(handler);
+ if (current.size === 0) this.chatHandlers.delete(chatId);
+ };
+ }
+
+ connect(): void {
+ if (this.socket && this.socket.readyState < WS_CLOSING) return;
+ this.intentionallyClosed = false;
+ this.setStatus("connecting");
+ const sock = this.socketFactory(this.currentUrl);
+ this.socket = sock;
+ sock.onopen = () => this.handleOpen();
+ sock.onmessage = (ev) => this.handleMessage(ev);
+ sock.onerror = () => this.setStatus("error");
+ sock.onclose = () => this.handleClose();
+ }
+
+ close(): void {
+ this.intentionallyClosed = true;
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ const sock = this.socket;
+ this.socket = null;
+ try {
+ sock?.close();
+ } catch {
+ // ignore
+ }
+ this.setStatus("closed");
+ }
+
+ /** Ask the server to provision a new chat_id; resolves with the assigned id. */
+ newChat(timeoutMs: number = 5_000): Promise {
+ if (this.pendingNewChat) {
+ return Promise.reject(new Error("newChat already in flight"));
+ }
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pendingNewChat = null;
+ reject(new Error("newChat timed out"));
+ }, timeoutMs);
+ this.pendingNewChat = { resolve, reject, timer };
+ this.queueSend({ type: "new_chat" });
+ });
+ }
+
+ attach(chatId: string): void {
+ this.knownChats.add(chatId);
+ if (this.socket?.readyState === WS_OPEN) {
+ this.queueSend({ type: "attach", chat_id: chatId });
+ }
+ }
+
+ sendMessage(chatId: string, content: string): void {
+ this.knownChats.add(chatId);
+ this.queueSend({ type: "message", chat_id: chatId, content });
+ }
+
+ // -- internals ---------------------------------------------------------
+
+ private setStatus(status: ConnectionStatus): void {
+ if (this.status_ === status) return;
+ this.status_ = status;
+ for (const handler of this.statusHandlers) handler(status);
+ }
+
+ private handleOpen(): void {
+ this.setStatus("open");
+ this.reconnectAttempts = 0;
+ // Re-attach every known chat_id so deliveries continue routing after a drop.
+ for (const chatId of this.knownChats) {
+ this.rawSend({ type: "attach", chat_id: chatId });
+ }
+ // Flush anything queued during reconnect.
+ const queued = this.sendQueue.splice(0);
+ for (const frame of queued) this.rawSend(frame);
+ }
+
+ private handleMessage(ev: MessageEvent): void {
+ let parsed: InboundEvent;
+ try {
+ parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
+ } catch {
+ return;
+ }
+
+ if (parsed.event === "ready") {
+ this.readyChatId = parsed.chat_id;
+ this.knownChats.add(parsed.chat_id);
+ return;
+ }
+
+ if (parsed.event === "attached") {
+ this.knownChats.add(parsed.chat_id);
+ if (this.pendingNewChat) {
+ clearTimeout(this.pendingNewChat.timer);
+ this.pendingNewChat.resolve(parsed.chat_id);
+ this.pendingNewChat = null;
+ }
+ this.dispatch(parsed.chat_id, parsed);
+ return;
+ }
+
+ const chatId = (parsed as { chat_id?: string }).chat_id;
+ if (chatId) this.dispatch(chatId, parsed);
+ }
+
+ private dispatch(chatId: string, ev: InboundEvent): void {
+ const handlers = this.chatHandlers.get(chatId);
+ if (!handlers) return;
+ for (const h of handlers) h(ev);
+ }
+
+ private handleClose(): void {
+ this.socket = null;
+ if (this.pendingNewChat) {
+ clearTimeout(this.pendingNewChat.timer);
+ this.pendingNewChat.reject(new Error("socket closed"));
+ this.pendingNewChat = null;
+ }
+ if (this.intentionallyClosed || !this.shouldReconnect) {
+ this.setStatus("closed");
+ return;
+ }
+ this.scheduleReconnect();
+ }
+
+ private scheduleReconnect(): void {
+ this.setStatus("reconnecting");
+ const attempt = this.reconnectAttempts++;
+ // Exponential backoff: 0.5s, 1s, 2s, 4s, capped.
+ const delay = Math.min(500 * 2 ** attempt, this.maxBackoffMs);
+ this.reconnectTimer = setTimeout(async () => {
+ this.reconnectTimer = null;
+ if (this.options.onReauth) {
+ try {
+ const refreshed = await this.options.onReauth();
+ if (refreshed) this.currentUrl = refreshed;
+ } catch {
+ // fall through to retry with current URL
+ }
+ }
+ this.connect();
+ }, delay);
+ }
+
+ private queueSend(frame: Outbound): void {
+ if (this.socket?.readyState === WS_OPEN) {
+ this.rawSend(frame);
+ } else {
+ this.sendQueue.push(frame);
+ }
+ }
+
+ private rawSend(frame: Outbound): void {
+ if (!this.socket) return;
+ try {
+ this.socket.send(JSON.stringify(frame));
+ } catch {
+ // Send failure will materialize as a close; queue the frame for retry.
+ this.sendQueue.push(frame);
+ }
+ }
+}
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
new file mode 100644
index 00000000..fcd80da2
--- /dev/null
+++ b/webui/src/lib/types.ts
@@ -0,0 +1,74 @@
+export type Role = "user" | "assistant" | "tool" | "system";
+
+/** "trace" rows are intermediate agent breadcrumbs (tool-call hints,
+ * progress pings) that should not be rendered as conversational replies. */
+export type MessageKind = "message" | "trace";
+
+export interface UIMessage {
+ id: string;
+ role: Role;
+ content: string;
+ kind?: MessageKind;
+ isStreaming?: boolean;
+ createdAt: number;
+ /** For trace rows: each individual hint line, so consecutive hints can
+ * render as a single collapsible group. */
+ traces?: string[];
+}
+
+export interface ChatSummary {
+ /** Server-side session key, e.g. ``websocket:abcd-...``. */
+ key: string;
+ /** Local channel + chat_id parts derived from ``key`` for convenience. */
+ channel: string;
+ chatId: string;
+ createdAt: string | null;
+ updatedAt: string | null;
+ preview: string;
+}
+
+export interface BootstrapResponse {
+ token: string;
+ ws_path: string;
+ expires_in: number;
+ model_name?: string | null;
+}
+
+export type ConnectionStatus =
+ | "idle"
+ | "connecting"
+ | "open"
+ | "reconnecting"
+ | "closed"
+ | "error";
+
+export type InboundEvent =
+ | { event: "ready"; chat_id: string; client_id: string }
+ | { event: "attached"; chat_id: string }
+ | {
+ event: "message";
+ chat_id: string;
+ text: string;
+ reply_to?: string;
+ media?: string[];
+ /** Present when the frame is an agent breadcrumb (e.g. tool hint,
+ * generic progress line) rather than a conversational reply. */
+ kind?: "tool_hint" | "progress";
+ }
+ | {
+ event: "delta";
+ chat_id: string;
+ text: string;
+ stream_id?: string;
+ }
+ | {
+ event: "stream_end";
+ chat_id: string;
+ stream_id?: string;
+ }
+ | { event: "error"; chat_id?: string; detail?: string };
+
+export type Outbound =
+ | { type: "new_chat" }
+ | { type: "attach"; chat_id: string }
+ | { type: "message"; chat_id: string; content: string };
diff --git a/webui/src/lib/utils.ts b/webui/src/lib/utils.ts
new file mode 100644
index 00000000..b500fb16
--- /dev/null
+++ b/webui/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
diff --git a/webui/src/main.tsx b/webui/src/main.tsx
new file mode 100644
index 00000000..ed79c766
--- /dev/null
+++ b/webui/src/main.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+
+import App from "./App";
+import "./globals.css";
+import "./i18n";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("root element missing");
+
+ReactDOM.createRoot(root).render(
+
+
+ ,
+);
diff --git a/webui/src/providers/ClientProvider.tsx b/webui/src/providers/ClientProvider.tsx
new file mode 100644
index 00000000..e97ab4cf
--- /dev/null
+++ b/webui/src/providers/ClientProvider.tsx
@@ -0,0 +1,37 @@
+import { createContext, useContext, type ReactNode } from "react";
+
+import type { NanobotClient } from "@/lib/nanobot-client";
+
+interface ClientContextValue {
+ client: NanobotClient;
+ token: string;
+ modelName: string | null;
+}
+
+const ClientContext = createContext(null);
+
+export function ClientProvider({
+ client,
+ token,
+ modelName = null,
+ children,
+}: {
+ client: NanobotClient;
+ token: string;
+ modelName?: string | null;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useClient(): ClientContextValue {
+ const ctx = useContext(ClientContext);
+ if (!ctx) {
+ throw new Error("useClient must be used within a ClientProvider");
+ }
+ return ctx;
+}
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
new file mode 100644
index 00000000..fdefac77
--- /dev/null
+++ b/webui/src/tests/api.test.ts
@@ -0,0 +1,37 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { deleteSession, fetchSessionMessages } from "@/lib/api";
+
+describe("webui API helpers", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ deleted: true, key: "websocket:chat-1", messages: [] }),
+ }),
+ );
+ });
+
+ it("percent-encodes websocket keys when fetching session history", async () => {
+ await fetchSessionMessages("tok", "websocket:chat-1");
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/sessions/websocket%3Achat-1/messages",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
+
+ it("percent-encodes websocket keys when deleting a session", async () => {
+ await deleteSession("tok", "websocket:chat-1");
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/sessions/websocket%3Achat-1/delete",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
+});
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
new file mode 100644
index 00000000..fd9756d1
--- /dev/null
+++ b/webui/src/tests/app-layout.test.tsx
@@ -0,0 +1,148 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ChatSummary } from "@/lib/types";
+
+const connectSpy = vi.fn();
+const refreshSpy = vi.fn();
+const createChatSpy = vi.fn().mockResolvedValue("chat-1");
+const deleteChatSpy = vi.fn();
+let mockSessions: ChatSummary[] = [];
+
+vi.mock("@/hooks/useSessions", async (importOriginal) => {
+ const React = await import("react");
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useSessions: () => {
+ const [sessions, setSessions] = React.useState(mockSessions);
+ return {
+ sessions,
+ loading: false,
+ error: null,
+ refresh: refreshSpy,
+ createChat: createChatSpy,
+ deleteChat: async (key: string) => {
+ await deleteChatSpy(key);
+ setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
+ },
+ };
+ },
+ };
+});
+
+vi.mock("@/hooks/useTheme", () => ({
+ useTheme: () => ({
+ theme: "light" as const,
+ toggle: vi.fn(),
+ }),
+}));
+
+vi.mock("@/lib/bootstrap", () => ({
+ fetchBootstrap: vi.fn().mockResolvedValue({
+ token: "tok",
+ ws_path: "/",
+ expires_in: 300,
+ }),
+ deriveWsUrl: vi.fn(() => "ws://test"),
+}));
+
+vi.mock("@/lib/nanobot-client", () => {
+ class MockClient {
+ status = "idle" as const;
+ defaultChatId: string | null = null;
+ connect = connectSpy;
+ onStatus = () => () => {};
+ onChat = () => () => {};
+ sendMessage = vi.fn();
+ newChat = vi.fn();
+ attach = vi.fn();
+ close = vi.fn();
+ updateUrl = vi.fn();
+ }
+
+ return { NanobotClient: MockClient };
+});
+
+import App from "@/App";
+
+describe("App layout", () => {
+ beforeEach(() => {
+ mockSessions = [];
+ connectSpy.mockClear();
+ refreshSpy.mockReset();
+ createChatSpy.mockClear();
+ deleteChatSpy.mockReset();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ }),
+ );
+ });
+
+ it("keeps sidebar layout out of the main thread width contract", async () => {
+ const { container } = render( );
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+
+ const main = container.querySelector("main");
+ expect(main).toBeInTheDocument();
+ expect(main).not.toHaveAttribute("style");
+
+ const asideClassNames = Array.from(container.querySelectorAll("aside")).map(
+ (el) => el.className,
+ );
+ expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
+ });
+
+ it("switches to the next session when deleting the active chat", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "First chat",
+ },
+ {
+ key: "websocket:chat-b",
+ channel: "websocket",
+ chatId: "chat-b",
+ createdAt: "2026-04-16T11:00:00Z",
+ updatedAt: "2026-04-16T11:00:00Z",
+ preview: "Second chat",
+ },
+ ];
+
+ render( );
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
+ );
+
+ fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
+ button: 0,
+ });
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
+
+ await waitFor(() =>
+ expect(screen.getByText('Delete “First chat”?')).toBeInTheDocument(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() =>
+ expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a"),
+ );
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: /^Second chat$/ }),
+ ).toBeInTheDocument(),
+ );
+ expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
+ expect(document.body.style.pointerEvents).not.toBe("none");
+ }, 15_000);
+});
diff --git a/webui/src/tests/format.i18n.test.ts b/webui/src/tests/format.i18n.test.ts
new file mode 100644
index 00000000..517b1953
--- /dev/null
+++ b/webui/src/tests/format.i18n.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { setAppLanguage } from "@/i18n";
+import { fmtDateTime, relativeTime } from "@/lib/format";
+
+describe("localized format helpers", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-04-18T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("formats relative time using the active locale", async () => {
+ const value = "2026-04-18T11:59:00Z";
+
+ await setAppLanguage("en");
+ const english = relativeTime(value);
+
+ await setAppLanguage("zh-CN");
+ const chinese = relativeTime(value);
+
+ expect(english).toBe(
+ new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
+ -1,
+ "minute",
+ ),
+ );
+ expect(chinese).toBe(
+ new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }).format(
+ -1,
+ "minute",
+ ),
+ );
+ expect(english).not.toBe(chinese);
+ });
+
+ it("formats date-time using the active locale", async () => {
+ const value = "2026-04-18T08:30:00Z";
+ const date = new Date(value);
+
+ await setAppLanguage("en");
+ const english = fmtDateTime(value);
+
+ await setAppLanguage("fr");
+ const french = fmtDateTime(value);
+
+ expect(english).toBe(
+ new Intl.DateTimeFormat("en", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date),
+ );
+ expect(french).toBe(
+ new Intl.DateTimeFormat("fr", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date),
+ );
+ expect(english).not.toBe(french);
+ });
+});
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
new file mode 100644
index 00000000..66b02957
--- /dev/null
+++ b/webui/src/tests/i18n.test.tsx
@@ -0,0 +1,44 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { ThreadComposer } from "@/components/thread/ThreadComposer";
+
+describe("webui i18n", () => {
+ it("switches UI copy and document locale through the language switcher", async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ expect(
+ screen.getByPlaceholderText("Type your message…"),
+ ).toBeInTheDocument();
+ expect(document.documentElement.lang).toBe("en");
+
+ await user.click(screen.getByRole("button", { name: "Change language" }));
+ await user.click(screen.getByRole("menuitemradio", { name: /简体中文/i }));
+
+ await waitFor(() => {
+ expect(document.documentElement.lang).toBe("zh-CN");
+ });
+ expect(localStorage.getItem("nanobot.locale")).toBe("zh-CN");
+ expect(screen.getByPlaceholderText("输入消息…")).toBeInTheDocument();
+ });
+
+ it("updates the composer aria label when the language changes", async () => {
+ render( );
+
+ await act(async () => {
+ const { setAppLanguage } = await import("@/i18n");
+ await setAppLanguage("ja");
+ });
+
+ expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
+ });
+});
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
new file mode 100644
index 00000000..80c24018
--- /dev/null
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -0,0 +1,43 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { MessageBubble } from "@/components/MessageBubble";
+import type { UIMessage } from "@/lib/types";
+
+describe("MessageBubble", () => {
+ it("renders user messages as right-aligned pills", () => {
+ const message: UIMessage = {
+ id: "u1",
+ role: "user",
+ content: "hello",
+ createdAt: Date.now(),
+ };
+
+ const { container } = render( );
+ const row = container.firstElementChild;
+ const pill = screen.getByText("hello");
+
+ expect(row).toHaveClass("ml-auto", "flex");
+ expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
+ });
+
+ it("renders trace messages as collapsible tool groups", () => {
+ const message: UIMessage = {
+ id: "t1",
+ role: "tool",
+ kind: "trace",
+ content: 'search "hk weather"',
+ traces: ['weather("get")', 'search "hk weather"'],
+ createdAt: Date.now(),
+ };
+
+ render( );
+ const toggle = screen.getByRole("button", { name: /used 2 tools/i });
+
+ expect(screen.getByText('weather("get")')).toBeInTheDocument();
+ expect(screen.getByText('search "hk weather"')).toBeInTheDocument();
+
+ fireEvent.click(toggle);
+ expect(screen.queryByText('weather("get")')).not.toBeInTheDocument();
+ });
+});
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
new file mode 100644
index 00000000..3ce14271
--- /dev/null
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NanobotClient } from "@/lib/nanobot-client";
+
+/**
+ * Minimal fake WebSocket implementing the subset NanobotClient touches.
+ * Every instance is retrievable via ``FakeSocket.instances`` so tests can
+ * drive open/close/message lifecycles deterministically.
+ */
+class FakeSocket {
+ static instances: FakeSocket[] = [];
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSING = 2;
+ static readonly CLOSED = 3;
+
+ url: string;
+ readyState = FakeSocket.CONNECTING;
+ sent: string[] = [];
+ onopen: (() => void) | null = null;
+ onmessage: ((ev: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+
+ constructor(url: string) {
+ this.url = url;
+ FakeSocket.instances.push(this);
+ }
+
+ send(data: string) {
+ this.sent.push(data);
+ }
+
+ close() {
+ this.readyState = FakeSocket.CLOSED;
+ this.onclose?.();
+ }
+
+ fakeOpen() {
+ this.readyState = FakeSocket.OPEN;
+ this.onopen?.();
+ }
+
+ fakeMessage(payload: unknown) {
+ this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
+ }
+}
+
+function lastSocket(): FakeSocket {
+ const s = FakeSocket.instances.at(-1);
+ if (!s) throw new Error("no socket created yet");
+ return s;
+}
+
+beforeEach(() => {
+ FakeSocket.instances = [];
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("NanobotClient", () => {
+ it("routes events to the matching chat handler", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const handler = vi.fn();
+ client.onChat("chat-a", handler);
+ client.connect();
+ lastSocket().fakeOpen();
+ lastSocket().fakeMessage({ event: "message", chat_id: "chat-a", text: "hi" });
+ lastSocket().fakeMessage({ event: "message", chat_id: "chat-b", text: "no" });
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler.mock.calls[0][0]).toMatchObject({
+ event: "message",
+ chat_id: "chat-a",
+ text: "hi",
+ });
+ });
+
+ it("resolves newChat() via the server-assigned chat_id", async () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ client.connect();
+ lastSocket().fakeOpen();
+ const promise = client.newChat(1_000);
+ expect(lastSocket().sent).toContain(JSON.stringify({ type: "new_chat" }));
+ lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
+ await expect(promise).resolves.toBe("fresh-id");
+ });
+
+ it("queues sends while connecting and flushes on open", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ client.connect();
+ client.sendMessage("chat-x", "hello");
+ expect(lastSocket().sent).toEqual([]);
+ lastSocket().fakeOpen();
+ // Attach is sent first because sendMessage adds to knownChats, which
+ // handleOpen re-attaches; then the queued message follows.
+ expect(lastSocket().sent).toContain(
+ JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }),
+ );
+ });
+
+ it("re-attaches known chats after a reconnect", async () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: true,
+ maxBackoffMs: 10,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ client.onChat("chat-z", () => {});
+ client.connect();
+ lastSocket().fakeOpen();
+ expect(lastSocket().sent).toContain(
+ JSON.stringify({ type: "attach", chat_id: "chat-z" }),
+ );
+ // Drop the socket.
+ lastSocket().close();
+ // Advance the backoff timer.
+ await vi.advanceTimersByTimeAsync(20);
+ const reconnected = lastSocket();
+ expect(reconnected).not.toBe(FakeSocket.instances[0]);
+ reconnected.fakeOpen();
+ expect(reconnected.sent).toContain(
+ JSON.stringify({ type: "attach", chat_id: "chat-z" }),
+ );
+ });
+
+ it("reports status transitions through onStatus", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const seen: string[] = [];
+ client.onStatus((s) => seen.push(s));
+ client.connect();
+ lastSocket().fakeOpen();
+ lastSocket().close();
+ expect(seen).toEqual(["idle", "connecting", "open", "closed"]);
+ });
+
+ it("does not schedule a reconnect when close() is called explicitly", async () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: true,
+ maxBackoffMs: 10,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const seen: string[] = [];
+ client.onStatus((s) => seen.push(s));
+ client.connect();
+ lastSocket().fakeOpen();
+ client.close();
+ // Advance past any possible backoff window to prove no reconnect was scheduled.
+ await vi.advanceTimersByTimeAsync(200);
+ expect(FakeSocket.instances).toHaveLength(1);
+ // "reconnecting" must never appear after an intentional close.
+ expect(seen).not.toContain("reconnecting");
+ expect(seen.at(-1)).toBe("closed");
+ });
+
+ it("surfaces 'reconnecting' only on an unexpected drop", async () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: true,
+ maxBackoffMs: 5,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const seen: string[] = [];
+ client.onStatus((s) => seen.push(s));
+ client.connect();
+ lastSocket().fakeOpen();
+ // Simulate the remote side hanging up (no client.close() call).
+ lastSocket().close();
+ await vi.advanceTimersByTimeAsync(50);
+ expect(seen).toContain("reconnecting");
+ expect(FakeSocket.instances.length).toBeGreaterThan(1);
+ });
+});
diff --git a/webui/src/tests/setup.ts b/webui/src/tests/setup.ts
new file mode 100644
index 00000000..bc8ec9d3
--- /dev/null
+++ b/webui/src/tests/setup.ts
@@ -0,0 +1,24 @@
+import "@testing-library/jest-dom/vitest";
+import { beforeEach } from "vitest";
+
+import i18n from "@/i18n";
+
+// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
+if (!("randomUUID" in globalThis.crypto)) {
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
+ value: () =>
+ "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ }),
+ configurable: true,
+ });
+}
+
+beforeEach(async () => {
+ await i18n.changeLanguage("en");
+ document.documentElement.lang = "en";
+ document.title = "nanobot";
+ localStorage.setItem("nanobot.locale", "en");
+});
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
new file mode 100644
index 00000000..17205fb6
--- /dev/null
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ThreadComposer } from "@/components/thread/ThreadComposer";
+
+describe("ThreadComposer", () => {
+ it("renders a readonly hero model composer when provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
+ const input = screen.getByPlaceholderText("What's on your mind?");
+ expect(input).toBeInTheDocument();
+ expect(input.className).toContain("min-h-[96px]");
+ expect(input.parentElement?.className).toContain("max-w-[40rem]");
+ });
+});
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
new file mode 100644
index 00000000..e42cc7bf
--- /dev/null
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -0,0 +1,324 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ThreadShell } from "@/components/thread/ThreadShell";
+import { ClientProvider } from "@/providers/ClientProvider";
+
+function makeClient() {
+ return {
+ status: "open" as const,
+ defaultChatId: null as string | null,
+ onStatus: () => () => {},
+ onChat: () => () => {},
+ sendMessage: vi.fn(),
+ newChat: vi.fn(),
+ attach: vi.fn(),
+ connect: vi.fn(),
+ close: vi.fn(),
+ updateUrl: vi.fn(),
+ };
+}
+
+function wrap(client: ReturnType, children: ReactNode) {
+ return (
+
+ {children}
+
+ );
+}
+
+function session(chatId: string) {
+ return {
+ key: `websocket:${chatId}`,
+ channel: "websocket" as const,
+ chatId,
+ createdAt: null,
+ updatedAt: null,
+ preview: "",
+ };
+}
+
+function httpJson(body: unknown) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => body,
+ };
+}
+
+describe("ThreadShell", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ }),
+ );
+ });
+
+ it("restores in-memory messages when switching away and back to a session", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-a");
+
+ const { rerender } = render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "persist me across tabs" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ await waitFor(() =>
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ "chat-a",
+ "persist me across tabs",
+ ),
+ );
+ expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
+ });
+
+ it("clears the old thread when the active session is removed", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-a");
+
+ const { rerender } = render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "delete me cleanly" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ await waitFor(() =>
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ "chat-a",
+ "delete me cleanly",
+ ),
+ );
+ expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
+ });
+ expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
+ });
+
+ it("does not leak the previous thread when opening a brand-new chat", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-new");
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("websocket%3Achat-a/messages")) {
+ return httpJson({
+ key: "websocket:chat-a",
+ created_at: null,
+ updated_at: null,
+ messages: [
+ { role: "user", content: "old question" },
+ { role: "assistant", content: "old answer" },
+ ],
+ });
+ }
+ return {
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ };
+ }),
+ );
+
+ const { rerender } = render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ expect(screen.queryByText("old answer")).not.toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
+ );
+ const input = screen.getByPlaceholderText("What's on your mind?");
+ expect(input.className).toContain("min-h-[96px]");
+ expect(screen.queryByText("old answer")).not.toBeInTheDocument();
+ });
+
+ it("clears the previous thread immediately while the next session loads", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-b");
+ let resolveChatB:
+ | ((value: { ok: boolean; status: number; json: () => Promise }) => void)
+ | null = null;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("websocket%3Achat-a/messages")) {
+ return Promise.resolve(
+ httpJson({
+ key: "websocket:chat-a",
+ created_at: null,
+ updated_at: null,
+ messages: [{ role: "assistant", content: "from chat a" }],
+ }),
+ );
+ }
+ if (url.includes("websocket%3Achat-b/messages")) {
+ return new Promise((resolve) => {
+ resolveChatB = resolve;
+ });
+ }
+ return Promise.resolve({
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ });
+ }),
+ );
+
+ const { rerender } = render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ await waitFor(() => expect(screen.getByText("from chat a")).toBeInTheDocument());
+
+ await act(async () => {
+ rerender(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+ });
+
+ expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
+ expect(screen.getByText("Loading conversation…")).toBeInTheDocument();
+
+ await act(async () => {
+ resolveChatB?.(
+ httpJson({
+ key: "websocket:chat-b",
+ created_at: null,
+ updated_at: null,
+ messages: [{ role: "assistant", content: "from chat b" }],
+ }),
+ );
+ });
+
+ await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument());
+ expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
+ });
+});
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
new file mode 100644
index 00000000..2b51a16f
--- /dev/null
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -0,0 +1,94 @@
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { useNanobotStream } from "@/hooks/useNanobotStream";
+import type { InboundEvent } from "@/lib/types";
+import { ClientProvider } from "@/providers/ClientProvider";
+
+function fakeClient() {
+ const handlers = new Map void>>();
+ return {
+ client: {
+ status: "open" as const,
+ defaultChatId: null as string | null,
+ onStatus: () => () => {},
+ onChat(chatId: string, h: (ev: InboundEvent) => void) {
+ let set = handlers.get(chatId);
+ if (!set) {
+ set = new Set();
+ handlers.set(chatId, set);
+ }
+ set.add(h);
+ return () => set!.delete(h);
+ },
+ sendMessage: vi.fn(),
+ newChat: vi.fn(),
+ attach: vi.fn(),
+ connect: vi.fn(),
+ close: vi.fn(),
+ updateUrl: vi.fn(),
+ },
+ emit(chatId: string, ev: InboundEvent) {
+ const set = handlers.get(chatId);
+ set?.forEach((h) => h(ev));
+ },
+ };
+}
+
+function wrap(client: ReturnType["client"]) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+}
+
+describe("useNanobotStream", () => {
+ it("collapses consecutive tool_hint frames into one trace row", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-t", []), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-t", {
+ event: "message",
+ chat_id: "chat-t",
+ text: 'weather("get")',
+ kind: "tool_hint",
+ });
+ fake.emit("chat-t", {
+ event: "message",
+ chat_id: "chat-t",
+ text: 'search "hk weather"',
+ kind: "tool_hint",
+ });
+ });
+
+ expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages[0].kind).toBe("trace");
+ expect(result.current.messages[0].role).toBe("tool");
+ expect(result.current.messages[0].traces).toEqual([
+ 'weather("get")',
+ 'search "hk weather"',
+ ]);
+
+ act(() => {
+ fake.emit("chat-t", {
+ event: "message",
+ chat_id: "chat-t",
+ text: "## Summary",
+ });
+ });
+
+ expect(result.current.messages).toHaveLength(2);
+ expect(result.current.messages[1].role).toBe("assistant");
+ expect(result.current.messages[1].kind).toBeUndefined();
+ });
+});
diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx
new file mode 100644
index 00000000..7ac8ce9e
--- /dev/null
+++ b/webui/src/tests/useSessions.test.tsx
@@ -0,0 +1,116 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useSessions } from "@/hooks/useSessions";
+import * as api from "@/lib/api";
+import { ClientProvider } from "@/providers/ClientProvider";
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ listSessions: vi.fn(),
+ deleteSession: vi.fn(),
+ fetchSessionMessages: vi.fn(),
+ };
+});
+
+function fakeClient() {
+ return {
+ status: "open" as const,
+ defaultChatId: null as string | null,
+ onStatus: () => () => {},
+ onChat: () => () => {},
+ sendMessage: vi.fn(),
+ newChat: vi.fn(),
+ attach: vi.fn(),
+ connect: vi.fn(),
+ close: vi.fn(),
+ updateUrl: vi.fn(),
+ };
+}
+
+function wrap(client: ReturnType) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+}
+
+describe("useSessions", () => {
+ beforeEach(() => {
+ vi.mocked(api.listSessions).mockReset();
+ vi.mocked(api.deleteSession).mockReset();
+ vi.mocked(api.fetchSessionMessages).mockReset();
+ });
+
+ it("removes a session from the local list after delete succeeds", async () => {
+ vi.mocked(api.listSessions).mockResolvedValue([
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Alpha",
+ },
+ {
+ key: "websocket:chat-b",
+ channel: "websocket",
+ chatId: "chat-b",
+ createdAt: "2026-04-16T11:00:00Z",
+ updatedAt: "2026-04-16T11:00:00Z",
+ preview: "Beta",
+ },
+ ]);
+ vi.mocked(api.deleteSession).mockResolvedValue(true);
+
+ const { result } = renderHook(() => useSessions(), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.sessions).toHaveLength(2));
+
+ await act(async () => {
+ await result.current.deleteChat("websocket:chat-a");
+ });
+
+ expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
+ expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
+ });
+
+ it("keeps the session in the list when delete fails", async () => {
+ vi.mocked(api.listSessions).mockResolvedValue([
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Alpha",
+ },
+ ]);
+ vi.mocked(api.deleteSession).mockRejectedValue(new Error("boom"));
+
+ const { result } = renderHook(() => useSessions(), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.sessions).toHaveLength(1));
+
+ await expect(
+ act(async () => {
+ await result.current.deleteChat("websocket:chat-a");
+ }),
+ ).rejects.toThrow("boom");
+
+ expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]);
+ });
+});
diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js
new file mode 100644
index 00000000..510334d4
--- /dev/null
+++ b/webui/tailwind.config.js
@@ -0,0 +1,81 @@
+import animate from "tailwindcss-animate";
+import typography from "@tailwindcss/typography";
+
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ theme: {
+ container: {
+ center: true,
+ padding: "1rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ sidebar: {
+ DEFAULT: "hsl(var(--sidebar))",
+ foreground: "hsl(var(--sidebar-foreground))",
+ accent: "hsl(var(--sidebar-accent))",
+ "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
+ border: "hsl(var(--sidebar-border))",
+ },
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [animate, typography],
+};
diff --git a/webui/tsconfig.build.json b/webui/tsconfig.build.json
new file mode 100644
index 00000000..8b218ef9
--- /dev/null
+++ b/webui/tsconfig.build.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "exclude": ["src/tests/**"]
+}
diff --git a/webui/tsconfig.json b/webui/tsconfig.json
new file mode 100644
index 00000000..9b643571
--- /dev/null
+++ b/webui/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+
+ "types": ["node", "vitest/globals", "@testing-library/jest-dom"],
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/webui/vite.config.ts b/webui/vite.config.ts
new file mode 100644
index 00000000..7a2c9edb
--- /dev/null
+++ b/webui/vite.config.ts
@@ -0,0 +1,65 @@
+import { defineConfig, loadEnv } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "node:path";
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), "");
+ const target = env.NANOBOT_API_URL ?? "http://127.0.0.1:8765";
+ const wsTarget = target.replace(/^http/, "ws");
+
+ return {
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ optimizeDeps: {
+ // Radix dialog was introduced mid-session for the mobile sidebar sheet.
+ // When Vite re-optimizes it on a running dev server, the browser can race
+ // and request stale chunk paths from `.vite/deps`. Excluding it keeps dev
+ // reloads stable instead of rewriting those chunk filenames under us.
+ exclude: ["@radix-ui/react-dialog"],
+ },
+ build: {
+ outDir: path.resolve(__dirname, "../nanobot/web/dist"),
+ emptyOutDir: true,
+ sourcemap: false,
+ },
+ server: {
+ host: "127.0.0.1",
+ port: 5173,
+ strictPort: true,
+ // Move Vite's HMR socket to a dedicated port so it doesn't collide with
+ // the ``/`` proxy below (Vite HMR and the nanobot ws upgrade both sit on
+ // the root path, which triggers spurious write-after-end errors as each
+ // side tries to close the other's socket).
+ hmr: {
+ host: "127.0.0.1",
+ port: 5174,
+ },
+ proxy: {
+ "/webui": { target, changeOrigin: true },
+ "/api": { target, changeOrigin: true },
+ "/auth": { target, changeOrigin: true },
+ // Forward only WebSocket upgrades on ``/`` to the nanobot gateway;
+ // plain HTTP GETs on ``/`` must stay with Vite so it can serve the SPA.
+ // ``bypass`` returning the original URL skips the proxy for that
+ // request; returning undefined lets the proxy (and ws upgrade handler)
+ // take it.
+ "/": {
+ target: wsTarget,
+ ws: true,
+ changeOrigin: true,
+ bypass: (req) =>
+ req.headers.upgrade === "websocket" ? undefined : req.url,
+ },
+ },
+ },
+ test: {
+ environment: "happy-dom",
+ globals: true,
+ setupFiles: ["./src/tests/setup.ts"],
+ },
+ };
+});