feat(webui): add initial webui with websocket chat flow
This commit is contained in:
@@ -11,6 +11,10 @@ webui/node_modules/
|
|||||||
webui/dist/
|
webui/dist/
|
||||||
webui/coverage/
|
webui/coverage/
|
||||||
webui/.vite/
|
webui/.vite/
|
||||||
|
webui/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Built webui assets shipped from `webui/` into the Python package
|
||||||
|
nanobot/web/dist/
|
||||||
|
|
||||||
# Python bytecode & caches
|
# Python bytecode & caches
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -13,6 +14,19 @@ from nanobot.channels.base import BaseChannel
|
|||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
|
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)
|
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
|
||||||
_SEND_RETRY_DELAYS = (1, 2, 4)
|
_SEND_RETRY_DELAYS = (1, 2, 4)
|
||||||
|
|
||||||
@@ -27,9 +41,16 @@ class ChannelManager:
|
|||||||
- Route outbound messages
|
- 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.config = config
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
self._session_manager = session_manager
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._dispatch_task: asyncio.Task | None = None
|
self._dispatch_task: asyncio.Task | None = None
|
||||||
|
|
||||||
@@ -55,7 +76,15 @@ class ChannelManager:
|
|||||||
if not enabled:
|
if not enabled:
|
||||||
continue
|
continue
|
||||||
try:
|
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_provider = transcription_provider
|
||||||
channel.transcription_api_key = transcription_key
|
channel.transcription_api_key = transcription_key
|
||||||
channel.transcription_api_base = transcription_base
|
channel.transcription_api_base = transcription_base
|
||||||
|
|||||||
+318
-21
@@ -7,13 +7,15 @@ import email.utils
|
|||||||
import hmac
|
import hmac
|
||||||
import http
|
import http
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Self
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field, field_validator, model_validator
|
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.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
def _strip_trailing_slash(path: str) -> str:
|
def _strip_trailing_slash(path: str) -> str:
|
||||||
if len(path) > 1 and path.endswith("/"):
|
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)
|
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]]]:
|
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
|
||||||
"""Parse normalized path and query parameters in one pass."""
|
"""Parse normalized path and query parameters in one pass."""
|
||||||
parsed = urlparse("ws://x" + path_with_query)
|
parsed = urlparse("ws://x" + path_with_query)
|
||||||
@@ -189,6 +206,78 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
|||||||
return data
|
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:
|
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``."""
|
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||||
if not configured_secret:
|
if not configured_secret:
|
||||||
@@ -209,7 +298,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
name = "websocket"
|
name = "websocket"
|
||||||
display_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):
|
if isinstance(config, dict):
|
||||||
config = WebSocketConfig.model_validate(config)
|
config = WebSocketConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
@@ -220,9 +316,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_chats: dict[Any, set[str]] = {}
|
self._conn_chats: dict[Any, set[str]] = {}
|
||||||
# connection -> default chat_id for legacy frames that omit routing.
|
# connection -> default chat_id for legacy frames that omit routing.
|
||||||
self._conn_default: dict[Any, str] = {}
|
self._conn_default: dict[Any, str] = {}
|
||||||
|
# Single-use tokens consumed at WebSocket handshake.
|
||||||
self._issued_tokens: dict[str, float] = {}
|
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._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | 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 -------------------------------------------
|
# -- Subscription bookkeeping -------------------------------------------
|
||||||
|
|
||||||
@@ -324,6 +427,209 @@ class WebSocketChannel(BaseChannel):
|
|||||||
{"token": token_value, "expires_in": self.config.token_ttl_s}
|
{"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:
|
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
|
||||||
supplied = _query_first(query, "token")
|
supplied = _query_first(query, "token")
|
||||||
static_token = self.config.token.strip()
|
static_token = self.config.token.strip()
|
||||||
@@ -355,24 +661,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
request: WsRequest,
|
request: WsRequest,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
got, _ = _parse_request_path(request.path)
|
return await self._dispatch_http(connection, request)
|
||||||
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)
|
|
||||||
|
|
||||||
async def handler(connection: ServerConnection) -> None:
|
async def handler(connection: ServerConnection) -> None:
|
||||||
await self._connection_loop(connection)
|
await self._connection_loop(connection)
|
||||||
@@ -523,6 +812,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
self._issued_tokens.clear()
|
self._issued_tokens.clear()
|
||||||
|
self._api_tokens.clear()
|
||||||
|
|
||||||
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
||||||
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
||||||
@@ -550,6 +840,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
payload["media"] = msg.media
|
payload["media"] = msg.media
|
||||||
if msg.reply_to:
|
if msg.reply_to:
|
||||||
payload["reply_to"] = 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)
|
raw = json.dumps(payload, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" ")
|
await self._safe_send_to(connection, raw, label=" ")
|
||||||
|
|||||||
+110
-10
@@ -636,6 +636,21 @@ def gateway(
|
|||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
):
|
):
|
||||||
"""Start the nanobot gateway."""
|
"""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.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
@@ -644,12 +659,6 @@ def gateway(
|
|||||||
from nanobot.heartbeat.service import HeartbeatService
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
from nanobot.session.manager import SessionManager
|
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
|
port = port if port is not None else config.gateway.port
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {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
|
cron.on_job = on_cron_job
|
||||||
|
|
||||||
# Create channel manager
|
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||||
channels = ChannelManager(config, bus)
|
# can serve the embedded webui's REST surface).
|
||||||
|
channels = ChannelManager(config, bus, session_manager=session_manager)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
"""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()}")
|
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():
|
async def run():
|
||||||
try:
|
try:
|
||||||
await cron.start()
|
await cron.start()
|
||||||
await heartbeat.start()
|
await heartbeat.start()
|
||||||
await asyncio.gather(
|
tasks = [
|
||||||
agent.run(),
|
agent.run(),
|
||||||
channels.start_all(),
|
channels.start_all(),
|
||||||
_health_server(config.gateway.host, port),
|
_health_server(config.gateway.host, port),
|
||||||
)
|
]
|
||||||
|
if open_browser_url:
|
||||||
|
tasks.append(_open_browser_when_ready())
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nShutting down...")
|
console.print("\nShutting down...")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -907,6 +945,68 @@ def gateway(
|
|||||||
asyncio.run(run())
|
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
|
# Agent Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -106,15 +106,18 @@ class SessionManager:
|
|||||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||||
self._cache: dict[str, Session] = {}
|
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:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
"""Get the file path for a session."""
|
"""Get the file path for a session."""
|
||||||
safe_key = safe_filename(key.replace(":", "_"))
|
return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
return self.sessions_dir / f"{safe_key}.jsonl"
|
|
||||||
|
|
||||||
def _get_legacy_session_path(self, key: str) -> Path:
|
def _get_legacy_session_path(self, key: str) -> Path:
|
||||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||||
safe_key = safe_filename(key.replace(":", "_"))
|
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
|
|
||||||
|
|
||||||
def get_or_create(self, key: str) -> Session:
|
def get_or_create(self, key: str) -> Session:
|
||||||
"""
|
"""
|
||||||
@@ -209,6 +212,61 @@ class SessionManager:
|
|||||||
"""Remove a session from the in-memory cache."""
|
"""Remove a session from the in-memory cache."""
|
||||||
self._cache.pop(key, None)
|
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]]:
|
def list_sessions(self) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List all sessions.
|
List all sessions.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -113,6 +113,7 @@ include = [
|
|||||||
"nanobot/templates/**/*.md",
|
"nanobot/templates/**/*.md",
|
||||||
"nanobot/skills/**/*.md",
|
"nanobot/skills/**/*.md",
|
||||||
"nanobot/skills/**/*.sh",
|
"nanobot/skills/**/*.sh",
|
||||||
|
"nanobot/web/dist/**/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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("<!doctype html><title>nbweb</title>")
|
||||||
|
(dist / "favicon.svg").write_text("<svg/>")
|
||||||
|
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 "<svg" in asset.text
|
||||||
|
# Unknown SPA route falls back to index.html.
|
||||||
|
spa = await _http_get("http://127.0.0.1:29905/sessions/abc")
|
||||||
|
assert spa.status_code == 200
|
||||||
|
assert "nbweb" in spa.text
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_static_rejects_path_traversal(
|
||||||
|
bus: MagicMock, tmp_path: Path
|
||||||
|
) -> 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
|
||||||
@@ -189,6 +189,45 @@ async def test_server_send_message(bus: MagicMock) -> None:
|
|||||||
await ch.stop(); await t
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
|
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
|
||||||
ch = _ch(bus, 29910)
|
ch = _ch(bus, 29910)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# nanobot webui
|
||||||
|
|
||||||
|
The browser front-end for `nanobot web`. Built with Vite + React 18 +
|
||||||
|
TypeScript + Tailwind 3 + shadcn/ui. Talks to the gateway over the WebSocket
|
||||||
|
multiplex protocol; session metadata comes from the embedded REST surface on
|
||||||
|
the same port.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
webui/ source tree (this directory)
|
||||||
|
nanobot/web/dist/ build output, shipped in the Python wheel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webui
|
||||||
|
bun install # npm install also works
|
||||||
|
bun run dev # http://127.0.0.1:5173 (proxies /api /webui /auth -> 8765)
|
||||||
|
```
|
||||||
|
|
||||||
|
In a separate shell, start the gateway with the WebSocket channel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run nanobot gateway # or `nanobot web` once you've built once
|
||||||
|
```
|
||||||
|
|
||||||
|
If the 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build # writes ../nanobot/web/dist (consumed by `nanobot web`)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test # vitest, jsdom-style happy-dom environment
|
||||||
|
```
|
||||||
+951
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="description" content="nanobot web UI — chat with your nanobot workspace." />
|
||||||
|
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#161618" media="(prefers-color-scheme: dark)" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/brand/nanobot_favicon_32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="73x75" href="/brand/nanobot_icon.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/brand/nanobot_apple_touch.png" />
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.74);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .boot-splash-inner {
|
||||||
|
color: rgba(10, 10, 10, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.75;
|
||||||
|
animation: boot-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes boot-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("nanobot-webui.theme");
|
||||||
|
var dark =
|
||||||
|
stored === "dark" ||
|
||||||
|
(!stored &&
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
if (dark) document.documentElement.classList.add("dark");
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<title>nanobot</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-foreground antialiased">
|
||||||
|
<div id="root">
|
||||||
|
<div class="boot-splash">
|
||||||
|
<div class="boot-splash-inner">
|
||||||
|
<span class="boot-dot" aria-hidden="true"></span>
|
||||||
|
<span>Loading nanobot…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,311 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
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 [state, setState] = useState<BootState>({ 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 (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
|
||||||
|
<img
|
||||||
|
src="/brand/nanobot_icon.png"
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 animate-pulse select-none"
|
||||||
|
aria-hidden
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-foreground/60" />
|
||||||
|
</span>
|
||||||
|
Connecting to nanobot…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center px-4 text-center">
|
||||||
|
<div className="flex max-w-md flex-col items-center gap-3">
|
||||||
|
<img
|
||||||
|
src="/brand/nanobot_icon.png"
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 opacity-60 grayscale select-none"
|
||||||
|
aria-hidden
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<p className="text-lg font-semibold">Couldn't reach nanobot</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{state.message}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Make sure the gateway is running (`nanobot web`) and that this page
|
||||||
|
is open on the same machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientProvider
|
||||||
|
client={state.client}
|
||||||
|
token={state.token}
|
||||||
|
modelName={state.modelName}
|
||||||
|
>
|
||||||
|
<Shell />
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
||||||
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
|
const [desktopSidebarOpen, setDesktopSidebarOpen] =
|
||||||
|
useState<boolean>(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<ChatSummary | null>(() => {
|
||||||
|
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 || `Chat ${activeSession.chatId.slice(0, 6)}`
|
||||||
|
: "nanobot";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
|
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||||
|
"transition-[width] duration-300 ease-out",
|
||||||
|
)}
|
||||||
|
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right",
|
||||||
|
"transition-transform duration-300 ease-out",
|
||||||
|
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
open={mobileSidebarOpen}
|
||||||
|
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||||
|
>
|
||||||
|
<SheetContent side="left" className="w-[279px] p-0 sm:max-w-[279px] lg:hidden">
|
||||||
|
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<main className="flex h-full min-w-0 flex-1 flex-col">
|
||||||
|
<ThreadShell
|
||||||
|
session={activeSession}
|
||||||
|
title={headerTitle}
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
onGoHome={() => setActiveKey(null)}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<DeleteConfirm
|
||||||
|
open={!!pendingDelete}
|
||||||
|
title={pendingDelete?.label ?? ""}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
onConfirm={onConfirmDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
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): string {
|
||||||
|
const p = s.preview?.trim();
|
||||||
|
if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p;
|
||||||
|
return `Chat ${s.chatId.slice(0, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatList({
|
||||||
|
sessions,
|
||||||
|
activeKey,
|
||||||
|
onSelect,
|
||||||
|
onRequestDelete,
|
||||||
|
loading,
|
||||||
|
}: ChatListProps) {
|
||||||
|
if (loading && sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">Loading…</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-6 text-xs text-muted-foreground">
|
||||||
|
No sessions yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<ul className="space-y-0.5 px-2 py-1">
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const active = s.key === activeKey;
|
||||||
|
const title = titleFor(s);
|
||||||
|
return (
|
||||||
|
<li key={s.key}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]"
|
||||||
|
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(s.key)}
|
||||||
|
className="flex min-w-0 flex-1 flex-col items-start text-left"
|
||||||
|
>
|
||||||
|
<span className="w-full truncate font-medium leading-5">{title}</span>
|
||||||
|
<span className="text-[10.5px] text-muted-foreground/80">
|
||||||
|
{relativeTime(s.updatedAt ?? s.createdAt) || "—"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
|
||||||
|
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||||
|
"focus-visible:opacity-100",
|
||||||
|
active && "opacity-100",
|
||||||
|
)}
|
||||||
|
aria-label={`Chat actions for ${title}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
window.setTimeout(() => onRequestDelete(s.key, title), 0);
|
||||||
|
}}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string | null>(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 (
|
||||||
|
<section className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
|
||||||
|
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||||
|
<picture>
|
||||||
|
<source
|
||||||
|
srcSet="/brand/nanobot_logo.webp"
|
||||||
|
type="image/webp"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/brand/nanobot_logo.png"
|
||||||
|
alt="nanobot"
|
||||||
|
className="h-12 w-auto select-none drop-shadow-sm"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<h1 className="text-xl font-medium tracking-tight text-foreground/90">
|
||||||
|
What's on your mind?
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-center text-sm text-muted-foreground">
|
||||||
|
Your conversations are persisted locally under the nanobot
|
||||||
|
workspace. Start typing and I'll open a new chat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||||
|
<Composer
|
||||||
|
compact
|
||||||
|
disabled={booting}
|
||||||
|
onSend={handleWelcomeSend}
|
||||||
|
placeholder={
|
||||||
|
booting ? "Opening a new chat…" : "Type your message…"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
<MessageList messages={messages} isStreaming={isStreaming} />
|
||||||
|
<Composer
|
||||||
|
onSend={send}
|
||||||
|
disabled={!chatId}
|
||||||
|
placeholder="Type your message…"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
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 [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 (
|
||||||
|
<div className={cn("overflow-hidden rounded-lg", className)}>
|
||||||
|
<div className="flex items-center justify-between bg-zinc-900 px-4 py-1.5 text-xs font-medium text-zinc-200">
|
||||||
|
<span className="lowercase">{language || "code"}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
aria-label="Copy code"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{copied ? "Copied" : "Copy"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={language}
|
||||||
|
style={isDark ? oneDark : oneLight}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "1rem",
|
||||||
|
background: "var(--tw-prose-pre-bg, #0a0a0a)",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
PreTag="pre"
|
||||||
|
wrapLongLines
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement> = (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
|
||||||
|
"border bg-muted/60 shadow-sm transition-all duration-200",
|
||||||
|
"focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
|
||||||
|
disabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onInput={onInput}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
rows={1}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Message input"
|
||||||
|
className={cn(
|
||||||
|
"min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
|
||||||
|
"placeholder:text-muted-foreground",
|
||||||
|
"focus:outline-none focus-visible:outline-none",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 pb-2">
|
||||||
|
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
|
||||||
|
Enter to send · Shift+Enter for newline
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden" aria-hidden />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
aria-label="Send message"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-full shadow-sm transition-transform",
|
||||||
|
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
import type { ConnectionStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const COPY: Record<ConnectionStatus, { label: string; color: string }> = {
|
||||||
|
idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
|
||||||
|
connecting: {
|
||||||
|
label: "Connecting…",
|
||||||
|
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
label: "Connected",
|
||||||
|
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
reconnecting: {
|
||||||
|
label: "Reconnecting…",
|
||||||
|
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||||
|
},
|
||||||
|
closed: {
|
||||||
|
label: "Disconnected",
|
||||||
|
color: "bg-card/40 text-muted-foreground",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: "Connection error",
|
||||||
|
color: "bg-destructive/10 text-destructive",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConnectionBadge() {
|
||||||
|
const { client } = useClient();
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>(client.status);
|
||||||
|
|
||||||
|
useEffect(() => client.onStatus(setStatus), [client]);
|
||||||
|
|
||||||
|
const meta = COPY[status];
|
||||||
|
const pulsing =
|
||||||
|
status === "connecting" ||
|
||||||
|
status === "reconnecting" ||
|
||||||
|
status === "error";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium transition-colors",
|
||||||
|
meta.color,
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-1.5 w-1.5" aria-hidden>
|
||||||
|
{pulsing && (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
||||||
|
)}
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||||
|
</span>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface DeleteConfirmProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirm({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteConfirmProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete “{title}”?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The session file will be removed from disk. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
onNewChat,
|
||||||
|
}: {
|
||||||
|
onNewChat: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<MessageSquarePlus
|
||||||
|
className="h-10 w-10 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-lg font-medium">No chats yet</p>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
Start a conversation — your sessions are stored locally on the nanobot
|
||||||
|
workspace and stay available across reloads.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onNewChat}>New chat</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"markdown-content prose prose-sm max-w-none dark:prose-invert",
|
||||||
|
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold",
|
||||||
|
"prose-h1:text-lg prose-h2:text-base prose-h3:text-[0.95rem] prose-h4:text-sm",
|
||||||
|
"prose-p:my-2 prose-p:leading-relaxed",
|
||||||
|
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
|
||||||
|
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
|
||||||
|
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
|
||||||
|
"prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80",
|
||||||
|
"prose-hr:my-6",
|
||||||
|
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
|
||||||
|
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
|
||||||
|
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex]}
|
||||||
|
components={{
|
||||||
|
code({ className: cls, children: kids, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(cls || "");
|
||||||
|
if (!match) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className={cn(
|
||||||
|
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||||
|
cls,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{kids}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const code = String(kids).replace(/\n$/, "");
|
||||||
|
return <CodeBlock language={match[1]} code={code} className="my-3" />;
|
||||||
|
},
|
||||||
|
pre({ children: markdownChildren }) {
|
||||||
|
return <>{markdownChildren}</>;
|
||||||
|
},
|
||||||
|
a({ href, children: markdownChildren, ...props }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{markdownChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronRight, Wrench } from "lucide-react";
|
||||||
|
|
||||||
|
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 <TraceGroup message={message} animClass={baseAnim} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "user") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group ml-auto flex max-w-[min(85%,36rem)] items-center gap-2",
|
||||||
|
baseAnim,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"ml-auto w-fit rounded-[18px] border border-border/60 bg-secondary/70 px-4 py-2",
|
||||||
|
"text-right text-sm whitespace-pre-wrap break-words",
|
||||||
|
"shadow-[0_10px_24px_-18px_rgba(0,0,0,0.55)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = message.content.trim().length === 0;
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full text-sm leading-relaxed", baseAnim)}>
|
||||||
|
{empty && message.isStreaming ? (
|
||||||
|
<TypingDots />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MarkdownText>{message.content}</MarkdownText>
|
||||||
|
{message.isStreaming && <StreamCursor />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blinking cursor appended at the end of streaming text. */
|
||||||
|
function StreamCursor() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="streaming"
|
||||||
|
className={cn(
|
||||||
|
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
|
||||||
|
"rounded-sm bg-foreground/70 animate-pulse",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-token-arrival placeholder: three bouncing dots. */
|
||||||
|
function TypingDots() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="Assistant is typing"
|
||||||
|
className="inline-flex items-center gap-1 py-1"
|
||||||
|
>
|
||||||
|
<Dot delay="0ms" />
|
||||||
|
<Dot delay="150ms" />
|
||||||
|
<Dot delay="300ms" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ delay }: { delay: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ animationDelay: delay }}
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60",
|
||||||
|
"animate-bounce",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 lines = message.traces ?? [message.content];
|
||||||
|
const count = lines.length;
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full", animClass)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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}
|
||||||
|
>
|
||||||
|
<Wrench className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
<span className="font-medium">
|
||||||
|
{count === 1 ? "Using a tool" : `Used ${count} tools`}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||||
|
open && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"mt-1 space-y-0.5 border-l border-muted-foreground/20 pl-3",
|
||||||
|
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed text-muted-foreground/90"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Say hi to get started.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn(
|
||||||
|
"h-full overflow-y-auto scroll-smooth",
|
||||||
|
"[&::-webkit-scrollbar]:w-1.5",
|
||||||
|
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||||
|
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||||
|
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<MessageBubble key={m.id} message={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top fade so messages slide under the header gracefully. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||||
|
/>
|
||||||
|
{/* Bottom fade so messages fade out behind the composer. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!atBottom && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
import { ChatList } from "@/components/ChatList";
|
||||||
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
|
||||||
|
<div className="flex items-center justify-between px-2 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
onClick={props.onCollapse}
|
||||||
|
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={props.onToggleTheme}
|
||||||
|
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
{props.theme === "dark" ? (
|
||||||
|
<Sun className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 pb-2.5">
|
||||||
|
<Button
|
||||||
|
onClick={props.onNewChat}
|
||||||
|
className="h-8.5 w-full justify-start gap-2 rounded-lg border border-sidebar-border/80 bg-card/25 px-3 text-[13px] font-medium text-sidebar-foreground shadow-none hover:bg-sidebar-accent/80"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-sidebar-border/70" />
|
||||||
|
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
|
||||||
|
<span>Recent</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||||
|
onClick={props.onRefresh}
|
||||||
|
aria-label="Refresh sessions"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ChatList
|
||||||
|
sessions={props.sessions}
|
||||||
|
activeKey={props.activeKey}
|
||||||
|
loading={props.loading}
|
||||||
|
onSelect={props.onSelect}
|
||||||
|
onRequestDelete={props.onRequestDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-sidebar-border/70" />
|
||||||
|
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
|
||||||
|
<ConnectionBadge />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowUp } from "lucide-react";
|
||||||
|
|
||||||
|
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 = "Type your message…",
|
||||||
|
modelLabel = null,
|
||||||
|
variant = "thread",
|
||||||
|
}: ThreadComposerProps) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const isHero = variant === "hero";
|
||||||
|
|
||||||
|
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<HTMLTextAreaElement> = (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
|
||||||
|
isHero
|
||||||
|
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card/72 shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
|
||||||
|
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card/55",
|
||||||
|
"focus-within:bg-card/70 focus-within:ring-1 focus-within:ring-foreground/8",
|
||||||
|
disabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onInput={onInput}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
rows={1}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Message input"
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-2",
|
||||||
|
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{modelLabel ? (
|
||||||
|
<span
|
||||||
|
title={modelLabel}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
|
||||||
|
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
|
||||||
|
isHero ? "text-[11px]" : "text-[10.5px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{modelLabel}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
||||||
|
Enter to send · Shift+Enter for newline
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="sm:hidden" aria-hidden />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
aria-label="Send message"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
|
||||||
|
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
|
||||||
|
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { PanelLeftOpen } from "lucide-react";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
|
||||||
|
<div className="relative flex min-w-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||||
|
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGoHome}
|
||||||
|
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/brand/nanobot_icon.png"
|
||||||
|
alt=""
|
||||||
|
className="h-4 w-4 rounded-[5px] opacity-85"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex w-full flex-col gap-5">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageBubble key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
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<string | null>;
|
||||||
|
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 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<string | null>(null);
|
||||||
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(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 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Loading conversation…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
|
||||||
|
<img
|
||||||
|
src="/brand/nanobot_icon.png"
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
draggable={false}
|
||||||
|
className="h-4 w-4 rounded-sm opacity-90"
|
||||||
|
/>
|
||||||
|
<span className="text-foreground/82">nanobot</span>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
|
||||||
|
Ask questions, continue local work, or start a new thread.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<ThreadHeader
|
||||||
|
title={title}
|
||||||
|
onToggleSidebar={onToggleSidebar}
|
||||||
|
onGoHome={onGoHome}
|
||||||
|
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
||||||
|
/>
|
||||||
|
<ThreadViewport
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
emptyState={emptyState}
|
||||||
|
composer={
|
||||||
|
session ? (
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={send}
|
||||||
|
disabled={!chatId}
|
||||||
|
placeholder={showHeroComposer ? "What's on your mind?" : "Type your message…"}
|
||||||
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={handleWelcomeSend}
|
||||||
|
disabled={booting}
|
||||||
|
placeholder={booting ? "Opening a new chat…" : "What's on your mind?"}
|
||||||
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
|
variant="hero"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
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 scrollRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 overflow-y-auto scroll-smooth scrollbar-thin",
|
||||||
|
"[&::-webkit-scrollbar]:w-1.5",
|
||||||
|
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||||
|
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||||
|
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasMessages ? (
|
||||||
|
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||||
|
<div className="flex-1 px-4 pb-20 pt-4">
|
||||||
|
<ThreadMessages messages={messages} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 z-10 mt-auto">
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
{composer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col px-4">
|
||||||
|
<div className="flex w-full flex-1 justify-center pb-16 pt-14 md:pt-[3.5rem]">
|
||||||
|
<div className="flex w-full max-w-[40rem] flex-col gap-5">
|
||||||
|
{emptyState}
|
||||||
|
<div className="w-full">{composer}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!atBottom && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => 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="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
@@ -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<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarFallback, AvatarImage };
|
||||||
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -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<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -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<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
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<typeof DialogPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
sheetVariants({ side }),
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"duration-300",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
export { Sheet, SheetTrigger, SheetClose, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetTitle };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -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<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<React.SetStateAction<UIMessage[]>>;
|
||||||
|
} {
|
||||||
|
const { client } = useClient();
|
||||||
|
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const buffer = useRef<StreamBuffer | null>(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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
deleteSession as apiDeleteSession,
|
||||||
|
fetchSessionMessages,
|
||||||
|
listSessions,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
|
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
|
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||||
|
export function useSessions(): {
|
||||||
|
sessions: ChatSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
createChat: () => Promise<string>;
|
||||||
|
deleteChat: (key: string) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const { client, token } = useClient();
|
||||||
|
const [sessions, setSessions] = useState<ChatSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<string> => {
|
||||||
|
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: [], 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: [], 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, "New chat");
|
||||||
|
}
|
||||||
@@ -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<Theme>(() => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
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<ChatSummary[]> {
|
||||||
|
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<boolean> {
|
||||||
|
const body = await request<{ deleted: boolean }>(
|
||||||
|
`${base}/api/sessions/${encodeURIComponent(key)}/delete`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
return body.deleted;
|
||||||
|
}
|
||||||
@@ -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<BootstrapResponse> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/** 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 RTF = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||||
|
|
||||||
|
export function relativeTime(value: string | number | null | undefined): string {
|
||||||
|
const date = parseDate(value);
|
||||||
|
if (!date) return "";
|
||||||
|
let delta = (date.getTime() - Date.now()) / 1000;
|
||||||
|
for (const [step, unit] of RELATIVE_THRESHOLDS) {
|
||||||
|
if (Math.abs(delta) < step) {
|
||||||
|
return RTF.format(Math.round(delta), unit);
|
||||||
|
}
|
||||||
|
delta /= step;
|
||||||
|
}
|
||||||
|
return RTF.format(Math.round(delta), "year");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateTime(value: string | number | null | undefined): string {
|
||||||
|
const date = parseDate(value);
|
||||||
|
return date ? date.toLocaleString() : "";
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NanobotClientOptions {
|
||||||
|
url: string;
|
||||||
|
reconnect?: boolean;
|
||||||
|
/** Called when a connection drops so the app can refresh its token. */
|
||||||
|
onReauth?: () => Promise<string | null>;
|
||||||
|
/** 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<StatusHandler>();
|
||||||
|
// chat_id -> handlers listening on it
|
||||||
|
private chatHandlers = new Map<string, Set<EventHandler>>();
|
||||||
|
// chat_ids we've attached to since connect; re-attached after reconnects
|
||||||
|
private knownChats = new Set<string>();
|
||||||
|
private pendingNewChat: PendingNewChat | null = null;
|
||||||
|
// Frames queued while the socket is not yet OPEN
|
||||||
|
private sendQueue: Outbound[] = [];
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | 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<string> {
|
||||||
|
if (this.pendingNewChat) {
|
||||||
|
return Promise.reject(new Error("newChat already in flight"));
|
||||||
|
}
|
||||||
|
return new Promise<string>((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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("root element missing");
|
||||||
|
|
||||||
|
ReactDOM.createRoot(root).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -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<ClientContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ClientProvider({
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
modelName = null,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
client: NanobotClient;
|
||||||
|
token: string;
|
||||||
|
modelName?: string | null;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ClientContext.Provider value={{ client, token, modelName }}>
|
||||||
|
{children}
|
||||||
|
</ClientContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClient(): ClientContextValue {
|
||||||
|
const ctx = useContext(ClientContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useClient must be used within a ClientProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@/hooks/useSessions")>();
|
||||||
|
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(<App />);
|
||||||
|
|
||||||
|
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(<App />);
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(<MessageBubble message={message} />);
|
||||||
|
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(<MessageBubble message={message} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
modelLabel="claude-opus-4-5"
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
variant="hero"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof makeClient>, children: ReactNode) {
|
||||||
|
return (
|
||||||
|
<ClientProvider
|
||||||
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||||
|
token="tok"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-b")}
|
||||||
|
title="Chat chat-b"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onGoHome={() => {}}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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,
|
||||||
|
<ThreadShell
|
||||||
|
session={null}
|
||||||
|
title="nanobot"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onGoHome={() => {}}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-new")}
|
||||||
|
title="Chat chat-new"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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<unknown> }) => 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,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onGoHome={() => {}}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("from chat a")).toBeInTheDocument());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-b")}
|
||||||
|
title="Chat chat-b"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Set<(ev: InboundEvent) => 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<typeof fakeClient>["client"]) {
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ClientProvider
|
||||||
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||||
|
token="tok"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@/lib/api")>();
|
||||||
|
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<typeof fakeClient>) {
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ClientProvider
|
||||||
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||||
|
token="tok"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"exclude": ["src/tests/**"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user