Merge PR #3310: feat(webui): add initial browser UI with websocket chat and i18n
feat(webui): add initial browser UI with websocket chat and i18n
This commit is contained in:
@@ -185,6 +185,29 @@ nanobot agent
|
||||
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
|
||||
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
|
||||
|
||||
## 🧪 WebUI (Development)
|
||||
|
||||
> [!NOTE]
|
||||
> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs, build steps, and release notes.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
</p>
|
||||
|
||||
**1. Start the gateway**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
**2. Start the webui dev server**
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
<p align="center">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
@@ -3,7 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -13,6 +14,19 @@ from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
def _default_webui_dist() -> Path | None:
|
||||
"""Return the absolute path to the bundled webui dist directory if it exists."""
|
||||
try:
|
||||
import nanobot.web as web_pkg # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
return None
|
||||
candidate = Path(web_pkg.__file__).resolve().parent / "dist"
|
||||
return candidate if candidate.is_dir() else None
|
||||
|
||||
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
|
||||
_SEND_RETRY_DELAYS = (1, 2, 4)
|
||||
|
||||
@@ -27,9 +41,16 @@ class ChannelManager:
|
||||
- Route outbound messages
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, bus: MessageBus):
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
bus: MessageBus,
|
||||
*,
|
||||
session_manager: "SessionManager | None" = None,
|
||||
):
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
self._session_manager = session_manager
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self._dispatch_task: asyncio.Task | None = None
|
||||
|
||||
@@ -55,7 +76,15 @@ class ChannelManager:
|
||||
if not enabled:
|
||||
continue
|
||||
try:
|
||||
channel = cls(section, self.bus)
|
||||
kwargs: dict[str, Any] = {}
|
||||
# Only the WebSocket channel currently hosts the embedded webui
|
||||
# surface; other channels stay oblivious to these knobs.
|
||||
if cls.name == "websocket" and self._session_manager is not None:
|
||||
kwargs["session_manager"] = self._session_manager
|
||||
static_path = _default_webui_dist()
|
||||
if static_path is not None:
|
||||
kwargs["static_dist_path"] = static_path
|
||||
channel = cls(section, self.bus, **kwargs)
|
||||
channel.transcription_provider = transcription_provider
|
||||
channel.transcription_api_key = transcription_key
|
||||
channel.transcription_api_base = transcription_base
|
||||
|
||||
+318
-21
@@ -7,13 +7,15 @@ import email.utils
|
||||
import hmac
|
||||
import http
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Self
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
@@ -28,6 +30,9 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
def _strip_trailing_slash(path: str) -> str:
|
||||
if len(path) > 1 and path.endswith("/"):
|
||||
@@ -116,6 +121,18 @@ def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
||||
return Response(status, reason, headers, body)
|
||||
|
||||
|
||||
def _read_webui_model_name() -> str | None:
|
||||
"""Return the configured default model for readonly webui display."""
|
||||
try:
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
model = load_config().agents.defaults.model.strip()
|
||||
return model or None
|
||||
except Exception as e:
|
||||
logger.debug("webui bootstrap could not load model name: {}", e)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
|
||||
"""Parse normalized path and query parameters in one pass."""
|
||||
parsed = urlparse("ws://x" + path_with_query)
|
||||
@@ -189,6 +206,78 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
_LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
# Matches the legacy chat-id pattern but allows file-system-safe stems too,
|
||||
# so the API can address sessions whose keys came from non-WebSocket channels.
|
||||
_API_KEY_RE = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
|
||||
|
||||
|
||||
def _decode_api_key(raw_key: str) -> str | None:
|
||||
"""Decode a percent-encoded API path segment, then validate the result."""
|
||||
key = unquote(raw_key)
|
||||
if _API_KEY_RE.match(key) is None:
|
||||
return None
|
||||
return key
|
||||
|
||||
|
||||
def _is_localhost(connection: Any) -> bool:
|
||||
"""Return True if *connection* originated from the loopback interface."""
|
||||
addr = getattr(connection, "remote_address", None)
|
||||
if not addr:
|
||||
return False
|
||||
host = addr[0] if isinstance(addr, tuple) else addr
|
||||
if not isinstance(host, str):
|
||||
return False
|
||||
# ``::ffff:127.0.0.1`` is loopback in IPv6-mapped form.
|
||||
if host.startswith("::ffff:"):
|
||||
host = host[7:]
|
||||
return host in _LOCALHOSTS
|
||||
|
||||
|
||||
def _http_response(
|
||||
body: bytes,
|
||||
*,
|
||||
status: int = 200,
|
||||
content_type: str = "text/plain; charset=utf-8",
|
||||
extra_headers: list[tuple[str, str]] | None = None,
|
||||
) -> Response:
|
||||
headers = [
|
||||
("Date", email.utils.formatdate(usegmt=True)),
|
||||
("Connection", "close"),
|
||||
("Content-Length", str(len(body))),
|
||||
("Content-Type", content_type),
|
||||
]
|
||||
if extra_headers:
|
||||
headers.extend(extra_headers)
|
||||
reason = http.HTTPStatus(status).phrase
|
||||
return Response(status, reason, Headers(headers), body)
|
||||
|
||||
|
||||
def _http_error(status: int, message: str | None = None) -> Response:
|
||||
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
|
||||
return _http_response(body, status=status)
|
||||
|
||||
|
||||
def _bearer_token(headers: Any) -> str | None:
|
||||
"""Pull a Bearer token out of standard or query-style headers."""
|
||||
auth = headers.get("Authorization") or headers.get("authorization")
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _is_websocket_upgrade(request: WsRequest) -> bool:
|
||||
"""Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through."""
|
||||
upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade")
|
||||
connection = request.headers.get("Connection") or request.headers.get("connection")
|
||||
if not upgrade or "websocket" not in upgrade.lower():
|
||||
return False
|
||||
if not connection or "upgrade" not in connection.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@@ -209,7 +298,14 @@ class WebSocketChannel(BaseChannel):
|
||||
name = "websocket"
|
||||
display_name = "WebSocket"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
def __init__(
|
||||
self,
|
||||
config: Any,
|
||||
bus: MessageBus,
|
||||
*,
|
||||
session_manager: "SessionManager | None" = None,
|
||||
static_dist_path: Path | None = None,
|
||||
):
|
||||
if isinstance(config, dict):
|
||||
config = WebSocketConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
@@ -220,9 +316,16 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_chats: dict[Any, set[str]] = {}
|
||||
# connection -> default chat_id for legacy frames that omit routing.
|
||||
self._conn_default: dict[Any, str] = {}
|
||||
# Single-use tokens consumed at WebSocket handshake.
|
||||
self._issued_tokens: dict[str, float] = {}
|
||||
# Multi-use tokens for the embedded webui's REST surface; checked but not consumed.
|
||||
self._api_tokens: dict[str, float] = {}
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._server_task: asyncio.Task[None] | None = None
|
||||
self._session_manager = session_manager
|
||||
self._static_dist_path: Path | None = (
|
||||
static_dist_path.resolve() if static_dist_path is not None else None
|
||||
)
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -324,6 +427,209 @@ class WebSocketChannel(BaseChannel):
|
||||
{"token": token_value, "expires_in": self.config.token_ttl_s}
|
||||
)
|
||||
|
||||
# -- HTTP dispatch ------------------------------------------------------
|
||||
|
||||
async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any:
|
||||
"""Route an inbound HTTP request to a handler or to the WS upgrade path."""
|
||||
got, query = _parse_request_path(request.path)
|
||||
|
||||
# 1. Token issue endpoint (legacy, optional, gated by configured secret).
|
||||
if self.config.token_issue_path:
|
||||
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
||||
if got == issue_expected:
|
||||
return self._handle_token_issue_http(connection, request)
|
||||
|
||||
# 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI.
|
||||
if got == "/webui/bootstrap":
|
||||
return self._handle_webui_bootstrap(connection)
|
||||
|
||||
# 3. REST surface for the embedded UI.
|
||||
if got == "/api/sessions":
|
||||
return self._handle_sessions_list(request)
|
||||
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
|
||||
# NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
|
||||
# true ``DELETE`` verb. The action is folded into the path instead.
|
||||
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
|
||||
if m:
|
||||
return self._handle_session_delete(request, m.group(1))
|
||||
|
||||
# 4. WebSocket upgrade (the channel's primary purpose). Only run the
|
||||
# handshake gate on requests that actually ask to upgrade; otherwise
|
||||
# a bare ``GET /`` from the browser would be rejected as an
|
||||
# unauthorized WS handshake instead of serving the SPA's index.html.
|
||||
expected_ws = self._expected_path()
|
||||
if got == expected_ws and _is_websocket_upgrade(request):
|
||||
client_id = _query_first(query, "client_id") or ""
|
||||
if len(client_id) > 128:
|
||||
client_id = client_id[:128]
|
||||
if not self.is_allowed(client_id):
|
||||
return connection.respond(403, "Forbidden")
|
||||
return self._authorize_websocket_handshake(connection, query)
|
||||
|
||||
# 5. Static SPA serving (only if a build directory was wired in).
|
||||
if self._static_dist_path is not None:
|
||||
response = self._serve_static(got)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
return connection.respond(404, "Not Found")
|
||||
|
||||
# -- HTTP route handlers ------------------------------------------------
|
||||
|
||||
def _check_api_token(self, request: WsRequest) -> bool:
|
||||
"""Validate a request against the API token pool (multi-use, TTL-bound)."""
|
||||
self._purge_expired_api_tokens()
|
||||
token = _bearer_token(request.headers) or _query_first(
|
||||
_parse_query(request.path), "token"
|
||||
)
|
||||
if not token:
|
||||
return False
|
||||
expiry = self._api_tokens.get(token)
|
||||
if expiry is None or time.monotonic() > expiry:
|
||||
self._api_tokens.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _purge_expired_api_tokens(self) -> None:
|
||||
now = time.monotonic()
|
||||
for token_key, expiry in list(self._api_tokens.items()):
|
||||
if now > expiry:
|
||||
self._api_tokens.pop(token_key, None)
|
||||
|
||||
def _handle_webui_bootstrap(self, connection: Any) -> Response:
|
||||
if not _is_localhost(connection):
|
||||
return _http_error(403, "webui bootstrap is localhost-only")
|
||||
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
|
||||
self._purge_expired_issued_tokens()
|
||||
self._purge_expired_api_tokens()
|
||||
if (
|
||||
len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS
|
||||
or len(self._api_tokens) >= self._MAX_ISSUED_TOKENS
|
||||
):
|
||||
return _http_response(
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
status=429,
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
token = f"nbwt_{secrets.token_urlsafe(32)}"
|
||||
expiry = time.monotonic() + float(self.config.token_ttl_s)
|
||||
# Same string registered in both pools: the WS handshake consumes one copy
|
||||
# while the REST surface keeps validating the other until TTL expiry.
|
||||
self._issued_tokens[token] = expiry
|
||||
self._api_tokens[token] = expiry
|
||||
return _http_json_response(
|
||||
{
|
||||
"token": token,
|
||||
"ws_path": self._expected_path(),
|
||||
"expires_in": self.config.token_ttl_s,
|
||||
"model_name": _read_webui_model_name(),
|
||||
}
|
||||
)
|
||||
|
||||
def _handle_sessions_list(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if self._session_manager is None:
|
||||
return _http_error(503, "session manager unavailable")
|
||||
sessions = self._session_manager.list_sessions()
|
||||
# The webui is only meaningful for websocket-channel chats — CLI /
|
||||
# Slack / Lark / Discord sessions can't be resumed from the browser,
|
||||
# so leaking them into the sidebar is just noise. Filter to the
|
||||
# ``websocket:`` prefix and strip absolute paths on the way out.
|
||||
cleaned = [
|
||||
{k: v for k, v in s.items() if k != "path"}
|
||||
for s in sessions
|
||||
if isinstance(s.get("key"), str) and s["key"].startswith("websocket:")
|
||||
]
|
||||
return _http_json_response({"sessions": cleaned})
|
||||
|
||||
@staticmethod
|
||||
def _is_webui_session_key(key: str) -> bool:
|
||||
"""Return True when *key* belongs to the webui's websocket-only surface."""
|
||||
return key.startswith("websocket:")
|
||||
|
||||
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if self._session_manager is None:
|
||||
return _http_error(503, "session manager unavailable")
|
||||
decoded_key = _decode_api_key(key)
|
||||
if decoded_key is None:
|
||||
return _http_error(400, "invalid session key")
|
||||
# The embedded webui only understands websocket-channel sessions. Keep
|
||||
# its read surface aligned with ``/api/sessions`` instead of letting a
|
||||
# caller probe arbitrary CLI / Slack / Lark history by handcrafted URL.
|
||||
if not self._is_webui_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
data = self._session_manager.read_session_file(decoded_key)
|
||||
if data is None:
|
||||
return _http_error(404, "session not found")
|
||||
return _http_json_response(data)
|
||||
|
||||
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if self._session_manager is None:
|
||||
return _http_error(503, "session manager unavailable")
|
||||
decoded_key = _decode_api_key(key)
|
||||
if decoded_key is None:
|
||||
return _http_error(400, "invalid session key")
|
||||
# Same boundary as ``_handle_session_messages``: the webui may only
|
||||
# mutate websocket sessions, and deletion really does unlink the local
|
||||
# JSONL, so keep the blast radius narrow and explicit.
|
||||
if not self._is_webui_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
deleted = self._session_manager.delete_session(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
def _serve_static(self, request_path: str) -> Response | None:
|
||||
"""Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
|
||||
assert self._static_dist_path is not None
|
||||
rel = request_path.lstrip("/")
|
||||
if not rel:
|
||||
rel = "index.html"
|
||||
# Reject path-traversal attempts and absolute targets.
|
||||
if ".." in rel.split("/") or rel.startswith("/"):
|
||||
return _http_error(403, "Forbidden")
|
||||
candidate = (self._static_dist_path / rel).resolve()
|
||||
try:
|
||||
candidate.relative_to(self._static_dist_path)
|
||||
except ValueError:
|
||||
return _http_error(403, "Forbidden")
|
||||
if not candidate.is_file():
|
||||
# SPA history-mode fallback: unknown routes serve index.html so the
|
||||
# client-side router can render them.
|
||||
index = self._static_dist_path / "index.html"
|
||||
if index.is_file():
|
||||
candidate = index
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
body = candidate.read_bytes()
|
||||
except OSError as e:
|
||||
logger.warning("websocket static: failed to read {}: {}", candidate, e)
|
||||
return _http_error(500, "Internal Server Error")
|
||||
ctype, _ = mimetypes.guess_type(candidate.name)
|
||||
if ctype is None:
|
||||
ctype = "application/octet-stream"
|
||||
if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}:
|
||||
ctype = f"{ctype}; charset=utf-8"
|
||||
# Hash-named build assets are cache-friendly; index.html must stay fresh.
|
||||
if candidate.name == "index.html":
|
||||
cache = "no-cache"
|
||||
else:
|
||||
cache = "public, max-age=31536000, immutable"
|
||||
return _http_response(
|
||||
body,
|
||||
status=200,
|
||||
content_type=ctype,
|
||||
extra_headers=[("Cache-Control", cache)],
|
||||
)
|
||||
|
||||
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
|
||||
supplied = _query_first(query, "token")
|
||||
static_token = self.config.token.strip()
|
||||
@@ -355,24 +661,7 @@ class WebSocketChannel(BaseChannel):
|
||||
connection: ServerConnection,
|
||||
request: WsRequest,
|
||||
) -> Any:
|
||||
got, _ = _parse_request_path(request.path)
|
||||
if self.config.token_issue_path:
|
||||
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
||||
if got == issue_expected:
|
||||
return self._handle_token_issue_http(connection, request)
|
||||
|
||||
expected_ws = self._expected_path()
|
||||
if got != expected_ws:
|
||||
return connection.respond(404, "Not Found")
|
||||
# Early reject before WebSocket upgrade to avoid unnecessary overhead;
|
||||
# _handle_message() performs a second check as defense-in-depth.
|
||||
query = _parse_query(request.path)
|
||||
client_id = _query_first(query, "client_id") or ""
|
||||
if len(client_id) > 128:
|
||||
client_id = client_id[:128]
|
||||
if not self.is_allowed(client_id):
|
||||
return connection.respond(403, "Forbidden")
|
||||
return self._authorize_websocket_handshake(connection, query)
|
||||
return await self._dispatch_http(connection, request)
|
||||
|
||||
async def handler(connection: ServerConnection) -> None:
|
||||
await self._connection_loop(connection)
|
||||
@@ -523,6 +812,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
self._issued_tokens.clear()
|
||||
self._api_tokens.clear()
|
||||
|
||||
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
||||
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
||||
@@ -550,6 +840,13 @@ class WebSocketChannel(BaseChannel):
|
||||
payload["media"] = msg.media
|
||||
if msg.reply_to:
|
||||
payload["reply_to"] = msg.reply_to
|
||||
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||
# progress strings) so WS clients can render them as subordinate
|
||||
# trace rows rather than conversational replies.
|
||||
if msg.metadata.get("_tool_hint"):
|
||||
payload["kind"] = "tool_hint"
|
||||
elif msg.metadata.get("_progress"):
|
||||
payload["kind"] = "progress"
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
|
||||
+110
-10
@@ -636,6 +636,21 @@ def gateway(
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Start the nanobot gateway."""
|
||||
if verbose:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
cfg = _load_runtime_config(config, workspace)
|
||||
_run_gateway(cfg, port=port)
|
||||
|
||||
|
||||
def _run_gateway(
|
||||
config: Config,
|
||||
*,
|
||||
port: int | None = None,
|
||||
open_browser_url: str | None = None,
|
||||
) -> None:
|
||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
@@ -644,12 +659,6 @@ def gateway(
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
config = _load_runtime_config(config, workspace)
|
||||
port = port if port is not None else config.gateway.port
|
||||
|
||||
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
|
||||
@@ -749,8 +758,9 @@ def gateway(
|
||||
|
||||
cron.on_job = on_cron_job
|
||||
|
||||
# Create channel manager
|
||||
channels = ChannelManager(config, bus)
|
||||
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||
# can serve the embedded webui's REST surface).
|
||||
channels = ChannelManager(config, bus, session_manager=session_manager)
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||
@@ -881,15 +891,43 @@ def gateway(
|
||||
))
|
||||
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
||||
|
||||
async def _open_browser_when_ready() -> None:
|
||||
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
||||
if not open_browser_url:
|
||||
return
|
||||
import webbrowser
|
||||
# Channels start asynchronously; a short poll lets us avoid racing the bind.
|
||||
for _ in range(40): # ~4s max
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(
|
||||
config.gateway.host or "127.0.0.1", port
|
||||
)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
except OSError:
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
webbrowser.open(open_browser_url)
|
||||
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await cron.start()
|
||||
await heartbeat.start()
|
||||
await asyncio.gather(
|
||||
tasks = [
|
||||
agent.run(),
|
||||
channels.start_all(),
|
||||
_health_server(config.gateway.host, port),
|
||||
)
|
||||
]
|
||||
if open_browser_url:
|
||||
tasks.append(_open_browser_when_ready())
|
||||
await asyncio.gather(*tasks)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nShutting down...")
|
||||
except Exception:
|
||||
@@ -907,6 +945,68 @@ def gateway(
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def web(
|
||||
port: int | None = typer.Option(None, "--port", "-p", help="WebSocket port for the webui"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser when ready"),
|
||||
):
|
||||
"""Start the gateway with the embedded webui and (by default) open a browser."""
|
||||
if verbose:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
cfg = _load_runtime_config(config, workspace)
|
||||
|
||||
# Force the websocket channel on with token-gated auth so the webui is functional.
|
||||
# ``--port`` applies to the webui's websocket/HTTP port, not the gateway's
|
||||
# management port, since that's the only surface users visit.
|
||||
ws_section = cfg.channels.websocket
|
||||
if isinstance(ws_section, dict):
|
||||
ws_section.setdefault("host", "127.0.0.1")
|
||||
ws_section["enabled"] = True
|
||||
ws_section["websocketRequiresToken"] = True
|
||||
if port is not None:
|
||||
ws_section["port"] = port
|
||||
ws_host = ws_section.get("host", "127.0.0.1")
|
||||
ws_port = ws_section.get("port", 8765)
|
||||
ws_path = ws_section.get("path", "/")
|
||||
else:
|
||||
ws_section.enabled = True
|
||||
if hasattr(ws_section, "websocket_requires_token"):
|
||||
ws_section.websocket_requires_token = True
|
||||
if port is not None:
|
||||
ws_section.port = port
|
||||
ws_host = getattr(ws_section, "host", "127.0.0.1") or "127.0.0.1"
|
||||
ws_port = getattr(ws_section, "port", 8765)
|
||||
ws_path = getattr(ws_section, "path", "/") or "/"
|
||||
|
||||
# Confirm the bundled SPA exists before promising the user a browser launch.
|
||||
from nanobot.channels.manager import _default_webui_dist
|
||||
|
||||
dist = _default_webui_dist()
|
||||
if dist is None:
|
||||
console.print(
|
||||
"[yellow]Warning: webui assets not found at nanobot/web/dist/. "
|
||||
"Run `cd webui && bun install && bun run build` from a source checkout.[/yellow]"
|
||||
)
|
||||
|
||||
scheme = "http"
|
||||
# Browsers refuse cookies/JS on 0.0.0.0 — collapse to loopback for the visit URL.
|
||||
visit_host = "127.0.0.1" if ws_host in {"0.0.0.0", "::"} else ws_host
|
||||
open_url = f"{scheme}://{visit_host}:{ws_port}{ws_path if ws_path != '/' else ''}/"
|
||||
|
||||
# The gateway's management port is separate from the webui port; leave it
|
||||
# on its configured default so --port only moves the visible surface.
|
||||
_run_gateway(
|
||||
cfg,
|
||||
open_browser_url=open_url if open_browser else None,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent Commands
|
||||
# ============================================================================
|
||||
|
||||
@@ -106,15 +106,18 @@ class SessionManager:
|
||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||
self._cache: dict[str, Session] = {}
|
||||
|
||||
@staticmethod
|
||||
def safe_key(key: str) -> str:
|
||||
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
||||
return safe_filename(key.replace(":", "_"))
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
"""Get the file path for a session."""
|
||||
safe_key = safe_filename(key.replace(":", "_"))
|
||||
return self.sessions_dir / f"{safe_key}.jsonl"
|
||||
return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
|
||||
def _get_legacy_session_path(self, key: str) -> Path:
|
||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||
safe_key = safe_filename(key.replace(":", "_"))
|
||||
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
|
||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
|
||||
def get_or_create(self, key: str) -> Session:
|
||||
"""
|
||||
@@ -209,6 +212,61 @@ class SessionManager:
|
||||
"""Remove a session from the in-memory cache."""
|
||||
self._cache.pop(key, None)
|
||||
|
||||
def delete_session(self, key: str) -> bool:
|
||||
"""Remove a session from disk and the in-memory cache.
|
||||
|
||||
Returns True if a JSONL file was found and unlinked.
|
||||
"""
|
||||
path = self._get_session_path(key)
|
||||
self.invalidate(key)
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("Failed to delete session file {}: {}", path, e)
|
||||
return False
|
||||
|
||||
def read_session_file(self, key: str) -> dict[str, Any] | None:
|
||||
"""Load a session from disk without caching; intended for read-only HTTP endpoints.
|
||||
|
||||
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
|
||||
``None`` when the session file does not exist or fails to parse.
|
||||
"""
|
||||
path = self._get_session_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
messages: list[dict[str, Any]] = []
|
||||
metadata: dict[str, Any] = {}
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
stored_key: str | None = None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
data = json.loads(line)
|
||||
if data.get("_type") == "metadata":
|
||||
metadata = data.get("metadata", {})
|
||||
created_at = data.get("created_at")
|
||||
updated_at = data.get("updated_at")
|
||||
stored_key = data.get("key")
|
||||
else:
|
||||
messages.append(data)
|
||||
return {
|
||||
"key": stored_key or key,
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"metadata": metadata,
|
||||
"messages": messages,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read session {}: {}", key, e)
|
||||
return None
|
||||
|
||||
def list_sessions(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all sessions.
|
||||
|
||||
@@ -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/skills/**/*.md",
|
||||
"nanobot/skills/**/*.sh",
|
||||
"nanobot/web/dist/**/*",
|
||||
]
|
||||
|
||||
[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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
|
||||
"""``_tool_hint`` metadata must surface as ``kind: "tool_hint"`` so WS
|
||||
clients render breadcrumbs separately from conversational replies."""
|
||||
ch = _ch(bus, 29919)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
|
||||
ready = await c.recv_ready()
|
||||
# Plain reply: no "kind" field.
|
||||
await ch.send(OutboundMessage(
|
||||
channel="websocket", chat_id=ready.chat_id, content="hi",
|
||||
))
|
||||
plain = await c.recv_message()
|
||||
assert plain.raw.get("kind") is None
|
||||
|
||||
# Tool-hint breadcrumb: kind == "tool_hint".
|
||||
await ch.send(OutboundMessage(
|
||||
channel="websocket", chat_id=ready.chat_id,
|
||||
content='weather("get")',
|
||||
metadata={"_progress": True, "_tool_hint": True},
|
||||
))
|
||||
hint = await c.recv_message()
|
||||
assert hint.raw.get("kind") == "tool_hint"
|
||||
assert hint.text == 'weather("get")'
|
||||
|
||||
# Generic progress (non-tool-hint) gets the softer "progress" label.
|
||||
await ch.send(OutboundMessage(
|
||||
channel="websocket", chat_id=ready.chat_id,
|
||||
content="thinking…",
|
||||
metadata={"_progress": True},
|
||||
))
|
||||
prog = await c.recv_message()
|
||||
assert prog.raw.get("kind") == "progress"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29910)
|
||||
|
||||
@@ -1212,7 +1212,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
return None
|
||||
|
||||
class _FakeChannelManager:
|
||||
def __init__(self, _config, _bus) -> None:
|
||||
def __init__(self, _config, _bus, **_kwargs) -> None:
|
||||
self.enabled_channels = ["telegram", "discord"]
|
||||
|
||||
async def start_all(self) -> None:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,90 @@
|
||||
# nanobot webui
|
||||
|
||||
The browser front-end for `nanobot web`. It is built with Vite + React 18 +
|
||||
TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket
|
||||
multiplex protocol, and reads session metadata from the embedded REST surface
|
||||
on the same port.
|
||||
|
||||
For the project overview, install guide, and general docs map, see the root
|
||||
[`README.md`](../README.md).
|
||||
|
||||
## Current status
|
||||
|
||||
> [!NOTE]
|
||||
> The standalone WebUI development workflow currently requires a source
|
||||
> checkout.
|
||||
>
|
||||
> WebUI changes in the GitHub repository may land before they are included in
|
||||
> the next packaged release, so source installs and published package versions
|
||||
> are not yet guaranteed to move in lockstep.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
webui/ source tree (this directory)
|
||||
nanobot/web/dist/ build output consumed by `nanobot web`
|
||||
```
|
||||
|
||||
## Develop from source
|
||||
|
||||
### 1. Install nanobot from source
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 2. Start the gateway
|
||||
|
||||
In one terminal:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
### 3. Start the WebUI dev server
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
bun install # npm install also works
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:5173`.
|
||||
|
||||
By default, the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket
|
||||
traffic to `http://127.0.0.1:8765`.
|
||||
|
||||
If your gateway listens on a non-default port, point the dev server at it:
|
||||
|
||||
```bash
|
||||
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
|
||||
```
|
||||
|
||||
## Build for packaged runtime
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
bun run build
|
||||
```
|
||||
|
||||
This writes the production assets to `../nanobot/web/dist`, which is the
|
||||
directory served by `nanobot web` and bundled into the Python wheel.
|
||||
|
||||
If you are cutting a release, run the build before packaging so the published
|
||||
wheel contains the current WebUI assets.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
bun run test
|
||||
```
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [`agent-chat-ui`](https://github.com/langchain-ai/agent-chat-ui) for UI and
|
||||
interaction inspiration across the chat surface.
|
||||
+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,184 @@
|
||||
<!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."
|
||||
data-i18n-meta="description"
|
||||
/>
|
||||
<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>
|
||||
<script>
|
||||
(function () {
|
||||
var localeKey = "nanobot.locale";
|
||||
var copy = {
|
||||
en: {
|
||||
boot: "Loading nanobot…",
|
||||
description: "nanobot web UI — chat with your nanobot workspace."
|
||||
},
|
||||
"zh-CN": {
|
||||
boot: "正在加载 nanobot…",
|
||||
description: "nanobot Web UI —— 与你的 nanobot 工作区对话。"
|
||||
},
|
||||
"zh-TW": {
|
||||
boot: "正在載入 nanobot…",
|
||||
description: "nanobot Web UI —— 與你的 nanobot 工作區對話。"
|
||||
},
|
||||
fr: {
|
||||
boot: "Chargement de nanobot…",
|
||||
description: "Interface web nanobot — discutez avec votre espace de travail nanobot."
|
||||
},
|
||||
ja: {
|
||||
boot: "nanobot を読み込み中…",
|
||||
description: "nanobot Web UI — nanobot ワークスペースと会話します。"
|
||||
},
|
||||
ko: {
|
||||
boot: "nanobot 불러오는 중…",
|
||||
description: "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
|
||||
},
|
||||
es: {
|
||||
boot: "Cargando nanobot…",
|
||||
description: "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
|
||||
},
|
||||
vi: {
|
||||
boot: "Đang tải nanobot…",
|
||||
description: "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
|
||||
},
|
||||
id: {
|
||||
boot: "Memuat nanobot…",
|
||||
description: "UI web nanobot — ngobrol dengan workspace nanobot Anda."
|
||||
}
|
||||
};
|
||||
|
||||
function normalizeLocale(input) {
|
||||
if (!input) return "en";
|
||||
var raw = String(input).trim();
|
||||
if (!raw) return "en";
|
||||
if (copy[raw]) return raw;
|
||||
var lower = raw.toLowerCase();
|
||||
if (lower === "zh" || lower.indexOf("zh-cn") === 0 || lower.indexOf("zh-sg") === 0) {
|
||||
return "zh-CN";
|
||||
}
|
||||
if (
|
||||
lower.indexOf("zh-tw") === 0 ||
|
||||
lower.indexOf("zh-hk") === 0 ||
|
||||
lower.indexOf("zh-mo") === 0 ||
|
||||
lower.indexOf("zh-hant") === 0
|
||||
) {
|
||||
return "zh-TW";
|
||||
}
|
||||
var base = lower.split("-")[0];
|
||||
return copy[base] ? base : "en";
|
||||
}
|
||||
|
||||
try {
|
||||
var stored = localStorage.getItem(localeKey);
|
||||
var detected =
|
||||
stored ||
|
||||
(navigator.languages && navigator.languages[0]) ||
|
||||
navigator.language ||
|
||||
"en";
|
||||
var locale = normalizeLocale(detected);
|
||||
var localized = copy[locale] || copy.en;
|
||||
document.documentElement.lang = locale;
|
||||
var description = document.querySelector('[data-i18n-meta=\"description\"]');
|
||||
if (description) description.setAttribute("content", localized.description);
|
||||
var boot = document.querySelector("[data-boot-copy]");
|
||||
if (boot) boot.textContent = localized.boot;
|
||||
} 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 data-boot-copy>Loading nanobot…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "nanobot-webui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.0.6",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"happy-dom": "^16.3.0",
|
||||
"katex": "^0.16.21",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -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,324 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteConfirm } from "@/components/DeleteConfirm";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
type BootState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| {
|
||||
status: "ready";
|
||||
client: NanobotClient;
|
||||
token: string;
|
||||
modelName: string | null;
|
||||
};
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const SIDEBAR_WIDTH = 279;
|
||||
|
||||
function readSidebarOpen(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
if (raw === null) return true;
|
||||
return raw === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<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>
|
||||
{t("app.loading.connecting")}
|
||||
</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">{t("app.error.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{state.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("app.error.gatewayHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientProvider
|
||||
client={state.client}
|
||||
token={state.token}
|
||||
modelName={state.modelName}
|
||||
>
|
||||
<Shell />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { t, i18n } = useTranslation();
|
||||
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 ||
|
||||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
|
||||
: t("app.brand");
|
||||
|
||||
useEffect(() => {
|
||||
document.title = activeSession
|
||||
? t("app.documentTitle.chat", { title: headerTitle })
|
||||
: t("app.documentTitle.base");
|
||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t]);
|
||||
|
||||
const sidebarProps = {
|
||||
sessions,
|
||||
activeKey,
|
||||
loading,
|
||||
theme,
|
||||
onToggleTheme: toggle,
|
||||
onNewChat: () => {
|
||||
void onNewChat();
|
||||
},
|
||||
onSelect: onSelectChat,
|
||||
onRefresh: () => void refresh(),
|
||||
onRequestDelete: (key: string, label: string) =>
|
||||
setPendingDelete({ key, label }),
|
||||
};
|
||||
|
||||
return (
|
||||
<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"
|
||||
showCloseButton={false}
|
||||
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,116 @@
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { relativeTime } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function titleFor(s: ChatSummary, fallbackTitle: string): string {
|
||||
const p = s.preview?.trim();
|
||||
if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p;
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
||||
export function ChatList({
|
||||
sessions,
|
||||
activeKey,
|
||||
onSelect,
|
||||
onRequestDelete,
|
||||
loading,
|
||||
}: ChatListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||
{t("chat.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground">
|
||||
{t("chat.noSessions")}
|
||||
</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,
|
||||
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
|
||||
);
|
||||
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={t("chat.actions", { 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" />
|
||||
{t("chat.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,72 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeBlockProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = () => {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1_500);
|
||||
});
|
||||
};
|
||||
|
||||
const isDark =
|
||||
typeof window !== "undefined"
|
||||
? document.documentElement.classList.contains("dark")
|
||||
: true;
|
||||
|
||||
return (
|
||||
<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 || t("code.fallbackLanguage")}
|
||||
</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={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? t("code.copied") : t("code.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,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type { ConnectionStatus } from "@/lib/types";
|
||||
|
||||
const COPY: Record<ConnectionStatus, { color: string }> = {
|
||||
idle: { color: "bg-card/40 text-muted-foreground" },
|
||||
connecting: {
|
||||
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
open: {
|
||||
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||
},
|
||||
reconnecting: {
|
||||
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
closed: {
|
||||
color: "bg-card/40 text-muted-foreground",
|
||||
},
|
||||
error: {
|
||||
color: "bg-destructive/10 text-destructive",
|
||||
},
|
||||
};
|
||||
|
||||
export function ConnectionBadge() {
|
||||
const { t } = useTranslation();
|
||||
const { client } = useClient();
|
||||
const [status, setStatus] = useState<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>
|
||||
{t(`connection.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DeleteConfirmProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirm({
|
||||
open,
|
||||
title,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteConfirmProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("deleteConfirm.title", { title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>
|
||||
{t("deleteConfirm.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("deleteConfirm.confirm")}
|
||||
</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,67 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import {
|
||||
currentLocale,
|
||||
} from "@/i18n";
|
||||
import {
|
||||
localeOption,
|
||||
supportedLocales,
|
||||
type SupportedLocale,
|
||||
} from "@/i18n/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const locale = currentLocale();
|
||||
const selected = localeOption(locale);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t("sidebar.language.ariaLabel")}
|
||||
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[7rem] truncate">{selected.nativeLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("sidebar.language.label")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={locale}
|
||||
onValueChange={(value) => {
|
||||
void setAppLanguage(value as SupportedLocale);
|
||||
}}
|
||||
>
|
||||
{supportedLocales.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.code} value={option.code}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span>{option.nativeLabel}</span>
|
||||
{option.nativeLabel !== option.label ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{option.label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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,165 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single message. Following agent-chat-ui: user turns are a rounded
|
||||
* "pill" right-aligned with a muted fill; assistant turns render as bare
|
||||
* markdown so prose/code read like a document rather than a chat bubble.
|
||||
* Each turn fades+slides in for a touch of motion polish.
|
||||
*
|
||||
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
|
||||
* collapsible group so intermediate steps never masquerade as replies.
|
||||
*/
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
||||
|
||||
if (message.kind === "trace") {
|
||||
return <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() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t("message.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() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t("message.assistantTyping")}
|
||||
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 { t } = useTranslation();
|
||||
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
|
||||
? t("message.toolSingle")
|
||||
: t("message.toolMany", { count })}
|
||||
</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,91 @@
|
||||
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
interface SidebarProps {
|
||||
sessions: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
loading: boolean;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
onNewChat: () => void;
|
||||
onSelect: (key: string) => void;
|
||||
onRefresh: () => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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={t("sidebar.collapse")}
|
||||
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={t("sidebar.toggleTheme")}
|
||||
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" />
|
||||
{t("sidebar.newChat")}
|
||||
</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>{t("sidebar.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={t("sidebar.refreshSessions")}
|
||||
>
|
||||
<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 gap-2 px-2.5 py-2 text-xs">
|
||||
<ConnectionBadge />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
modelLabel?: string | null;
|
||||
variant?: "thread" | "hero";
|
||||
}
|
||||
|
||||
export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isHero = variant === "hero";
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? t("thread.composer.placeholderThread");
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const id = requestAnimationFrame(() => el.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [disabled]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = "auto";
|
||||
el.focus();
|
||||
}
|
||||
});
|
||||
}, [disabled, onSend, value]);
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<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={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
|
||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
<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">
|
||||
{t("thread.composer.sendHint")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="sm:hidden" aria-hidden />
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label={t("thread.composer.send")}
|
||||
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,54 @@
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThreadHeaderProps {
|
||||
title: string;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome: () => void;
|
||||
hideSidebarToggleOnDesktop?: boolean;
|
||||
}
|
||||
|
||||
export function ThreadHeader({
|
||||
title,
|
||||
onToggleSidebar,
|
||||
onGoHome,
|
||||
hideSidebarToggleOnDesktop = false,
|
||||
}: ThreadHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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={t("thread.header.toggleSidebar")}
|
||||
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,172 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
title: string;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome: () => void;
|
||||
onNewChat: () => Promise<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 { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading } = useSessionHistory(historyKey);
|
||||
const { client, modelName } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const pendingFirstRef = useRef<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">
|
||||
{t("thread.loadingConversation")}
|
||||
</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">
|
||||
{t("thread.empty.description")}
|
||||
</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
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
placeholder={
|
||||
booting
|
||||
? t("thread.composer.placeholderOpening")
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant="hero"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
|
||||
export function ThreadViewport({
|
||||
messages,
|
||||
isStreaming,
|
||||
composer,
|
||||
emptyState,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<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={t("thread.scrollToBottom")}
|
||||
>
|
||||
<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,113 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<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> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, showCloseButton = true, ...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}
|
||||
{showCloseButton ? (
|
||||
<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>
|
||||
) : null}
|
||||
</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,193 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import i18n from "@/i18n";
|
||||
import {
|
||||
ApiError,
|
||||
deleteSession as apiDeleteSession,
|
||||
fetchSessionMessages,
|
||||
listSessions,
|
||||
} from "@/lib/api";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||
|
||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||
|
||||
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||
export function useSessions(): {
|
||||
sessions: ChatSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<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: EMPTY_MESSAGES, loading: false, error: null };
|
||||
}
|
||||
|
||||
// Even before the effect above commits its loading state, never surface the
|
||||
// previous session's payload for a brand-new key.
|
||||
if (state.key !== key) {
|
||||
return { messages: EMPTY_MESSAGES, loading: true, error: null };
|
||||
}
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
|
||||
/** Produce a compact display title for a session. */
|
||||
export function sessionTitle(
|
||||
session: ChatSummary,
|
||||
firstUserMessage?: string,
|
||||
): string {
|
||||
return deriveTitle(
|
||||
firstUserMessage || session.preview,
|
||||
i18n.t("chat.newChat"),
|
||||
);
|
||||
}
|
||||
@@ -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 @@
|
||||
export const LOCALE_STORAGE_KEY = "nanobot.locale";
|
||||
|
||||
export const supportedLocales = [
|
||||
{ code: "en", label: "English", nativeLabel: "English" },
|
||||
{ code: "zh-CN", label: "Chinese (Simplified)", nativeLabel: "简体中文" },
|
||||
{ code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" },
|
||||
{ code: "fr", label: "French", nativeLabel: "Français" },
|
||||
{ code: "ja", label: "Japanese", nativeLabel: "日本語" },
|
||||
{ code: "ko", label: "Korean", nativeLabel: "한국어" },
|
||||
{ code: "es", label: "Spanish", nativeLabel: "Español" },
|
||||
{ code: "vi", label: "Vietnamese", nativeLabel: "Tiếng Việt" },
|
||||
{ code: "id", label: "Indonesian", nativeLabel: "Bahasa Indonesia" },
|
||||
] as const;
|
||||
|
||||
export type SupportedLocale = (typeof supportedLocales)[number]["code"];
|
||||
|
||||
export const defaultLocale: SupportedLocale = "en";
|
||||
export const fallbackLocale: SupportedLocale = "en";
|
||||
|
||||
export function normalizeLocale(
|
||||
input: string | null | undefined,
|
||||
): SupportedLocale {
|
||||
if (!input) return defaultLocale;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return defaultLocale;
|
||||
|
||||
const exact = supportedLocales.find((locale) => locale.code === trimmed);
|
||||
if (exact) return exact.code;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "zh" || lower.startsWith("zh-cn") || lower.startsWith("zh-sg")) {
|
||||
return "zh-CN";
|
||||
}
|
||||
if (
|
||||
lower.startsWith("zh-tw") ||
|
||||
lower.startsWith("zh-hk") ||
|
||||
lower.startsWith("zh-mo") ||
|
||||
lower.startsWith("zh-hant")
|
||||
) {
|
||||
return "zh-TW";
|
||||
}
|
||||
|
||||
const base = lower.split("-")[0];
|
||||
const baseMatch = supportedLocales.find(
|
||||
(locale) => locale.code.toLowerCase() === base,
|
||||
);
|
||||
return baseMatch?.code ?? defaultLocale;
|
||||
}
|
||||
|
||||
export function readStoredLocale(): SupportedLocale | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
return raw ? normalizeLocale(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectNavigatorLocale(): SupportedLocale {
|
||||
if (typeof navigator === "undefined") return defaultLocale;
|
||||
const candidates = [
|
||||
...(navigator.languages ?? []),
|
||||
navigator.language,
|
||||
].filter(Boolean);
|
||||
for (const locale of candidates) {
|
||||
const normalized = normalizeLocale(locale);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function resolveInitialLocale(): SupportedLocale {
|
||||
return readStoredLocale() ?? detectNavigatorLocale();
|
||||
}
|
||||
|
||||
export function persistLocale(locale: SupportedLocale): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDocumentLocale(locale: SupportedLocale): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
export function localeOption(locale: SupportedLocale) {
|
||||
return supportedLocales.find((entry) => entry.code === locale) ?? supportedLocales[0];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import {
|
||||
applyDocumentLocale,
|
||||
defaultLocale,
|
||||
fallbackLocale,
|
||||
LOCALE_STORAGE_KEY,
|
||||
normalizeLocale,
|
||||
persistLocale,
|
||||
resolveInitialLocale,
|
||||
type SupportedLocale,
|
||||
} from "./config";
|
||||
|
||||
import enCommon from "./locales/en/common.json";
|
||||
import zhCNCommon from "./locales/zh-CN/common.json";
|
||||
import zhTWCommon from "./locales/zh-TW/common.json";
|
||||
import frCommon from "./locales/fr/common.json";
|
||||
import jaCommon from "./locales/ja/common.json";
|
||||
import koCommon from "./locales/ko/common.json";
|
||||
import esCommon from "./locales/es/common.json";
|
||||
import viCommon from "./locales/vi/common.json";
|
||||
import idCommon from "./locales/id/common.json";
|
||||
|
||||
export const resources = {
|
||||
en: { common: enCommon },
|
||||
"zh-CN": { common: zhCNCommon },
|
||||
"zh-TW": { common: zhTWCommon },
|
||||
fr: { common: frCommon },
|
||||
ja: { common: jaCommon },
|
||||
ko: { common: koCommon },
|
||||
es: { common: esCommon },
|
||||
vi: { common: viCommon },
|
||||
id: { common: idCommon },
|
||||
} as const;
|
||||
|
||||
export function currentLocale(): SupportedLocale {
|
||||
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
|
||||
}
|
||||
|
||||
export async function setAppLanguage(locale: SupportedLocale): Promise<void> {
|
||||
await i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
void i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
lng: resolveInitialLocale(),
|
||||
fallbackLng: fallbackLocale,
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
returnNull: false,
|
||||
supportedLngs: Object.keys(resources),
|
||||
});
|
||||
}
|
||||
|
||||
const syncLocaleSideEffects = (language: string) => {
|
||||
const locale = normalizeLocale(language);
|
||||
applyDocumentLocale(locale);
|
||||
persistLocale(locale);
|
||||
};
|
||||
|
||||
syncLocaleSideEffects(currentLocale());
|
||||
i18n.on("languageChanged", syncLocaleSideEffects);
|
||||
|
||||
export { LOCALE_STORAGE_KEY };
|
||||
export default i18n;
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "Connecting to nanobot…",
|
||||
"boot": "Loading nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "Couldn't reach nanobot",
|
||||
"gatewayHint": "Make sure the gateway is running (`nanobot web`) and that this page is open on the same machine."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "nanobot web UI — chat with your nanobot workspace."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Collapse sidebar",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"newChat": "New chat",
|
||||
"recent": "Recent",
|
||||
"refreshSessions": "Refresh sessions",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"ariaLabel": "Change language"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "Chat {{id}}",
|
||||
"loading": "Loading…",
|
||||
"noSessions": "No sessions yet.",
|
||||
"actions": "Chat actions for {{title}}",
|
||||
"delete": "Delete",
|
||||
"newChat": "New chat"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete “{{title}}”?",
|
||||
"description": "The session file will be removed from disk. This cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Idle",
|
||||
"connecting": "Connecting…",
|
||||
"open": "Connected",
|
||||
"reconnecting": "Reconnecting…",
|
||||
"closed": "Disconnected",
|
||||
"error": "Connection error"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "Loading conversation…",
|
||||
"empty": {
|
||||
"description": "Ask questions, continue local work, or start a new thread."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Toggle sidebar"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Type your message…",
|
||||
"placeholderHero": "What's on your mind?",
|
||||
"placeholderOpening": "Opening a new chat…",
|
||||
"inputAria": "Message input",
|
||||
"sendHint": "Enter to send · Shift+Enter for newline",
|
||||
"send": "Send message"
|
||||
},
|
||||
"scrollToBottom": "Scroll to bottom"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "streaming",
|
||||
"assistantTyping": "Assistant is typing",
|
||||
"toolSingle": "Using a tool",
|
||||
"toolMany": "Used {{count}} tools"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copy code",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "Conectando con nanobot…",
|
||||
"boot": "Cargando nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "No se pudo conectar con nanobot",
|
||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot web`) y de que esta página esté abierta en la misma máquina."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Contraer barra lateral",
|
||||
"toggleTheme": "Cambiar tema",
|
||||
"newChat": "Nuevo chat",
|
||||
"recent": "Recientes",
|
||||
"refreshSessions": "Actualizar sesiones",
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"ariaLabel": "Cambiar idioma"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "Chat {{id}}",
|
||||
"loading": "Cargando…",
|
||||
"noSessions": "Todavía no hay sesiones.",
|
||||
"actions": "Acciones del chat {{title}}",
|
||||
"delete": "Eliminar",
|
||||
"newChat": "Nuevo chat"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "¿Eliminar “{{title}}”?",
|
||||
"description": "El archivo de sesión se eliminará del disco. Esta acción no se puede deshacer.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Inactivo",
|
||||
"connecting": "Conectando…",
|
||||
"open": "Conectado",
|
||||
"reconnecting": "Reconectando…",
|
||||
"closed": "Desconectado",
|
||||
"error": "Error de conexión"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "Cargando conversación…",
|
||||
"empty": {
|
||||
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Mostrar u ocultar la barra lateral"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Escribe tu mensaje…",
|
||||
"placeholderHero": "¿Qué tienes en mente?",
|
||||
"placeholderOpening": "Abriendo un nuevo chat…",
|
||||
"inputAria": "Entrada de mensaje",
|
||||
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
|
||||
"send": "Enviar mensaje"
|
||||
},
|
||||
"scrollToBottom": "Desplazarse al final"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "transmitiendo",
|
||||
"assistantTyping": "El asistente está escribiendo",
|
||||
"toolSingle": "Usando una herramienta",
|
||||
"toolMany": "Se usaron {{count}} herramientas"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "código",
|
||||
"copyAria": "Copiar código",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "Connexion à nanobot…",
|
||||
"boot": "Chargement de nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "Impossible de joindre nanobot",
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot web`) et que cette page est ouverte sur la même machine."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "Interface web nanobot — discutez avec votre espace de travail nanobot."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Réduire la barre latérale",
|
||||
"toggleTheme": "Changer de thème",
|
||||
"newChat": "Nouvelle discussion",
|
||||
"recent": "Récentes",
|
||||
"refreshSessions": "Actualiser les sessions",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"ariaLabel": "Changer de langue"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "Discussion {{id}}",
|
||||
"loading": "Chargement…",
|
||||
"noSessions": "Aucune session pour le moment.",
|
||||
"actions": "Actions de la discussion {{title}}",
|
||||
"delete": "Supprimer",
|
||||
"newChat": "Nouvelle discussion"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer « {{title}} » ?",
|
||||
"description": "Le fichier de session sera supprimé du disque. Cette action est irréversible.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Inactif",
|
||||
"connecting": "Connexion…",
|
||||
"open": "Connecté",
|
||||
"reconnecting": "Reconnexion…",
|
||||
"closed": "Déconnecté",
|
||||
"error": "Erreur de connexion"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "Chargement de la conversation…",
|
||||
"empty": {
|
||||
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Afficher ou masquer la barre latérale"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Saisissez votre message…",
|
||||
"placeholderHero": "Qu’avez-vous en tête ?",
|
||||
"placeholderOpening": "Ouverture d’une nouvelle discussion…",
|
||||
"inputAria": "Champ de message",
|
||||
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
|
||||
"send": "Envoyer le message"
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "en cours de génération",
|
||||
"assistantTyping": "L’assistant est en train d’écrire",
|
||||
"toolSingle": "Utilisation d’un outil",
|
||||
"toolMany": "{{count}} outils utilisés"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copier le code",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "Menghubungkan ke nanobot…",
|
||||
"boot": "Memuat nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "Tidak dapat menjangkau nanobot",
|
||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot web`) dan halaman ini dibuka pada mesin yang sama."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "UI web nanobot — ngobrol dengan workspace nanobot Anda."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Ciutkan sidebar",
|
||||
"toggleTheme": "Ganti tema",
|
||||
"newChat": "Obrolan baru",
|
||||
"recent": "Terbaru",
|
||||
"refreshSessions": "Segarkan sesi",
|
||||
"language": {
|
||||
"label": "Bahasa",
|
||||
"ariaLabel": "Ganti bahasa"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "Obrolan {{id}}",
|
||||
"loading": "Memuat…",
|
||||
"noSessions": "Belum ada sesi.",
|
||||
"actions": "Aksi obrolan untuk {{title}}",
|
||||
"delete": "Hapus",
|
||||
"newChat": "Obrolan baru"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Hapus “{{title}}”?",
|
||||
"description": "File sesi akan dihapus dari disk. Tindakan ini tidak dapat dibatalkan.",
|
||||
"cancel": "Batal",
|
||||
"confirm": "Hapus"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Idle",
|
||||
"connecting": "Menghubungkan…",
|
||||
"open": "Terhubung",
|
||||
"reconnecting": "Menyambung ulang…",
|
||||
"closed": "Terputus",
|
||||
"error": "Kesalahan koneksi"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "Memuat percakapan…",
|
||||
"empty": {
|
||||
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Ketik pesan Anda…",
|
||||
"placeholderHero": "Apa yang sedang Anda pikirkan?",
|
||||
"placeholderOpening": "Membuka obrolan baru…",
|
||||
"inputAria": "Input pesan",
|
||||
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
|
||||
"send": "Kirim pesan"
|
||||
},
|
||||
"scrollToBottom": "Gulir ke bawah"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "sedang mengalir",
|
||||
"assistantTyping": "Asisten sedang mengetik",
|
||||
"toolSingle": "Menggunakan sebuah alat",
|
||||
"toolMany": "Menggunakan {{count}} alat"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "kode",
|
||||
"copyAria": "Salin kode",
|
||||
"copy": "Salin",
|
||||
"copied": "Tersalin"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "nanobot に接続中…",
|
||||
"boot": "nanobot を読み込み中…"
|
||||
},
|
||||
"error": {
|
||||
"title": "nanobot に接続できませんでした",
|
||||
"gatewayHint": "gateway(`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "nanobot Web UI — nanobot ワークスペースと会話します。"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "サイドバーを閉じる",
|
||||
"toggleTheme": "テーマを切り替える",
|
||||
"newChat": "新しいチャット",
|
||||
"recent": "最近のチャット",
|
||||
"refreshSessions": "セッションを更新",
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"ariaLabel": "言語を変更"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "チャット {{id}}",
|
||||
"loading": "読み込み中…",
|
||||
"noSessions": "まだセッションがありません。",
|
||||
"actions": "「{{title}}」のチャット操作",
|
||||
"delete": "削除",
|
||||
"newChat": "新しいチャット"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "「{{title}}」を削除しますか?",
|
||||
"description": "セッションファイルはディスクから削除されます。この操作は元に戻せません。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "削除"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "待機中",
|
||||
"connecting": "接続中…",
|
||||
"open": "接続済み",
|
||||
"reconnecting": "再接続中…",
|
||||
"closed": "切断済み",
|
||||
"error": "接続エラー"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "会話を読み込み中…",
|
||||
"empty": {
|
||||
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "サイドバーを切り替える"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "メッセージを入力…",
|
||||
"placeholderHero": "何を考えていますか?",
|
||||
"placeholderOpening": "新しいチャットを開いています…",
|
||||
"inputAria": "メッセージ入力欄",
|
||||
"sendHint": "Enter で送信 · Shift+Enter で改行",
|
||||
"send": "メッセージを送信"
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "生成中",
|
||||
"assistantTyping": "アシスタントが入力中",
|
||||
"toolSingle": "ツールを使用中",
|
||||
"toolMany": "{{count}} 個のツールを使用"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "コード",
|
||||
"copyAria": "コードをコピー",
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "nanobot에 연결 중…",
|
||||
"boot": "nanobot 불러오는 중…"
|
||||
},
|
||||
"error": {
|
||||
"title": "nanobot에 연결할 수 없습니다",
|
||||
"gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "사이드바 접기",
|
||||
"toggleTheme": "테마 전환",
|
||||
"newChat": "새 채팅",
|
||||
"recent": "최근 대화",
|
||||
"refreshSessions": "세션 새로고침",
|
||||
"language": {
|
||||
"label": "언어",
|
||||
"ariaLabel": "언어 변경"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "채팅 {{id}}",
|
||||
"loading": "불러오는 중…",
|
||||
"noSessions": "아직 세션이 없습니다.",
|
||||
"actions": "{{title}} 채팅 작업",
|
||||
"delete": "삭제",
|
||||
"newChat": "새 채팅"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "“{{title}}”을(를) 삭제할까요?",
|
||||
"description": "세션 파일이 디스크에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "삭제"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "대기 중",
|
||||
"connecting": "연결 중…",
|
||||
"open": "연결됨",
|
||||
"reconnecting": "재연결 중…",
|
||||
"closed": "연결 끊김",
|
||||
"error": "연결 오류"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "대화 불러오는 중…",
|
||||
"empty": {
|
||||
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "사이드바 전환"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "메시지를 입력하세요…",
|
||||
"placeholderHero": "무슨 생각을 하고 있나요?",
|
||||
"placeholderOpening": "새 채팅을 여는 중…",
|
||||
"inputAria": "메시지 입력",
|
||||
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
|
||||
"send": "메시지 보내기"
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "생성 중",
|
||||
"assistantTyping": "도우미가 입력 중",
|
||||
"toolSingle": "도구 사용 중",
|
||||
"toolMany": "도구 {{count}}개 사용됨"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "코드",
|
||||
"copyAria": "코드 복사",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "Đang kết nối tới nanobot…",
|
||||
"boot": "Đang tải nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "Không thể kết nối tới nanobot",
|
||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot web`) và trang này được mở trên cùng máy."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
"toggleTheme": "Chuyển giao diện",
|
||||
"newChat": "Cuộc trò chuyện mới",
|
||||
"recent": "Gần đây",
|
||||
"refreshSessions": "Làm mới phiên",
|
||||
"language": {
|
||||
"label": "Ngôn ngữ",
|
||||
"ariaLabel": "Đổi ngôn ngữ"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "Trò chuyện {{id}}",
|
||||
"loading": "Đang tải…",
|
||||
"noSessions": "Chưa có phiên nào.",
|
||||
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
|
||||
"delete": "Xóa",
|
||||
"newChat": "Cuộc trò chuyện mới"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Xóa “{{title}}”?",
|
||||
"description": "Tệp phiên sẽ bị xóa khỏi đĩa. Không thể hoàn tác thao tác này.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xóa"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Rảnh",
|
||||
"connecting": "Đang kết nối…",
|
||||
"open": "Đã kết nối",
|
||||
"reconnecting": "Đang kết nối lại…",
|
||||
"closed": "Đã ngắt kết nối",
|
||||
"error": "Lỗi kết nối"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "Đang tải cuộc trò chuyện…",
|
||||
"empty": {
|
||||
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "Bật/tắt thanh bên"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "Nhập tin nhắn…",
|
||||
"placeholderHero": "Bạn đang nghĩ gì?",
|
||||
"placeholderOpening": "Đang mở cuộc trò chuyện mới…",
|
||||
"inputAria": "Ô nhập tin nhắn",
|
||||
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
|
||||
"send": "Gửi tin nhắn"
|
||||
},
|
||||
"scrollToBottom": "Cuộn xuống cuối"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "đang truyền",
|
||||
"assistantTyping": "Trợ lý đang nhập",
|
||||
"toolSingle": "Đang dùng một công cụ",
|
||||
"toolMany": "Đã dùng {{count}} công cụ"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "mã",
|
||||
"copyAria": "Sao chép mã",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "正在连接 nanobot…",
|
||||
"boot": "正在加载 nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "无法连接到 nanobot",
|
||||
"gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "nanobot Web UI —— 与你的 nanobot 工作区对话。"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收起侧边栏",
|
||||
"toggleTheme": "切换主题",
|
||||
"newChat": "新建对话",
|
||||
"recent": "最近对话",
|
||||
"refreshSessions": "刷新会话",
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"ariaLabel": "切换语言"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "对话 {{id}}",
|
||||
"loading": "加载中…",
|
||||
"noSessions": "还没有会话。",
|
||||
"actions": "“{{title}}” 的会话操作",
|
||||
"delete": "删除",
|
||||
"newChat": "新建对话"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "删除“{{title}}”?",
|
||||
"description": "这个会话文件会从磁盘中删除,且无法撤销。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "空闲",
|
||||
"connecting": "连接中…",
|
||||
"open": "已连接",
|
||||
"reconnecting": "重连中…",
|
||||
"closed": "已断开",
|
||||
"error": "连接出错"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "正在加载对话…",
|
||||
"empty": {
|
||||
"description": "可以提问、继续本地工作,或者开启一个新线程。"
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "切换侧边栏"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "输入消息…",
|
||||
"placeholderHero": "你在想什么?",
|
||||
"placeholderOpening": "正在打开新对话…",
|
||||
"inputAria": "消息输入框",
|
||||
"sendHint": "Enter 发送 · Shift+Enter 换行",
|
||||
"send": "发送消息"
|
||||
},
|
||||
"scrollToBottom": "滚动到底部"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "流式输出中",
|
||||
"assistantTyping": "助手正在输入",
|
||||
"toolSingle": "正在使用工具",
|
||||
"toolMany": "已使用 {{count}} 个工具"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "代码",
|
||||
"copyAria": "复制代码",
|
||||
"copy": "复制",
|
||||
"copied": "已复制"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "nanobot",
|
||||
"loading": {
|
||||
"connecting": "正在連線到 nanobot…",
|
||||
"boot": "正在載入 nanobot…"
|
||||
},
|
||||
"error": {
|
||||
"title": "無法連線到 nanobot",
|
||||
"gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
},
|
||||
"meta": {
|
||||
"description": "nanobot Web UI —— 與你的 nanobot 工作區對話。"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收合側邊欄",
|
||||
"toggleTheme": "切換主題",
|
||||
"newChat": "新增對話",
|
||||
"recent": "最近對話",
|
||||
"refreshSessions": "重新整理會話",
|
||||
"language": {
|
||||
"label": "語言",
|
||||
"ariaLabel": "切換語言"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "對話 {{id}}",
|
||||
"loading": "載入中…",
|
||||
"noSessions": "目前還沒有會話。",
|
||||
"actions": "「{{title}}」的會話操作",
|
||||
"delete": "刪除",
|
||||
"newChat": "新增對話"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "刪除「{{title}}」?",
|
||||
"description": "這個會話檔案會從磁碟中移除,而且無法復原。",
|
||||
"cancel": "取消",
|
||||
"confirm": "刪除"
|
||||
},
|
||||
"connection": {
|
||||
"idle": "閒置",
|
||||
"connecting": "連線中…",
|
||||
"open": "已連線",
|
||||
"reconnecting": "重新連線中…",
|
||||
"closed": "已中斷",
|
||||
"error": "連線錯誤"
|
||||
},
|
||||
"thread": {
|
||||
"loadingConversation": "正在載入對話…",
|
||||
"empty": {
|
||||
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
|
||||
},
|
||||
"header": {
|
||||
"toggleSidebar": "切換側邊欄"
|
||||
},
|
||||
"composer": {
|
||||
"placeholderThread": "輸入訊息…",
|
||||
"placeholderHero": "你在想什麼?",
|
||||
"placeholderOpening": "正在開啟新對話…",
|
||||
"inputAria": "訊息輸入框",
|
||||
"sendHint": "Enter 送出 · Shift+Enter 換行",
|
||||
"send": "送出訊息"
|
||||
},
|
||||
"scrollToBottom": "捲動到底部"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "串流輸出中",
|
||||
"assistantTyping": "助理正在輸入",
|
||||
"toolSingle": "正在使用工具",
|
||||
"toolMany": "已使用 {{count}} 個工具"
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "程式碼",
|
||||
"copyAria": "複製程式碼",
|
||||
"copy": "複製",
|
||||
"copied": "已複製"
|
||||
}
|
||||
}
|
||||
@@ -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,77 @@
|
||||
import i18n, { currentLocale } from "@/i18n";
|
||||
|
||||
/** Truncate the first user message into a chat title. */
|
||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||
if (!preview) return fallback;
|
||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||
if (!oneLine) return fallback;
|
||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||
}
|
||||
|
||||
/** Loose ISO-or-epoch parser; returns ``null`` for missing/invalid input. */
|
||||
function parseDate(value: string | number | null | undefined): Date | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[7, "day"],
|
||||
[4.345, "week"],
|
||||
[12, "month"],
|
||||
[Number.POSITIVE_INFINITY, "year"],
|
||||
];
|
||||
|
||||
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function activeLocale(locale?: string): string {
|
||||
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
|
||||
}
|
||||
|
||||
function relativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
|
||||
const existing = relativeTimeFormatters.get(locale);
|
||||
if (existing) return existing;
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
relativeTimeFormatters.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
|
||||
const existing = dateTimeFormatters.get(locale);
|
||||
if (existing) return existing;
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
dateTimeFormatters.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
export function relativeTime(
|
||||
value: string | number | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
const date = parseDate(value);
|
||||
if (!date) return "";
|
||||
let delta = (date.getTime() - Date.now()) / 1000;
|
||||
const formatter = relativeTimeFormatter(activeLocale(locale));
|
||||
for (const [step, unit] of RELATIVE_THRESHOLDS) {
|
||||
if (Math.abs(delta) < step) {
|
||||
return formatter.format(Math.round(delta), unit);
|
||||
}
|
||||
delta /= step;
|
||||
}
|
||||
return formatter.format(Math.round(delta), "year");
|
||||
}
|
||||
|
||||
export function fmtDateTime(
|
||||
value: string | number | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
const date = parseDate(value);
|
||||
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
||||
}
|
||||
@@ -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,15 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./globals.css";
|
||||
import "./i18n";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("root element missing");
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<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");
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||
|
||||
describe("localized format helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-18T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("formats relative time using the active locale", async () => {
|
||||
const value = "2026-04-18T11:59:00Z";
|
||||
|
||||
await setAppLanguage("en");
|
||||
const english = relativeTime(value);
|
||||
|
||||
await setAppLanguage("zh-CN");
|
||||
const chinese = relativeTime(value);
|
||||
|
||||
expect(english).toBe(
|
||||
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
|
||||
-1,
|
||||
"minute",
|
||||
),
|
||||
);
|
||||
expect(chinese).toBe(
|
||||
new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }).format(
|
||||
-1,
|
||||
"minute",
|
||||
),
|
||||
);
|
||||
expect(english).not.toBe(chinese);
|
||||
});
|
||||
|
||||
it("formats date-time using the active locale", async () => {
|
||||
const value = "2026-04-18T08:30:00Z";
|
||||
const date = new Date(value);
|
||||
|
||||
await setAppLanguage("en");
|
||||
const english = fmtDateTime(value);
|
||||
|
||||
await setAppLanguage("fr");
|
||||
const french = fmtDateTime(value);
|
||||
|
||||
expect(english).toBe(
|
||||
new Intl.DateTimeFormat("en", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date),
|
||||
);
|
||||
expect(french).toBe(
|
||||
new Intl.DateTimeFormat("fr", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date),
|
||||
);
|
||||
expect(english).not.toBe(french);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
|
||||
describe("webui i18n", () => {
|
||||
it("switches UI copy and document locale through the language switcher", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
<ThreadComposer onSend={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText("Type your message…"),
|
||||
).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Change language" }));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /简体中文/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
});
|
||||
expect(localStorage.getItem("nanobot.locale")).toBe("zh-CN");
|
||||
expect(screen.getByPlaceholderText("输入消息…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the composer aria label when the language changes", async () => {
|
||||
render(<ThreadComposer onSend={vi.fn()} />);
|
||||
|
||||
await act(async () => {
|
||||
const { setAppLanguage } = await import("@/i18n");
|
||||
await setAppLanguage("ja");
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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,24 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
|
||||
// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
|
||||
if (!("randomUUID" in globalThis.crypto)) {
|
||||
Object.defineProperty(globalThis.crypto, "randomUUID", {
|
||||
value: () =>
|
||||
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
document.documentElement.lang = "en";
|
||||
document.title = "nanobot";
|
||||
localStorage.setItem("nanobot.locale", "en");
|
||||
});
|
||||
@@ -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