diff --git a/README.md b/README.md index 19ac7ff..4026899 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ By default, workspace metadata and layout are stored in: ~/.hermes/visual-workspace/workspaces.sqlite3 ``` -Hermes remains the canonical store for conversation history. The app database stores only application ownership, node mappings, rendered documents and layout. +Hermes remains the canonical live store for conversation history. The app database keeps application ownership, node mappings, rendered documents, layout, **completed session snapshots**, and queued follow-ups locally. Every completed owned session is snapshotted into this SQLite database; use **Export archive** in the toolbar to download the active Rabbithole, its local snapshots, and any queued follow-ups as JSON. Back up the SQLite database before destructive maintenance. Uninstalling the service does not remove data. diff --git a/backend/attachments.py b/backend/attachments.py new file mode 100644 index 0000000..395b200 --- /dev/null +++ b/backend/attachments.py @@ -0,0 +1,346 @@ +"""Attachment storage, classification and multipart parsing. + +Mirrors the contract Hermes core uses for platform attachments +(``gateway/platforms/base.py::cache_media_bytes``): images become inline +``image_url`` content parts so the model actually sees them, while every other +file kind is written to disk and surfaced to the agent as a path note it can +open with its own tools. The Hermes session API rejects ``input_file`` parts +outright, so the path-note route is the only way non-image files can reach a +session. +""" + +from __future__ import annotations + +import base64 +import mimetypes +import os +import re +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +# Matches the Telegram adapter's ``_max_doc_bytes`` default so uploads here +# behave like uploads on every other Hermes platform. +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 +MAX_ATTACHMENTS_PER_TURN = 10 + +# Images the model can see inline. Kept deliberately narrow: these are the +# types the upstream vision path accepts as ``data:image/...`` URLs. +IMAGE_TYPES = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +} +VIDEO_TYPES = { + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", +} +AUDIO_TYPES = { + ".ogg": "audio/ogg", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".m4a": "audio/mp4", + ".opus": "audio/opus", + ".flac": "audio/flac", +} + +_UNSAFE_NAME = re.compile(r"[^\w.\- ]") + + +class AttachmentError(ValueError): + """Raised when an upload cannot be accepted.""" + + +def safe_filename(raw: object) -> str: + """Reduce a browser-supplied filename to a safe display name. + + Strips any directory component, normalises unicode and removes characters + outside a conservative allowlist. Never returns an empty string, and never + returns a name that could traverse out of the upload directory. + """ + text = raw if isinstance(raw, str) else "" + text = unicodedata.normalize("NFKC", text) + # Windows and POSIX separators both, before basename. + text = text.replace("\\", "/").split("/")[-1] + text = _UNSAFE_NAME.sub("_", text).strip().strip(".") + if not text: + return "upload.bin" + return text[:255] + + +def classify(filename: str, declared_type: str = "") -> tuple[str, str]: + """Return ``(kind, media_type)`` for an upload. + + Classification is permissive by design. Hermes core notes that "the + file-extension allowlist must not silently drop their uploads", so an + unrecognised type is stored as a document with an octet-stream MIME rather + than being rejected. + """ + ext = os.path.splitext(filename)[1].lower() + declared = (declared_type or "").split(";")[0].strip().lower() + + if ext in IMAGE_TYPES: + return "image", IMAGE_TYPES[ext] + if ext in VIDEO_TYPES: + return "video", VIDEO_TYPES[ext] + if ext in AUDIO_TYPES: + return "audio", AUDIO_TYPES[ext] + + # Trust a declared image type only when it is one we can actually inline. + if declared in set(IMAGE_TYPES.values()): + return "image", declared + if declared.startswith("video/"): + return "video", declared + if declared.startswith("audio/"): + return "audio", declared + + guessed = declared or (mimetypes.guess_type(filename)[0] or "") + return "document", guessed or "application/octet-stream" + + +@dataclass +class UploadedPart: + """One file field decoded from a multipart body.""" + + filename: str + content_type: str + data: bytes + + +def _split_headers(chunk: bytes) -> tuple[dict[str, str], bytes]: + head, _, body = chunk.partition(b"\r\n\r\n") + headers: dict[str, str] = {} + for line in head.split(b"\r\n"): + name, sep, value = line.partition(b":") + if sep: + headers[name.decode("latin-1").strip().lower()] = value.decode("latin-1").strip() + return headers, body + + +def _header_param(value: str, key: str) -> str: + # RFC 2183 style: name="value". Quoted form first, then bare token. + match = re.search(rf'{key}="([^"]*)"', value) + if match: + return match.group(1) + match = re.search(rf"{key}=([^;]+)", value) + return match.group(1).strip() if match else "" + + +def parse_multipart(body: bytes, content_type: str) -> Iterator[UploadedPart]: + """Yield file parts from a ``multipart/form-data`` body. + + Implemented directly because the backend is Python-stdlib-only and + ``cgi.FieldStorage`` was removed in 3.13. Only parts carrying a filename + are returned; plain text fields are ignored. + """ + boundary = _header_param(content_type, "boundary") + if not boundary: + raise AttachmentError("multipart body is missing its boundary") + + delimiter = b"--" + boundary.encode("latin-1") + segments = body.split(delimiter) + # First segment is the preamble, last is the closing "--" epilogue. + for segment in segments[1:]: + if segment[:2] == b"--": + break + segment = segment.lstrip(b"\r\n") + if not segment: + continue + headers, payload = _split_headers(segment) + disposition = headers.get("content-disposition", "") + if "filename" not in disposition: + continue + # Each part's payload is terminated by the CRLF preceding the next + # delimiter, which the split leaves attached. + if payload.endswith(b"\r\n"): + payload = payload[:-2] + filename = _header_param(disposition, "filename") + if not filename: + continue + yield UploadedPart( + filename=filename, + content_type=headers.get("content-type", ""), + data=payload, + ) + + +class AttachmentStorage: + """Writes attachment bytes under a per-node directory.""" + + def __init__(self, root: str | Path): + self.root = Path(root).expanduser().resolve() + + def _node_dir(self, node_id: str) -> Path: + return self.root / node_id + + def write(self, node_id: str, attachment_id: str, filename: str, data: bytes) -> Path: + """Persist bytes and return the absolute path written. + + The stored basename is derived from the attachment id, not the + user-supplied filename, so a hostile name cannot collide with or + overwrite another upload. The display name lives in SQLite. + """ + if len(data) > MAX_ATTACHMENT_BYTES: + raise AttachmentError("file exceeds the maximum upload size") + ext = os.path.splitext(safe_filename(filename))[1][:16] + directory = self._node_dir(node_id) + directory.mkdir(parents=True, exist_ok=True) + target = directory / f"{attachment_id}{ext}" + # Written 0600: uploads may contain private documents. + handle = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(handle, data) + finally: + os.close(handle) + return target + + def resolve(self, storage_path: str) -> Path: + """Return a path guaranteed to sit inside the upload root.""" + candidate = Path(storage_path).expanduser().resolve() + if candidate != self.root and self.root not in candidate.parents: + raise AttachmentError("attachment path escapes the upload root") + return candidate + + def move_to_node(self, storage_path: str, node_id: str) -> Path: + """Relocate an existing upload into another node's directory. + + Used when a pending attachment is handed from a parent node to the + child branch that actually sends it: the bytes must follow the row so + the per-node directory stays the single place a node's files live, and + so ``remove_node`` on the parent cannot delete a file the child owns. + + The basename is preserved (it is the attachment id, not user input) and + the source is resolved through ``resolve`` first, so a tampered + ``storage_path`` cannot make this read or write outside the root. If + the file is already in the right place, or its bytes have gone missing, + the existing path is returned rather than raising -- losing the move + must not fail the branch that triggered it. + """ + source = self.resolve(storage_path) + directory = self._node_dir(node_id) + target = directory / source.name + if source == target or not source.exists(): + return source + directory.mkdir(parents=True, exist_ok=True) + os.replace(source, target) + return target + + def remove(self, storage_path: str) -> None: + try: + self.resolve(storage_path).unlink(missing_ok=True) + except (AttachmentError, OSError): + # Losing the bytes must never fail the API call that deletes the row. + pass + + def remove_node(self, node_id: str) -> None: + directory = self._node_dir(node_id) + if not directory.exists(): + return + for child in directory.iterdir(): + try: + child.unlink() + except OSError: + pass + try: + directory.rmdir() + except OSError: + pass + + +def agent_visible_path(storage_path: str, translation: str = "") -> str: + """Map a backend path to the path the agent sees. + + The workspace backend and the Hermes agent can run on opposite sides of a + container boundary, where the same bytes are reachable at two different + absolute paths. ``translation`` (``HERMES_VISUAL_AGENT_PATH_MAP``) describes + that relationship as ``from:to`` -- e.g. ``/root/.hermes:/opt/data`` when the + agent's ``/opt/data`` is a bind mount of the host's ``/root/.hermes``. A bare + value with no colon is treated as a prefix to prepend, which covers the + simpler case of the agent seeing the host tree under a mount point. + + Empty means both processes share one filesystem view and paths pass through. + """ + if not translation: + return storage_path + source, separator, target = translation.partition(":") + if not separator: + return translation.rstrip("/") + storage_path + source = source.rstrip("/") + if storage_path == source or storage_path.startswith(source + "/"): + return target.rstrip("/") + storage_path[len(source):] + return storage_path + + +def context_note(kind: str, filename: str, agent_path: str) -> str: + """One-line note pointing the agent at an uploaded file. + + Deliberately identical in shape to ``CachedMedia.context_note`` in Hermes + core so the agent sees the exact phrasing it already handles from Telegram + and the other platform adapters. + """ + return f"[{kind} '{filename}' saved at: {agent_path}]" + + +def data_url(media_type: str, data: bytes) -> str: + return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}" + + +def build_chat_input( + prompt: str, + attachments: list[dict[str, Any]], + storage: AttachmentStorage, + translation: str = "", +) -> Any: + """Compose the Hermes ``input`` payload for a turn with attachments. + + Images become ``image_url`` parts carrying a base64 data URL, which is the + only inline form the session API accepts. Non-image attachments cannot be + inlined at all, so their location is appended to the text as a note. + Returns a plain string when there is nothing to attach, keeping the common + case byte-identical to the previous behaviour. + """ + if not attachments: + return prompt + + notes: list[str] = [] + image_parts: list[dict[str, Any]] = [] + + for attachment in attachments: + kind = attachment.get("kind", "document") + filename = attachment.get("filename", "file") + stored = attachment.get("storage_path", "") + if kind == "image": + try: + payload = storage.resolve(stored).read_bytes() + except (AttachmentError, OSError): + notes.append(f"[image '{filename}' could not be read from disk]") + continue + image_parts.append( + { + "type": "image_url", + "image_url": { + "url": data_url(attachment.get("media_type", "image/png"), payload) + }, + } + ) + # Named so the agent can refer to the image, and so a later + # tool-based re-read can find the same bytes on disk. + notes.append( + context_note("image", filename, agent_visible_path(stored, translation)) + ) + else: + notes.append( + context_note(kind, filename, agent_visible_path(stored, translation)) + ) + + text = prompt + if notes: + text = (prompt + "\n\n" + "\n".join(notes)).strip() + if not image_parts: + return text + return [{"type": "text", "text": text}, *image_parts] diff --git a/backend/hermes_client.py b/backend/hermes_client.py index e6ff662..405f7ef 100644 --- a/backend/hermes_client.py +++ b/backend/hermes_client.py @@ -4,6 +4,7 @@ import json import os +import re import socket import urllib.error import urllib.parse @@ -136,6 +137,20 @@ def _first_text(value: object) -> str | None: return None +def _with_run_id(result: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + """Carry the upstream run id onto a normalized event when present. + + Hermes core stamps run_id onto every SSE payload. It is the only handle + that can interrupt an in-flight turn, so it is preserved here rather than + discarded with the rest of the raw payload. It stays server-side: the HTTP + layer strips it before writing events to the browser. + """ + run_id = payload.get("run_id") + if isinstance(run_id, str) and run_id.strip(): + result["run_id"] = run_id.strip()[:200] + return result + + def normalize_hermes_event( event_name: str, payload: dict[str, Any], node_id: str ) -> tuple[str, dict[str, Any]] | None: @@ -147,7 +162,7 @@ def normalize_hermes_event( name = embedded.lower().replace("_", ".") if name in {"heartbeat", "ping", "keepalive"}: - return "heartbeat", {"node_id": node_id} + return "heartbeat", _with_run_id({"node_id": node_id}, payload) if name in { "assistant.delta", @@ -162,7 +177,9 @@ def normalize_hermes_event( if delta is None and isinstance(payload.get("choices"), list) and payload["choices"]: delta = _first_text(payload["choices"][0]) if delta: - return "assistant.delta", {"node_id": node_id, "delta": delta} + return "assistant.delta", _with_run_id( + {"node_id": node_id, "delta": delta}, payload + ) return None if name in {"tool.started", "tool.start"} or ( @@ -178,7 +195,7 @@ def normalize_hermes_event( result["id"] = str(tool_id) if preview is not None: result["preview"] = str(preview)[:1000] - return "tool.started", result + return "tool.started", _with_run_id(result, payload) if name in {"tool.completed", "tool.complete", "tool.failed"} or ( name == "tool.progress" and payload.get("status") in {"completed", "failed", "error"} @@ -194,7 +211,7 @@ def normalize_hermes_event( result["id"] = str(tool_id) if preview is not None: result["preview"] = str(preview)[:1000] - return "tool.completed", result + return "tool.completed", _with_run_id(result, payload) if name in {"assistant.completed", "message.completed", "response.completed"}: content = _first_text(payload.get("content")) @@ -207,6 +224,9 @@ def normalize_hermes_event( if name in {"run.completed", "done"}: content = _first_text(payload.get("content")) + if content is None: + # The runs API carries the final assistant text as ``output``. + content = _first_text(payload.get("output")) if content is None and isinstance(payload.get("messages"), list): for message in reversed(payload["messages"]): if isinstance(message, dict) and message.get("role") == "assistant": @@ -217,6 +237,11 @@ def normalize_hermes_event( result["markdown"] = content return "run.completed", result + if name in {"run.cancelled", "run.stopped", "run.stopping"}: + # A stop was honoured upstream. Surfaced as its own terminal event so + # the caller can settle the node as stopped rather than as a failure. + return "run.cancelled", _with_run_id({"node_id": node_id}, payload) + if name in {"error", "run.failed", "assistant.failed", "response.failed"}: message = payload.get("message") or payload.get("error") or "Hermes request failed" if isinstance(message, dict): @@ -290,15 +315,48 @@ def _json(self, method: str, path: str, body: dict[str, Any] | None = None) -> d def health(self) -> dict[str, Any]: return self._json("GET", "/health") - def create_session(self, session_id: object, title: str | None = None) -> dict[str, Any]: + def create_session( + self, + session_id: object, + title: str | None = None, + model: str | None = None, + reasoning_effort: str | None = None, + ) -> dict[str, Any]: owned_id = require_rabbithole_session_id(session_id) body: dict[str, Any] = {"id": owned_id} if title: body["title"] = title + if model: + body["model"] = model + if reasoning_effort: + body["reasoning_effort"] = reasoning_effort result = self._json("POST", "/api/sessions", body) self._verify_returned_session(result, owned_id) return result + def models(self) -> list[dict[str, str]]: + result = self._json("GET", "/v1/models") + models: list[dict[str, str]] = [] + for item in result.get("data", []): + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + continue + model_id = item["id"].strip() + if model_id: + models.append({"id": model_id, "label": str(item.get("label") or model_id)}) + return models + + def update_session_settings( + self, session_id: object, model: str, reasoning_effort: str + ) -> dict[str, Any]: + owned_id = require_rabbithole_session_id(session_id) + result = self._json( + "PATCH", + f"/api/sessions/{urllib.parse.quote(owned_id, safe='')}", + {"model": model, "reasoning_effort": reasoning_effort}, + ) + self._verify_returned_session(result, owned_id) + return result + def fork_session( self, source_session_id: object, child_session_id: object, title: str | None = None ) -> dict[str, Any]: @@ -325,9 +383,24 @@ def delete_session(self, session_id: object) -> dict[str, Any]: "DELETE", f"/api/sessions/{urllib.parse.quote(owned_id, safe='')}" ) - def stream_chat(self, session_id: object, prompt: str) -> Iterator[tuple[str, dict[str, Any]]]: + def stream_chat( + self, session_id: object, prompt: str | list[dict[str, Any]] + ) -> Iterator[tuple[str, dict[str, Any]]]: owned_id = require_rabbithole_session_id(session_id) - if not isinstance(prompt, str) or not prompt.strip(): + # A list is the multimodal form: text plus image_url parts, as accepted + # by the Hermes session chat API. Validate it has visible payload so an + # empty turn still fails here rather than upstream. + if isinstance(prompt, list): + if not prompt or not all(isinstance(part, dict) for part in prompt): + raise ValueError("prompt parts must be a non-empty list of objects") + has_text = any( + part.get("type") == "text" and str(part.get("text", "")).strip() + for part in prompt + ) + has_image = any(part.get("type") in ("image_url", "input_image") for part in prompt) + if not (has_text or has_image): + raise ValueError("prompt must not be empty") + elif not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must not be empty") request = urllib.request.Request( self.base_url @@ -349,6 +422,112 @@ def stream_chat(self, session_id: object, prompt: str) -> Iterator[tuple[str, di finally: response.close() + def _history_for_session(self, owned_id: str) -> list[dict[str, str]]: + """Rebuild prior turns for a session as plain role/content messages. + + ``/v1/runs`` persists each turn to the session but does not reload it + on the next run, so continuity has to be supplied explicitly. Only + user/assistant messages with textual content are replayed: tool call + records are session-internal and are not accepted by the runs API. + """ + try: + payload = self._json( + "GET", f"/api/sessions/{urllib.parse.quote(owned_id, safe='')}/messages" + ) + except HermesError: + return [] + history: list[dict[str, str]] = [] + for message in payload.get("data", []) or []: + if not isinstance(message, dict): + continue + role = message.get("role") + content = message.get("content") + if role in {"user", "assistant"} and isinstance(content, str) and content.strip(): + history.append({"role": role, "content": content}) + return history + + def start_run( + self, session_id: object, prompt: str | list[dict[str, Any]] + ) -> str: + """Start a stoppable run and return its run id. + + Runs are created through ``/v1/runs`` rather than the session chat + stream because only runs are registered in the core's active-run table, + which is what ``/v1/runs/{id}/stop`` interrupts. The session chat + stream mints a run id for event correlation but never registers it, so + stopping such a turn always failed with ``run_not_found``. + """ + owned_id = require_rabbithole_session_id(session_id) + if isinstance(prompt, list): + if not prompt or not all(isinstance(part, dict) for part in prompt): + raise ValueError("prompt parts must be a non-empty list of objects") + has_text = any( + part.get("type") == "text" and str(part.get("text", "")).strip() + for part in prompt + ) + has_image = any(part.get("type") in ("image_url", "input_image") for part in prompt) + if not (has_text or has_image): + raise ValueError("prompt must not be empty") + # The runs API reads the final entry of a list as the user turn, so + # multimodal parts must be wrapped in an explicit message envelope. + run_input: Any = [{"role": "user", "content": prompt}] + elif isinstance(prompt, str) and prompt.strip(): + run_input = prompt + else: + raise ValueError("prompt must not be empty") + + body: dict[str, Any] = {"session_id": owned_id, "input": run_input} + history = self._history_for_session(owned_id) + if history: + body["conversation_history"] = history + result = self._json("POST", "/v1/runs", body) + run_id = result.get("run_id") + if not isinstance(run_id, str) or not run_id: + raise HermesError("Hermes did not return a run id", code="hermes_run_invalid") + return run_id + + def stream_run_events(self, run_id: object) -> Iterator[tuple[str, dict[str, Any]]]: + """Stream lifecycle events for an already-started run.""" + value = str(run_id or "").strip() + if not re.fullmatch(r"run_[0-9a-zA-Z_-]{1,128}", value): + raise ValueError("run id is not a valid Hermes run identifier") + request = urllib.request.Request( + self.base_url + f"/v1/runs/{urllib.parse.quote(value, safe='')}/events", + method="GET", + headers={**self._headers(), "Accept": "text/event-stream"}, + ) + response = self._open(request, timeout=max(self.timeout, 300.0)) + try: + content_type = response.headers.get_content_type() + if content_type != "text/event-stream": + raw = response.read(1_000_000) + raise HermesError( + _error_message(raw, "Hermes did not return an SSE stream"), + code="hermes_stream_invalid", + ) + for name, payload in parse_sse(response): + payload.setdefault("run_id", value) + yield name, payload + finally: + response.close() + + def stop_run(self, run_id: object) -> dict[str, Any]: + """Interrupt an in-flight run via Hermes core. + + Note the /v1/ prefix: sessions live under /api/, but the run control + endpoints are served from /v1/. The run id is validated here because it + is interpolated into the request path. + + The stop is cooperative — core signals the agent and returns + immediately, so the stream settles a moment later rather than at once. + A 404 means the run already finished, which is treated as success by + the caller: the desired end state (not running) already holds. + """ + value = str(run_id or "").strip() + if not re.fullmatch(r"run_[0-9a-zA-Z_-]{1,128}", value): + raise ValueError("run id is not a valid Hermes run identifier") + return self._json("POST", f"/v1/runs/{urllib.parse.quote(value, safe='')}/stop") + @staticmethod def _verify_returned_session(result: dict[str, Any], expected_id: str) -> None: session = result.get("session") diff --git a/backend/ownership.py b/backend/ownership.py index b15ca67..0bc6ca9 100644 --- a/backend/ownership.py +++ b/backend/ownership.py @@ -15,6 +15,10 @@ class OwnershipError(ValueError): "rh": re.compile(r"^rh_[0-9a-f]{32}$"), "rw": re.compile(r"^rw_[0-9a-f]{32}$"), "rn": re.compile(r"^rn_[0-9a-f]{32}$"), + "qf": re.compile(r"^qf_[0-9a-f]{32}$"), + "rs": re.compile(r"^rs_[0-9a-f]{32}$"), + "rd": re.compile(r"^rd_[0-9a-f]{32}$"), + "ra": re.compile(r"^ra_[0-9a-f]{32}$"), } @@ -37,6 +41,22 @@ def new_node_id() -> str: return new_id("rn") +def new_queued_follow_up_id() -> str: + return new_id("qf") + + +def new_screen_id() -> str: + return new_id("rs") + + +def new_drawing_id() -> str: + return new_id("rd") + + +def new_attachment_id() -> str: + return new_id("ra") + + def is_valid_id(value: object, kind: str) -> bool: pattern = _PATTERNS.get(kind) return bool(pattern and isinstance(value, str) and pattern.fullmatch(value)) diff --git a/backend/schema.sql b/backend/schema.sql index 2f5ad10..4c11ce9 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -19,10 +19,17 @@ CREATE TABLE IF NOT EXISTS nodes ( markdown TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')), error TEXT NOT NULL DEFAULT '', + -- Hermes run id for the turn currently in flight, '' when nothing is running. + -- Kept server-side only: it is the handle used to interrupt a run via the + -- Hermes core stop endpoint, and is never exposed to the browser. + run_id TEXT NOT NULL DEFAULT '', x REAL NOT NULL DEFAULT 0, y REAL NOT NULL DEFAULT 0, width REAL NOT NULL DEFAULT 520 CHECK (width > 0), height REAL NOT NULL DEFAULT 620 CHECK (height > 0), + header_color TEXT NOT NULL DEFAULT '', + model_alias TEXT NOT NULL DEFAULT 'hermes-agent', + reasoning_effort TEXT NOT NULL DEFAULT 'medium', ordinal INTEGER NOT NULL CHECK (ordinal >= 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -33,3 +40,78 @@ CREATE INDEX IF NOT EXISTS nodes_workspace_order ON nodes(workspace_id, ordinal); CREATE INDEX IF NOT EXISTS nodes_parent ON nodes(parent_node_id); + +CREATE TABLE IF NOT EXISTS session_archives ( + node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + hermes_session_id TEXT NOT NULL CHECK (hermes_session_id GLOB 'rh_*'), + messages_json TEXT NOT NULL, + saved_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS session_archives_session + ON session_archives(hermes_session_id); + +CREATE TABLE IF NOT EXISTS queued_followups ( + id TEXT PRIMARY KEY CHECK (id GLOB 'qf_*'), + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + created_at TEXT NOT NULL, + UNIQUE (node_id, ordinal) +); + +CREATE INDEX IF NOT EXISTS queued_followups_node_order + ON queued_followups(node_id, ordinal); + +CREATE TABLE IF NOT EXISTS screen_windows ( + id TEXT PRIMARY KEY CHECK (id GLOB 'rs_*'), + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + screen_key TEXT NOT NULL, + title TEXT NOT NULL, + x REAL NOT NULL DEFAULT 0, + y REAL NOT NULL DEFAULT 0, + width REAL NOT NULL DEFAULT 960 CHECK (width > 0), + height REAL NOT NULL DEFAULT 620 CHECK (height > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS screen_windows_workspace + ON screen_windows(workspace_id, created_at); + +-- Drawing windows own a tldraw document. The snapshot is opaque JSON produced +-- by the tldraw SDK; it belongs to the workspace alone and is never linked to a +-- Hermes session. +CREATE TABLE IF NOT EXISTS drawing_windows ( + id TEXT PRIMARY KEY CHECK (id GLOB 'rd_*'), + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + title TEXT NOT NULL, + snapshot TEXT NOT NULL DEFAULT '', + -- Rendered preview (data: URL) shown while the window is idle, so an + -- unfocused drawing costs one instead of a live editor. + preview TEXT NOT NULL DEFAULT '', + x REAL NOT NULL DEFAULT 0, + y REAL NOT NULL DEFAULT 0, + width REAL NOT NULL DEFAULT 900 CHECK (width > 0), + height REAL NOT NULL DEFAULT 640 CHECK (height > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS drawing_windows_workspace + ON drawing_windows(workspace_id, created_at); + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY CHECK (id GLOB 'ra_*'), + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + media_type TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'audio', 'document')), + byte_size INTEGER NOT NULL CHECK (byte_size >= 0), + storage_path TEXT NOT NULL, + delivered INTEGER NOT NULL DEFAULT 0 CHECK (delivered IN (0, 1)), + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS attachments_node + ON attachments(node_id, created_at); diff --git a/backend/screens.py b/backend/screens.py new file mode 100644 index 0000000..f116f4f --- /dev/null +++ b/backend/screens.py @@ -0,0 +1,136 @@ +"""Server-side registry of remote screens (VNC/RFB hosts). + +Screen targets and their credentials are configured through the environment and +never leave the backend. The browser only ever sees an opaque ``key`` plus a +human label; the password is used solely by :mod:`backend.vncproxy` when it +authenticates to the remote host on the client's behalf. + +Configuration format (``HERMES_VISUAL_SCREENS``), one screen per entry, +separated by ``;``:: + + key|Label|host|port|password + +``port`` may be omitted (defaults to 5900). ``password`` may be empty for hosts +that offer RFB security type 1 (None). +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Any, Iterable + +DEFAULT_RFB_PORT = 5900 + +_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +# Screens live on the private Tailscale mesh or loopback. Anything else is +# refused so a misconfigured entry cannot turn the proxy into an open relay. +_ALLOWED_HOST_RE = re.compile( + r"^(?:" + r"100\.(?:[0-9]{1,3})\.(?:[0-9]{1,3})\.(?:[0-9]{1,3})" # Tailscale CGNAT + r"|127\.0\.0\.1|localhost" + r"|[a-z0-9][a-z0-9.-]*\.ts\.net" # MagicDNS + r")$", + re.IGNORECASE, +) + + +class ScreenConfigError(ValueError): + """Raised when the screen registry configuration is malformed.""" + + +@dataclass(frozen=True) +class Screen: + key: str + label: str + host: str + port: int + password: str + + def public(self) -> dict[str, Any]: + """The representation safe to hand to the browser (no password).""" + return {"key": self.key, "label": self.label} + + +def _validate_host(host: str) -> str: + cleaned = host.strip() + if not cleaned: + raise ScreenConfigError("screen host must not be empty") + if not _ALLOWED_HOST_RE.fullmatch(cleaned): + raise ScreenConfigError( + f"screen host {cleaned!r} is not on the Tailscale mesh or loopback" + ) + return cleaned + + +def _validate_port(raw: str) -> int: + text = raw.strip() + if not text: + return DEFAULT_RFB_PORT + try: + port = int(text) + except ValueError as exc: + raise ScreenConfigError(f"screen port {raw!r} is not a number") from exc + if not 1 <= port <= 65535: + raise ScreenConfigError(f"screen port {port} is out of range") + return port + + +def parse_screens(raw: str | None) -> tuple[Screen, ...]: + """Parse ``HERMES_VISUAL_SCREENS`` into an ordered, de-duplicated tuple.""" + if not raw or not raw.strip(): + return () + screens: list[Screen] = [] + seen: set[str] = set() + for entry in raw.split(";"): + if not entry.strip(): + continue + parts = entry.split("|") + if len(parts) < 3: + raise ScreenConfigError( + "each screen needs at least key|label|host" + ) + key = parts[0].strip().lower() + if not _KEY_RE.fullmatch(key): + raise ScreenConfigError(f"screen key {parts[0]!r} is not a valid slug") + if key in seen: + raise ScreenConfigError(f"duplicate screen key {key!r}") + label = parts[1].strip() + if not label: + raise ScreenConfigError(f"screen {key!r} must have a label") + if len(label) > 120: + raise ScreenConfigError(f"screen {key!r} label is too long") + host = _validate_host(parts[2]) + port = _validate_port(parts[3]) if len(parts) > 3 else DEFAULT_RFB_PORT + password = parts[4] if len(parts) > 4 else "" + screens.append(Screen(key=key, label=label, host=host, port=port, password=password)) + seen.add(key) + return tuple(screens) + + +class ScreenRegistry: + """Immutable lookup over the configured screens.""" + + def __init__(self, screens: Iterable[Screen] = ()): + self._screens = tuple(screens) + self._by_key = {screen.key: screen for screen in self._screens} + + @classmethod + def from_env(cls, environ: dict[str, str] | None = None) -> "ScreenRegistry": + env = os.environ if environ is None else environ + return cls(parse_screens(env.get("HERMES_VISUAL_SCREENS"))) + + def __len__(self) -> int: + return len(self._screens) + + def __iter__(self): + return iter(self._screens) + + def get(self, key: object) -> Screen | None: + if not isinstance(key, str): + return None + return self._by_key.get(key.strip().lower()) + + def public(self) -> list[dict[str, Any]]: + return [screen.public() for screen in self._screens] diff --git a/backend/server.py b/backend/server.py index f994271..d19c9bc 100644 --- a/backend/server.py +++ b/backend/server.py @@ -8,7 +8,9 @@ import mimetypes import os import re +import socket import sys +from collections.abc import Iterator from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -18,18 +20,46 @@ try: from .hermes_client import HermesClient, HermesError, normalize_hermes_event from .ownership import new_session_id + from .screens import ScreenRegistry from .store import ConflictError, NotFoundError, Store, ValidationError + from . import attachments as attachments_mod + from . import vncproxy except ImportError: # pragma: no cover - direct script execution from hermes_client import HermesClient, HermesError, normalize_hermes_event # type: ignore from ownership import new_session_id # type: ignore + from screens import ScreenRegistry # type: ignore from store import ConflictError, NotFoundError, Store, ValidationError # type: ignore + import attachments as attachments_mod # type: ignore + import vncproxy # type: ignore _MAX_BODY = 1_000_000 _WORKSPACE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})$") +_WORKSPACE_NODE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/nodes$") +_WORKSPACE_ARCHIVE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/archive$") _NODE_RUN_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/run/stream$") _NODE_BRANCH_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/branch/stream$") +_NODE_MERGE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/merge/stream$") +_NODE_CONTINUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/continue/stream$") +_NODE_STOP_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/stop$") +_NODE_RETRY_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/retry/stream$") +_NODE_QUEUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/queue$") +_QUEUED_FOLLOW_UP_RE = re.compile(r"^/api/queued-follow-ups/(qf_[0-9a-f]{32})$") _NODE_LAYOUT_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/layout$") +_NODE_SETTINGS_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/settings$") +_NODE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})$") +_WORKSPACE_SCREEN_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/screens$") +_SCREEN_LAYOUT_RE = re.compile(r"^/api/screens/(rs_[0-9a-f]{32})/layout$") +_SCREEN_STREAM_RE = re.compile(r"^/api/screens/(rs_[0-9a-f]{32})/stream$") +_SCREEN_RE = re.compile(r"^/api/screens/(rs_[0-9a-f]{32})$") +_WORKSPACE_DRAWING_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/drawings$") +_DRAWING_SNAPSHOT_RE = re.compile(r"^/api/drawings/(rd_[0-9a-f]{32})/snapshot$") +_DRAWING_LAYOUT_RE = re.compile(r"^/api/drawings/(rd_[0-9a-f]{32})/layout$") +_DRAWING_RE = re.compile(r"^/api/drawings/(rd_[0-9a-f]{32})$") +_NODE_ATTACHMENTS_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/attachments$") +_ATTACHMENT_RE = re.compile(r"^/api/attachments/(ra_[0-9a-f]{32})$") +_ATTACHMENT_CONTENT_RE = re.compile(r"^/api/attachments/(ra_[0-9a-f]{32})/content$") +_REASONING_LEVELS = ("none", "minimal", "low", "medium", "high", "xhigh") class ApplicationHTTPServer(ThreadingHTTPServer): @@ -42,10 +72,16 @@ def __init__( store: Store, hermes: HermesClient, static_dir: str | Path | None, + screens: ScreenRegistry | None = None, + attachment_storage: "attachments_mod.AttachmentStorage | None" = None, + agent_path_map: str = "", ): self.store = store self.hermes = hermes + self.screens = screens if screens is not None else ScreenRegistry() self.static_dir = Path(static_dir).resolve() if static_dir is not None else None + self.attachment_storage = attachment_storage + self.agent_path_map = agent_path_map super().__init__(server_address, RabbitholeHandler) @@ -57,10 +93,56 @@ def do_GET(self) -> None: # noqa: N802 path = urlsplit(self.path).path if path == "/api/health": self._health() + elif path == "/api/models": + try: + models = [] + for item in self.server.hermes.models(): + model_id = item["id"] + lowered = model_id.lower() + if any(token in lowered for token in ("whisper", "tts", "realtime", "embedding", "audio")): + continue + label = "Default" if model_id == "hermes-agent" else self._model_label(model_id) + models.append({"id": model_id, "label": label}) + if not any(item["id"] == "hermes-agent" for item in models): + models.insert(0, {"id": "hermes-agent", "label": "Default"}) + self._send_json( + HTTPStatus.OK, + { + "models": models, + "reasoning_levels": list(_REASONING_LEVELS), + "defaults": {"model": "hermes-agent", "reasoning_effort": "medium"}, + }, + ) + except Exception as exc: + self._exception(exc) elif path == "/api/workspaces": self._send_json(HTTPStatus.OK, {"workspaces": self.server.store.list_workspaces()}) + elif path == "/api/screens": + self._send_json(HTTPStatus.OK, {"screens": self.server.screens.public()}) + elif path == "/api/drawings/license": + # tldraw validates this public deployment key in the browser. It is + # deliberately scoped to the drawing frame's same-origin API rather + # than embedded in source, static files, or the service unit. + self._send_json(HTTPStatus.OK, {"licenseKey": os.environ.get("TLDRAW_LICENSE_KEY", "")}) + elif match := _SCREEN_STREAM_RE.fullmatch(path): + self._screen_stream(match.group(1)) + elif match := _DRAWING_SNAPSHOT_RE.fullmatch(path): + self._handle_store(lambda: self.server.store.get_drawing(match.group(1))) + elif match := _ATTACHMENT_CONTENT_RE.fullmatch(path): + self._serve_attachment(match.group(1), head=False) + elif match := _NODE_ATTACHMENTS_RE.fullmatch(path): + self._handle_store( + lambda: { + "attachments": [ + self._public_attachment(row) + for row in self.server.store.list_attachments(match.group(1)) + ] + } + ) + elif match := _WORKSPACE_ARCHIVE_RE.fullmatch(path): + self._handle_store(lambda: self.server.store.export_workspace(match.group(1))) elif match := _WORKSPACE_RE.fullmatch(path): - self._handle_store(lambda: self.server.store.get_workspace(match.group(1))) + self._handle_store(lambda: self._workspace_payload(match.group(1))) elif path.startswith("/api/"): self._error(HTTPStatus.NOT_FOUND, "not_found", "API endpoint not found") else: @@ -68,26 +150,84 @@ def do_GET(self) -> None: # noqa: N802 def do_HEAD(self) -> None: # noqa: N802 path = urlsplit(self.path).path - if path.startswith("/api/"): + if match := _ATTACHMENT_CONTENT_RE.fullmatch(path): + self._serve_attachment(match.group(1), head=True) + elif path.startswith("/api/"): self._error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Method not allowed") else: self._serve_static(path, head=True) def do_POST(self) -> None: # noqa: N802 path = urlsplit(self.path).path + if match := _NODE_ATTACHMENTS_RE.fullmatch(path): + self._upload_attachments(match.group(1)) + return + if match := _WORKSPACE_SCREEN_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + required = {"screen_key", "x", "y"} + if not self._only_fields(body, required) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "screen_key, x, and y are required", + ) + return + screen = self.server.screens.get(body["screen_key"]) + if screen is None: + self._error(HTTPStatus.NOT_FOUND, "not_found", "Unknown screen") + return + self._handle_store( + lambda: { + "screen": self.server.store.create_screen( + match.group(1), screen.key, screen.label, body["x"], body["y"] + ) + } + ) + return + if match := _WORKSPACE_DRAWING_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + required = {"x", "y"} + allowed = required | {"title"} + if not self._only_fields(body, allowed) or not required.issubset(body): + self._error(HTTPStatus.BAD_REQUEST, "invalid_request", "x and y are required") + return + self._handle_store( + lambda: { + "drawing": self.server.store.create_drawing( + match.group(1), body.get("title", "Drawing"), body["x"], body["y"] + ) + } + ) + return if path == "/api/workspaces": body = self._read_body() if body is None: return - if not self._only_fields(body, {"title", "prompt"}) or "prompt" not in body: + required = {"prompt"} + if not self._only_fields(body, {"title", "model", "reasoning_effort", *required}) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "prompt is required", + ) return try: prompt = self._required_prompt(body.get("prompt")) title = self._optional_title(body.get("title")) + model = self._required_model(body.get("model", "hermes-agent")) + effort = self._required_reasoning_effort(body.get("reasoning_effort", "medium")) session_id = new_session_id() - self.server.hermes.create_session(session_id, title) + self.server.hermes.create_session(session_id, title, model, effort) try: - workspace, node = self.server.store.create_workspace(prompt, session_id, title) + workspace, node = self.server.store.create_workspace( + prompt, session_id, title, model, effort + ) except Exception: self._delete_orphan_session(session_id) raise @@ -96,6 +236,38 @@ def do_POST(self) -> None: # noqa: N802 self._exception(exc) return + if match := _WORKSPACE_NODE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + required = {"prompt", "x", "y"} + if not self._only_fields(body, {"title", "model", "reasoning_effort", *required}) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "prompt, x, and y are required", + ) + return + try: + prompt = self._required_prompt(body.get("prompt")) + title = self._optional_title(body.get("title")) + model = self._required_model(body.get("model", "hermes-agent")) + effort = self._required_reasoning_effort(body.get("reasoning_effort", "medium")) + session_id = new_session_id() + self.server.hermes.create_session(session_id, title, model, effort) + try: + node = self.server.store.create_standalone( + match.group(1), prompt, session_id, body["x"], body["y"], title, model, effort + ) + except Exception: + self._delete_orphan_session(session_id) + raise + self._send_json(HTTPStatus.CREATED, {"node": node}) + except Exception as exc: + self._exception(exc) + return + if match := _NODE_RUN_RE.fullmatch(path): if self._read_body(allow_empty=True) is None: return @@ -153,6 +325,11 @@ def do_POST(self) -> None: # noqa: N802 self._delete_orphan_session(child_session_id) raise running = self.server.store.start_node(child["id"]) + # Files staged on the parent card belong to the turn the user + # is sending now, which is the child's. Hand them over before + # streaming, otherwise the branch goes out with no attachment + # and the rows stay stranded on the already-completed parent. + self._inherit_pending_attachments(parent["id"], child["id"]) except Exception as exc: self._exception(exc) return @@ -161,6 +338,128 @@ def do_POST(self) -> None: # noqa: N802 self._stream_node(running, input_override=hermes_prompt) return + if match := _NODE_MERGE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + if not self._only_fields(body, {"destination", "instruction"}) or "destination" not in body: + return + try: + destination = body.get("destination") + if destination not in {"parent", "root"}: + raise ValidationError("destination must be parent or root") + instruction = body.get("instruction", "") + if not isinstance(instruction, str): + raise ValidationError("instruction must be a string") + instruction = instruction.strip() + if len(instruction) > 20_000: + raise ValidationError("instruction is too long") + source = self.server.store.get_node(match.group(1)) + if source["parent_node_id"] is None: + raise ConflictError("only a child node can be merged") + if source["status"] != "completed": + raise ConflictError("source node must be completed before merging") + detail = self.server.store.get_workspace(source["workspace_id"]) + destination_id = ( + source["parent_node_id"] + if destination == "parent" + else detail["workspace"]["root_node_id"] + ) + target = self.server.store.get_node(destination_id) + if target["status"] != "completed": + raise ConflictError("merge destination must be completed") + source_markdown = source["markdown"][:160_000] + merge_prompt = ( + "Integrate the useful findings from this completed child branch into the current " + "conversation. Summarize and reconcile the findings rather than copying the full branch.\n\n" + f"Child branch title: {source['title']}\n" + f"Child branch task: {source['prompt']}\n\n" + f"Child branch result:\n{source_markdown}" + ) + if instruction: + merge_prompt += f"\n\nAdditional instruction:\n{instruction}" + running = self.server.store.merge_node(target["id"], source["title"]) + except Exception as exc: + self._exception(exc) + return + self._begin_sse() + self._sse( + "node.continued", + {"node": running, "merged_from_node_id": source["id"]}, + ) + self._stream_node( + running, + input_override=merge_prompt, + initial_markdown=running["markdown"], + ) + return + + if match := _NODE_QUEUE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + if not self._only_fields(body, {"prompt"}) or "prompt" not in body: + return + try: + queued = self.server.store.queue_follow_up( + match.group(1), self._required_prompt(body.get("prompt")) + ) + self._send_json(HTTPStatus.CREATED, {"queued": queued}) + except Exception as exc: + self._exception(exc) + return + + if match := _NODE_CONTINUE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + if not self._only_fields(body, {"prompt"}) or "prompt" not in body: + return + try: + prompt = self._required_prompt(body.get("prompt")) + running = self.server.store.continue_node(match.group(1), prompt) + except Exception as exc: + self._exception(exc) + return + self._begin_sse() + self._sse("node.continued", {"node": running}) + self._stream_node(running, input_override=prompt, initial_markdown=running["markdown"]) + return + + if match := _NODE_STOP_RE.fullmatch(path): + node_id = match.group(1) + try: + run_id = self.server.store.get_run_id(node_id) + if not run_id: + # Running, but no run id was ever observed on the stream. + # Report it rather than silently succeeding: the caller + # would otherwise believe the turn was interrupted. + raise ConflictError("this run cannot be stopped yet") + try: + self.server.hermes.stop_run(run_id) + except HermesError as stop_error: + # A run that already finished is not an error: the node is + # about to settle on its own and the goal state holds. + if "run_not_found" not in str(stop_error) and "404" not in str(stop_error): + raise + self._send_json(HTTPStatus.ACCEPTED, {"node_id": node_id, "status": "stopping"}) + except Exception as exc: + self._exception(exc) + return + + if match := _NODE_RETRY_RE.fullmatch(path): + node_id = match.group(1) + try: + running = self.server.store.retry_node(node_id) + except Exception as exc: + self._exception(exc) + return + self._begin_sse() + self._sse("node.retried", {"node": running}) + # Replay the node's original prompt on its existing session. + self._stream_node(running, initial_markdown=running["markdown"]) + return + self._error(HTTPStatus.NOT_FOUND, "not_found", "API endpoint not found") def do_PATCH(self) -> None: # noqa: N802 @@ -175,6 +474,30 @@ def do_PATCH(self) -> None: # noqa: N802 lambda: {"workspace": self.server.store.update_workspace_title(match.group(1), body["title"])} ) return + if match := _NODE_SETTINGS_RE.fullmatch(path): + required = {"model", "reasoning_effort"} + if not self._only_fields(body, required) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "model and reasoning_effort are required", + ) + return + try: + model = self._required_model(body["model"]) + effort = self._required_reasoning_effort(body["reasoning_effort"]) + node = self.server.store.get_node(match.group(1)) + if node["status"] != "completed": + raise ConflictError("settings can only change after the session completes") + self.server.hermes.update_session_settings( + node["hermes_session_id"], model, effort + ) + updated = self.server.store.update_settings(node["id"], model, effort) + self._send_json(HTTPStatus.OK, {"node": updated}) + except Exception as exc: + self._exception(exc) + return if match := _NODE_LAYOUT_RE.fullmatch(path): required = {"x", "y", "width", "height"} if not self._only_fields(body, required) or not required.issubset(body): @@ -193,14 +516,378 @@ def do_PATCH(self) -> None: # noqa: N802 } ) return + if match := _SCREEN_LAYOUT_RE.fullmatch(path): + required = {"x", "y", "width", "height"} + if not self._only_fields(body, required) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "x, y, width, and height are required", + ) + return + self._handle_store( + lambda: { + "screen": self.server.store.update_screen_layout( + match.group(1), body["x"], body["y"], body["width"], body["height"] + ) + } + ) + return + if match := _DRAWING_LAYOUT_RE.fullmatch(path): + required = {"x", "y", "width", "height"} + if not self._only_fields(body, required) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "x, y, width, and height are required", + ) + return + self._handle_store( + lambda: { + "drawing": self.server.store.update_drawing_layout( + match.group(1), body["x"], body["y"], body["width"], body["height"] + ) + } + ) + return + if match := _DRAWING_SNAPSHOT_RE.fullmatch(path): + if not self._only_fields(body, {"snapshot", "preview"}) or "snapshot" not in body: + self._error(HTTPStatus.BAD_REQUEST, "invalid_request", "snapshot is required") + return + self._handle_store( + lambda: { + "drawing": self.server.store.save_drawing_snapshot( + match.group(1), body["snapshot"], body.get("preview") + ) + } + ) + return + if match := _DRAWING_RE.fullmatch(path): + if not self._only_fields(body, {"title"}) or "title" not in body: + self._error(HTTPStatus.BAD_REQUEST, "invalid_request", "title is required") + return + self._handle_store( + lambda: { + "drawing": self.server.store.rename_drawing(match.group(1), body["title"]) + } + ) + return self._error(HTTPStatus.NOT_FOUND, "not_found", "API endpoint not found") def do_PUT(self) -> None: # noqa: N802 self._error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Method not allowed") def do_DELETE(self) -> None: # noqa: N802 + path = urlsplit(self.path).path + if match := _QUEUED_FOLLOW_UP_RE.fullmatch(path): + self._handle_store( + lambda: {"cancelled": self.server.store.cancel_queued_follow_up(match.group(1))} + ) + return + if match := _NODE_RE.fullmatch(path): + self._handle_store(lambda: self._delete_node(match.group(1))) + return + if match := _SCREEN_RE.fullmatch(path): + self._handle_store(lambda: self.server.store.delete_screen(match.group(1))) + return + if match := _DRAWING_RE.fullmatch(path): + self._handle_store(lambda: self.server.store.delete_drawing(match.group(1))) + return + if match := _ATTACHMENT_RE.fullmatch(path): + self._handle_store(lambda: self._delete_attachment(match.group(1))) + return self._error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Method not allowed") + def _workspace_payload(self, workspace_id: str) -> dict[str, Any]: + """Workspace view with attachments projected to their public shape.""" + payload = self.server.store.get_workspace(workspace_id) + payload["attachments"] = [ + self._public_attachment(row) for row in payload.get("attachments", []) + ] + return payload + + def _public_attachment(self, row: dict[str, Any]) -> dict[str, Any]: + """Project a stored attachment for the browser. + + ``storage_path`` is deliberately withheld: the browser has no use for a + server-side path and exposing it would leak filesystem layout. + """ + return { + "id": row["id"], + "node_id": row["node_id"], + "filename": row["filename"], + "media_type": row["media_type"], + "kind": row["kind"], + "byte_size": row["byte_size"], + "delivered": bool(row["delivered"]), + "created_at": row["created_at"], + "url": f"/api/attachments/{row['id']}/content", + } + + def _attachment_storage(self) -> "attachments_mod.AttachmentStorage": + storage = self.server.attachment_storage + if storage is None: + raise HermesError("Attachment storage is not configured", status=503) + return storage + + def _inherit_pending_attachments(self, from_node_id: str, to_node_id: str) -> None: + """Move undelivered uploads from one node to another, bytes included. + + Best effort by design: an upload that fails to follow the branch is a + missing image, but a raised exception here would kill a turn the user + has already committed to. The row move is transactional in the store; + this only reconciles the file on disk with the row's new owner. + """ + try: + moved = self.server.store.reassign_pending_attachments(from_node_id, to_node_id) + except Exception as error: + self.log_error("could not reassign attachments %s -> %s: %s", from_node_id, to_node_id, error) + return + storage = self.server.attachment_storage + if storage is None: + return + for record in moved: + try: + relocated = storage.move_to_node(record["storage_path"], to_node_id) + except Exception as error: + self.log_error("could not move attachment bytes %s: %s", record["id"], error) + continue + if str(relocated) != record["storage_path"]: + try: + self.server.store.set_attachment_path(record["id"], str(relocated)) + except Exception as error: + self.log_error("could not record moved path %s: %s", record["id"], error) + + def _upload_attachments(self, node_id: str) -> None: + """Accept one or more files posted as multipart/form-data onto a node.""" + content_type = self.headers.get("Content-Type", "") + if "multipart/form-data" not in content_type.lower(): + self._error( + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + "invalid_request", + "Attachments must be uploaded as multipart/form-data", + ) + return + if self.headers.get("Transfer-Encoding", ""): + self._error( + HTTPStatus.BAD_REQUEST, "invalid_request", "Chunked request bodies are not supported" + ) + return + raw_length = self.headers.get("Content-Length") + if raw_length is None: + self._error(HTTPStatus.LENGTH_REQUIRED, "length_required", "Content-Length is required") + return + try: + length = int(raw_length) + except ValueError: + self._error(HTTPStatus.BAD_REQUEST, "invalid_request", "Invalid Content-Length") + return + # Envelope allowance on top of the per-file cap covers MIME headers and + # boundaries for a full batch. + ceiling = attachments_mod.MAX_ATTACHMENT_BYTES * attachments_mod.MAX_ATTACHMENTS_PER_TURN + if length < 0 or length > ceiling + 1_000_000: + self._error( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "request_too_large", "Upload is too large" + ) + return + + try: + storage = self._attachment_storage() + # Fail before reading the body when the node is unknown. + self.server.store.get_node(node_id) + raw = self._read_exactly(length) + parts = list(attachments_mod.parse_multipart(raw, content_type)) + if not parts: + raise ValidationError("no files were included in the upload") + if len(parts) > attachments_mod.MAX_ATTACHMENTS_PER_TURN: + raise ValidationError( + f"at most {attachments_mod.MAX_ATTACHMENTS_PER_TURN} files per upload" + ) + + created: list[dict[str, Any]] = [] + for part in parts: + if len(part.data) == 0: + raise ValidationError(f"'{attachments_mod.safe_filename(part.filename)}' is empty") + if len(part.data) > attachments_mod.MAX_ATTACHMENT_BYTES: + limit_mb = attachments_mod.MAX_ATTACHMENT_BYTES // (1024 * 1024) + raise ValidationError( + f"'{attachments_mod.safe_filename(part.filename)}' exceeds " + f"the {limit_mb} MB limit" + ) + display = attachments_mod.safe_filename(part.filename) + kind, media_type = attachments_mod.classify(display, part.content_type) + record = self.server.store.create_attachment( + node_id, + filename=display, + media_type=media_type, + kind=kind, + byte_size=len(part.data), + storage_path="", + ) + try: + written = storage.write(node_id, record["id"], display, part.data) + except Exception: + # Never leave a row pointing at bytes that failed to land. + self.server.store.delete_attachment(record["id"]) + raise + record = self.server.store.set_attachment_path(record["id"], str(written)) + created.append(self._public_attachment(record)) + + self._send_json(HTTPStatus.CREATED, {"attachments": created}) + except Exception as exc: + self._exception(exc) + + def _read_exactly(self, length: int) -> bytes: + """Read exactly ``length`` bytes, tolerating short socket reads. + + ``rfile.read(n)`` on a buffered socket can legally return fewer bytes + than requested; a single call would silently truncate a large upload + and corrupt the trailing multipart boundary. + """ + chunks: list[bytes] = [] + remaining = length + while remaining > 0: + chunk = self.rfile.read(min(remaining, 1 << 20)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + def _delete_attachment(self, attachment_id: str) -> dict[str, Any]: + record = self.server.store.delete_attachment(attachment_id) + storage = self.server.attachment_storage + if storage is not None and record.get("storage_path"): + storage.remove(record["storage_path"]) + return {"deleted_attachment_ids": [attachment_id], "node_id": record["node_id"]} + + def _serve_attachment(self, attachment_id: str, *, head: bool) -> None: + """Stream an uploaded file back for preview or download.""" + try: + record = self.server.store.get_attachment(attachment_id) + storage = self._attachment_storage() + target = storage.resolve(record["storage_path"]) + except Exception as exc: + self._exception(exc) + return + if not target.is_file(): + self._error(HTTPStatus.NOT_FOUND, "not_found", "Attachment bytes are missing") + return + + payload = b"" if head else target.read_bytes() + size = target.stat().st_size if head else len(payload) + # Inline for images so previews render; attachment for everything else + # so a click downloads rather than navigating the browser away. + disposition = "inline" if record["kind"] == "image" else "attachment" + filename = record["filename"].replace('"', "") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", record["media_type"]) + self.send_header("Content-Length", str(size)) + self.send_header( + "Content-Disposition", f'{disposition}; filename="{filename}"' + ) + # Uploads are immutable and addressed by a random id, so they are safe + # to cache hard; the CSP blocks script execution from this origin path. + self.send_header("Cache-Control", "private, max-age=31536000, immutable") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Content-Security-Policy", "default-src 'none'; sandbox") + self.end_headers() + if not head: + self.wfile.write(payload) + + def _screen_stream(self, screen_id: str) -> None: + """Upgrade to a WebSocket and bridge it to the configured remote screen. + + The RFB password is applied here, server-side; the browser never sees + it and noVNC is handed a security-type-None handshake instead. + """ + try: + record = self.server.store.get_screen(screen_id) + except NotFoundError: + self._error(HTTPStatus.NOT_FOUND, "not_found", "Screen not found") + return + except Exception as exc: + self._exception(exc) + return + + target = self.server.screens.get(record["screen_key"]) + if target is None: + self._error( + HTTPStatus.SERVICE_UNAVAILABLE, + "screen_unavailable", + "This screen is no longer configured on the server", + ) + return + + upgrade = (self.headers.get("Upgrade") or "").strip().lower() + client_key = (self.headers.get("Sec-WebSocket-Key") or "").strip() + if upgrade != "websocket" or not client_key: + self._error( + HTTPStatus.BAD_REQUEST, "invalid_request", "Expected a WebSocket upgrade" + ) + return + + try: + vnc_sock, server_init = vncproxy.connect_screen( + target.host, target.port, target.password + ) + except (vncproxy.ProxyError, OSError) as exc: + # Report the reason before upgrading so the browser sees real text. + self._error(HTTPStatus.BAD_GATEWAY, "screen_unreachable", str(exc)) + return + + try: + handshake = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {vncproxy.accept_key(client_key)}\r\n" + ) + # noVNC offers the "binary" subprotocol; echo it when requested. + requested = (self.headers.get("Sec-WebSocket-Protocol") or "") + offered = [item.strip() for item in requested.split(",") if item.strip()] + if "binary" in offered: + handshake += "Sec-WebSocket-Protocol: binary\r\n" + handshake += "\r\n" + self.wfile.write(handshake.encode("ascii")) + self.wfile.flush() + # The socket is now a WebSocket; stop the HTTP machinery touching it. + self.close_connection = True + vncproxy.pump(self.connection, vnc_sock, server_init) + except OSError: + pass + finally: + try: + vnc_sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + vnc_sock.close() + + def _delete_node(self, node_id: str) -> dict[str, Any]: + """Remove a window subtree, then release each orphaned Hermes session.""" + removed = self.server.store.delete_node(node_id) + storage = self.server.attachment_storage + released, failed = [], [] + for node in removed["nodes"]: + # Attachment rows go with the node via ON DELETE CASCADE; the bytes + # on disk have to be reclaimed explicitly. + if storage is not None: + storage.remove_node(node["id"]) + try: + self.server.hermes.delete_session(node["hermes_session_id"]) + released.append(node["hermes_session_id"]) + except Exception as exc: # Hermes already gone: local delete stands. + self.log_error("could not release session %s: %s", node["hermes_session_id"], exc) + failed.append(node["hermes_session_id"]) + return { + "workspace_id": removed["workspace_id"], + "deleted_node_ids": [node["id"] for node in removed["nodes"]], + "released_session_ids": released, + "orphaned_session_ids": failed, + } + def _health(self) -> None: hermes_status: dict[str, Any] try: @@ -229,6 +916,34 @@ def _optional_title(self, value: object) -> str | None: raise ValidationError("title is too long") return value.strip() + def _required_model(self, value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValidationError("model must be a non-empty string") + model = value.strip() + if len(model) > 500: + raise ValidationError("model is too long") + available = {item["id"] for item in self.server.hermes.models()} + available.add("hermes-agent") + if model not in available: + raise ValidationError("unsupported model") + return model + + @staticmethod + def _required_reasoning_effort(value: object) -> str: + if not isinstance(value, str) or value.strip().lower() not in _REASONING_LEVELS: + raise ValidationError("unsupported reasoning_effort") + return value.strip().lower() + + @staticmethod + def _model_label(model_id: str) -> str: + name = model_id.rsplit("/", 1)[-1] + name = re.sub(r"-(\d+)-(\d+)$", r"-\1.\2", name) + if name.startswith("gpt-"): + return "GPT-" + name[4:].replace("-", " ").title() + if name.startswith("claude-"): + return "Claude " + name[7:].replace("-", " ").title() + return name.replace("-", " ").title() + def _read_body(self, *, allow_empty: bool = False) -> dict[str, Any] | None: transfer_encoding = self.headers.get("Transfer-Encoding", "") if transfer_encoding: @@ -322,19 +1037,67 @@ def _sse(self, event_name: str, data: dict[str, Any]) -> None: # Continue consuming Hermes and persisting output after a tab closes. self._sse_connected = False - def _stream_node(self, node: dict[str, Any], *, input_override: str | None = None) -> None: + def _run_stream( + self, node_id: str, chat_input: Any + ) -> Iterator[tuple[str, dict[str, Any]]]: + """Start a stoppable run and yield its events. + + The run id is recorded before the first event is consumed. Stopping is + therefore possible for the whole life of the turn, including the model's + first (often longest) call, instead of only after an event happened to + carry the id back. + """ + node = self.server.store.get_node(node_id) + run_id = self.server.hermes.start_run(node["hermes_session_id"], chat_input) + try: + self.server.store.set_run_id(node_id, run_id) + except Exception: + pass + yield from self.server.hermes.stream_run_events(run_id) + + def _stream_node( + self, + node: dict[str, Any], + *, + input_override: str | None = None, + initial_markdown: str | None = None, + ) -> None: node_id = node["id"] final_markdown: str | None = None failed_error: str | None = None saw_terminal = False + stopped = False + seen_run_id: str | None = None try: - for upstream_name, upstream_payload in self.server.hermes.stream_chat( - node["hermes_session_id"], input_override or node["prompt"] - ): + # Claim this node's pending uploads and fold them into the turn. + # Every streaming entry point funnels through here, so run, branch, + # continue and queued follow-ups all pick attachments up. + chat_input: Any = input_override or node["prompt"] + storage = self.server.attachment_storage + if storage is not None: + try: + pending = self.server.store.take_pending_attachments(node_id) + except Exception: + pending = [] + if pending: + chat_input = attachments_mod.build_chat_input( + str(chat_input), pending, storage, self.server.agent_path_map + ) + for upstream_name, upstream_payload in self._run_stream(node_id, chat_input): normalized = normalize_hermes_event(upstream_name, upstream_payload, node_id) if normalized is None: continue name, payload = normalized + # run_id is a server-side control handle, never browser state. + # Record it so /stop can interrupt this turn, then strip it + # from the payload that goes out over SSE. + run_id = payload.pop("run_id", None) + if run_id and run_id != seen_run_id: + seen_run_id = run_id + try: + self.server.store.set_run_id(node_id, run_id) + except Exception: + pass if name == "assistant.delta": # This write intentionally precedes the outward event. self.server.store.append_markdown(node_id, payload["delta"]) @@ -348,9 +1111,24 @@ def _stream_node(self, node: dict[str, Any], *, input_override: str | None = Non saw_terminal = True if "markdown" in payload: final_markdown = payload["markdown"] + elif name == "run.cancelled": + # A stop was honoured. Treat it as terminal-but-not-success: + # partial markdown is already persisted, and settling into + # 'failed' with an explicit reason keeps the state machine + # honest while enabling both Retry and Continue. + stopped = True + break elif name == "node.failed": failed_error = payload["error"] break + if stopped: + message = "Stopped at your request." + try: + self.server.store.fail_node(node_id, message) + except ConflictError: + pass + self._sse("node.failed", {"node_id": node_id, "error": message}) + return if failed_error is not None: raise HermesError(failed_error, code="hermes_run_failed") if not saw_terminal: @@ -358,7 +1136,15 @@ def _stream_node(self, node: dict[str, Any], *, input_override: str | None = Non "Hermes stream ended before run completion", code="hermes_stream_incomplete", ) + if final_markdown is not None and initial_markdown is not None: + final_markdown = initial_markdown + final_markdown completed = self.server.store.complete_node(node_id, final_markdown) + try: + self.server.store.archive_session( + node_id, self.server.hermes.messages(completed["hermes_session_id"]) + ) + except Exception as archive_error: + self.log_error("could not archive Hermes session %s: %s", node_id, archive_error) self._sse( "node.completed", { @@ -368,6 +1154,18 @@ def _stream_node(self, node: dict[str, Any], *, input_override: str | None = Non "hermes_session_id": completed["hermes_session_id"], }, ) + queued = self.server.store.take_queued_follow_up(node_id) + if queued is not None: + running = self.server.store.continue_node(node_id, queued["prompt"]) + self._sse( + "node.continued", + {"node": running, "queued_follow_up_id": queued["id"]}, + ) + self._stream_node( + running, + input_override=queued["prompt"], + initial_markdown=running["markdown"], + ) except Exception as exc: message = str(exc).strip() or "Hermes request failed" try: @@ -416,17 +1214,33 @@ def _serve_static(self, request_path: str, *, head: bool) -> None: content_type, _ = mimetypes.guess_type(candidate.name) if candidate.suffix == ".js": content_type = "text/javascript" + # The drawing frame is the ONLY document allowed to relax style-src and + # be framed. tldraw applies inline style attributes at runtime, which + # nonces and hashes cannot cover (CSP ignores hashes for style + # attributes without 'unsafe-hashes'). Scoping the relaxation to this + # one asset keeps the main application - which renders model-authored + # markdown - on the strict policy. + is_drawing_frame = relative == "drawing.html" or candidate.name == "drawing.html" + if is_drawing_frame: + csp = ( + "default-src 'self'; script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob:; font-src 'self' data:; " + "connect-src 'self'; object-src 'none'; base-uri 'none'; " + "frame-ancestors 'self'; form-action 'none'" + ) + else: + csp = ( + "default-src 'self'; script-src 'self'; style-src 'self'; " + "img-src 'self' data: blob: https:; font-src 'self' data:; " + "connect-src 'self'; object-src 'none'; base-uri 'none'; " + "frame-ancestors 'none'; form-action 'self'" + ) self.send_response(HTTPStatus.OK) self.send_header("Content-Type", f"{content_type or 'application/octet-stream'}; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.send_header("X-Content-Type-Options", "nosniff") - self.send_header( - "Content-Security-Policy", - "default-src 'self'; script-src 'self'; style-src 'self'; " - "img-src 'self' data: blob: https:; font-src 'self' data:; " - "connect-src 'self'; object-src 'none'; base-uri 'none'; " - "frame-ancestors 'none'; form-action 'self'", - ) + self.send_header("Content-Security-Policy", csp) self.send_header("Referrer-Policy", "no-referrer") self.send_header("Cache-Control", "no-cache") self.end_headers() @@ -471,6 +1285,8 @@ def create_server( database: str | Path | None = None, static_dir: str | Path | None = None, hermes_client: HermesClient | None = None, + screens: ScreenRegistry | None = None, + attachment_dir: str | Path | None = None, ) -> ApplicationHTTPServer: repo_root = Path(__file__).resolve().parent.parent database_path = database or os.environ.get( @@ -480,7 +1296,25 @@ def create_server( hermes = hermes_client or HermesClient( os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642") ) - return ApplicationHTTPServer((host, port), Store(database_path), hermes, static_path) + registry = screens if screens is not None else ScreenRegistry.from_env() + # Uploads live beside the database by default so a deployment only has to + # provision one writable directory. + uploads = attachment_dir or os.environ.get("HERMES_VISUAL_UPLOAD_DIR") + if not uploads: + if str(database_path) == ":memory:": + uploads = str(Path.home() / ".rabbithole" / "uploads") + else: + uploads = str(Path(database_path).expanduser().resolve().parent / "uploads") + storage = attachments_mod.AttachmentStorage(uploads) + return ApplicationHTTPServer( + (host, port), + Store(database_path), + hermes, + static_path, + registry, + storage, + os.environ.get("HERMES_VISUAL_AGENT_PATH_MAP", ""), + ) def main(argv: list[str] | None = None) -> int: diff --git a/backend/store.py b/backend/store.py index 851de98..bb76b14 100644 --- a/backend/store.py +++ b/backend/store.py @@ -2,8 +2,12 @@ from __future__ import annotations +import json import math +import secrets import sqlite3 +from collections import Counter +from collections.abc import Sequence from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -11,14 +15,22 @@ try: from .ownership import ( is_valid_id, + new_attachment_id, + new_drawing_id, new_node_id, + new_queued_follow_up_id, + new_screen_id, new_workspace_id, require_rabbithole_session_id, ) except ImportError: # pragma: no cover - permits `python backend/store.py` from ownership import ( # type: ignore is_valid_id, + new_attachment_id, + new_drawing_id, new_node_id, + new_queued_follow_up_id, + new_screen_id, new_workspace_id, require_rabbithole_session_id, ) @@ -43,10 +55,55 @@ class ValidationError(StoreError): _WORKSPACE_COLUMNS = "id, title, root_node_id, created_at, updated_at" _NODE_COLUMNS = ( "id, workspace_id, parent_node_id, hermes_session_id, title, prompt, " - "markdown, status, error, x, y, width, height, created_at, updated_at" + "markdown, status, error, x, y, width, height, header_color, model_alias, reasoning_effort, created_at, updated_at" +) +_SCREEN_COLUMNS = ( + "id, workspace_id, screen_key, title, x, y, width, height, created_at, updated_at" +) +_DRAWING_COLUMNS = ( + "id, workspace_id, title, snapshot, preview, x, y, width, height, created_at, updated_at" +) +_ATTACHMENT_COLUMNS = ( + "id, node_id, filename, media_type, kind, byte_size, storage_path, delivered, created_at" +) +_REASONING_LEVELS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"}) +DEFAULT_SCREEN_WIDTH = 960.0 +DEFAULT_SCREEN_HEIGHT = 620.0 +DEFAULT_DRAWING_WIDTH = 900.0 +DEFAULT_DRAWING_HEIGHT = 640.0 +# tldraw snapshots grow with shape count; cap the column so a runaway document +# cannot bloat the workspace database. +MAX_DRAWING_SNAPSHOT_BYTES = 8_000_000 +# The idle preview is a downscaled raster of the document. It is a cache, so it +# is capped well below the snapshot: anything larger means the rasteriser +# misbehaved and the window is better off falling back to the live editor. +MAX_DRAWING_PREVIEW_BYTES = 2_000_000 +HEADER_COLORS = ( + "#7c6cf2", # violet + "#3f7ee8", # blue + "#1596a8", # teal + "#17a673", # green + "#d9912b", # amber + "#db6d45", # coral + "#d6538f", # pink + "#8b6fd6", # lavender ) +def _pick_header_color(used: Sequence[str]) -> str: + """Pick a header color that is distinct from the ones already in use. + + Every new window must look different from its neighbours, so unused palette + entries always win. Once the palette is exhausted the least-used colors are + recycled first, which keeps the canvas as evenly spread as the palette + allows instead of clustering on a repeat. + """ + counts = Counter(color.strip().lower() for color in used if color) + fewest = min(counts.get(color, 0) for color in HEADER_COLORS) + candidates = [color for color in HEADER_COLORS if counts.get(color, 0) == fewest] + return secrets.choice(candidates) + + def utc_now() -> str: return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") @@ -62,6 +119,14 @@ def _clean_text(value: object, field: str, *, required: bool, maximum: int) -> s return cleaned +def _clean_settings(model: object, reasoning_effort: object) -> tuple[str, str]: + model_text = _clean_text(model, "model", required=True, maximum=500) + effort = _clean_text(reasoning_effort, "reasoning_effort", required=True, maximum=20).lower() + if effort not in _REASONING_LEVELS: + raise ValidationError("unsupported reasoning_effort") + return model_text, effort + + def _row_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: return dict(row) if row is not None else None @@ -99,14 +164,49 @@ def initialize(self) -> None: connection = self._connect() try: connection.executescript(schema) + columns = { + row["name"] for row in connection.execute("PRAGMA table_info(nodes)").fetchall() + } + if "header_color" not in columns: + connection.execute( + "ALTER TABLE nodes ADD COLUMN header_color TEXT NOT NULL DEFAULT ''" + ) + if "model_alias" not in columns: + connection.execute( + "ALTER TABLE nodes ADD COLUMN model_alias TEXT NOT NULL DEFAULT 'hermes-agent'" + ) + if "reasoning_effort" not in columns: + connection.execute( + "ALTER TABLE nodes ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'medium'" + ) + if "run_id" not in columns: + connection.execute( + "ALTER TABLE nodes ADD COLUMN run_id TEXT NOT NULL DEFAULT ''" + ) + # `drawing_windows` predates the preview column, so existing + # databases need it added rather than recreated. + drawing_columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(drawing_windows)").fetchall() + } + if drawing_columns and "preview" not in drawing_columns: + connection.execute( + "ALTER TABLE drawing_windows ADD COLUMN preview TEXT NOT NULL DEFAULT ''" + ) finally: self._close(connection) def create_workspace( - self, prompt: object, session_id: object, title: object | None = None + self, + prompt: object, + session_id: object, + title: object | None = None, + model: object = "hermes-agent", + reasoning_effort: object = "medium", ) -> tuple[dict[str, Any], dict[str, Any]]: prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) session = require_rabbithole_session_id(session_id) + model_alias, effort = _clean_settings(model, reasoning_effort) title_text = ( _clean_text(title, "title", required=True, maximum=500) if title is not None @@ -115,6 +215,7 @@ def create_workspace( workspace_id = new_workspace_id() node_id = new_node_id() now = utc_now() + header_color = secrets.choice(HEADER_COLORS) connection = self._connect() try: connection.execute("BEGIN IMMEDIATE") @@ -125,9 +226,12 @@ def create_workspace( connection.execute( """INSERT INTO nodes( id,workspace_id,parent_node_id,hermes_session_id,title,prompt, - markdown,status,error,x,y,width,height,ordinal,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,'pending','',0,0,520,620,0,?,?)""", - (node_id, workspace_id, None, session, title_text, prompt_text, "", now, now), + markdown,status,error,x,y,width,height,header_color,model_alias,reasoning_effort,ordinal,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,'pending','',0,0,520,620,?,?,?,0,?,?)""", + ( + node_id, workspace_id, None, session, title_text, prompt_text, "", + header_color, model_alias, effort, now, now, + ), ) connection.commit() workspace = self._workspace_in(connection, workspace_id) @@ -180,8 +284,8 @@ def create_child( connection.execute( """INSERT INTO nodes( id,workspace_id,parent_node_id,hermes_session_id,title,prompt, - markdown,status,error,x,y,width,height,ordinal,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,'pending','',?,?,520,620,?,?,?)""", + markdown,status,error,x,y,width,height,header_color,model_alias,reasoning_effort,ordinal,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,'pending','',?,?,520,620,?,?,?,?,?,?)""", ( node_id, parent["workspace_id"], @@ -192,6 +296,9 @@ def create_child( "", float(parent["x"]) + float(parent["width"]) + 80, float(parent["y"]) + sibling_count * (float(parent["height"]) + 80), + parent["header_color"], + parent["model_alias"], + parent["reasoning_effort"], ordinal, now, now, @@ -217,6 +324,97 @@ def create_child( finally: self._close(connection) + def create_standalone( + self, + workspace_id: object, + prompt: object, + session_id: object, + x: object, + y: object, + title: object | None = None, + model: object = "hermes-agent", + reasoning_effort: object = "medium", + ) -> dict[str, Any]: + if not is_valid_id(workspace_id, "rw"): + raise NotFoundError("workspace not found") + prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) + session = require_rabbithole_session_id(session_id) + model_alias, effort = _clean_settings(model, reasoning_effort) + title_text = ( + _clean_text(title, "title", required=True, maximum=500) + if title is not None + else prompt_text.splitlines()[0][:120] + ) + coordinates: list[float] = [] + for field, value in (("x", x), ("y", y)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + coordinates.append(float(value)) + node_id = new_node_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + workspace = self._workspace_in(connection, workspace_id) + if workspace is None: + raise NotFoundError("workspace not found") + # Chosen inside the transaction so concurrent window creation cannot + # read the same "already used" set and land on the same color. Only + # top-level nodes count: branches deliberately inherit their parent's + # color, so including them would skew the tally toward busy branches. + header_color = _pick_header_color( + [ + row[0] + for row in connection.execute( + "SELECT header_color FROM nodes " + "WHERE workspace_id = ? AND parent_node_id IS NULL", + (workspace_id,), + ).fetchall() + ] + ) + ordinal = connection.execute( + "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM nodes WHERE workspace_id = ?", + (workspace_id,), + ).fetchone()[0] + connection.execute( + """INSERT INTO nodes( + id,workspace_id,parent_node_id,hermes_session_id,title,prompt, + markdown,status,error,x,y,width,height,header_color,model_alias,reasoning_effort,ordinal,created_at,updated_at + ) VALUES(?,?,NULL,?,?,?,'','pending','',?,?,440,430,?,?,?,?,?,?)""", + ( + node_id, + workspace_id, + session, + title_text, + prompt_text, + *coordinates, + header_color, + model_alias, + effort, + ordinal, + now, + now, + ), + ) + connection.execute( + "UPDATE workspaces SET updated_at = ? WHERE id = ?", (now, workspace_id) + ) + connection.commit() + node = self._node_in(connection, node_id) + assert node is not None + return node + except (NotFoundError, ValidationError): + connection.rollback() + raise + except sqlite3.IntegrityError as exc: + connection.rollback() + raise ConflictError("node or session already exists") from exc + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + def _workspace_in(self, connection: sqlite3.Connection, workspace_id: str) -> dict[str, Any] | None: return _row_dict( connection.execute( @@ -251,7 +449,111 @@ def get_workspace(self, workspace_id: object) -> dict[str, Any]: f"SELECT {_NODE_COLUMNS} FROM nodes WHERE workspace_id = ? ORDER BY ordinal", (workspace_id,), ).fetchall() - return {"workspace": workspace, "nodes": [dict(row) for row in nodes]} + queued_followups = connection.execute( + """SELECT q.id,q.node_id,q.prompt,q.ordinal,q.created_at + FROM queued_followups q + JOIN nodes n ON n.id=q.node_id + WHERE n.workspace_id=? + ORDER BY q.created_at,q.id""", + (workspace_id,), + ).fetchall() + screens = connection.execute( + f"SELECT {_SCREEN_COLUMNS} FROM screen_windows WHERE workspace_id = ?" + " ORDER BY created_at, id", + (workspace_id,), + ).fetchall() + # Snapshots can reach megabytes; the canvas listing only needs + # window geometry and the idle preview. Each drawing frame fetches + # its own document when it is actually activated. + drawings = connection.execute( + "SELECT id, workspace_id, title, preview, x, y, width, height," + " created_at, updated_at" + " FROM drawing_windows WHERE workspace_id = ?" + " ORDER BY created_at, id", + (workspace_id,), + ).fetchall() + attachments = connection.execute( + f"""SELECT {", ".join("a." + column.strip() for column in _ATTACHMENT_COLUMNS.split(","))} + FROM attachments a + JOIN nodes n ON n.id=a.node_id + WHERE n.workspace_id=? + ORDER BY a.created_at, a.id""", + (workspace_id,), + ).fetchall() + return { + "workspace": workspace, + "nodes": [dict(row) for row in nodes], + "queued_followups": [dict(row) for row in queued_followups], + "screens": [dict(row) for row in screens], + "drawings": [dict(row) for row in drawings], + "attachments": [dict(row) for row in attachments], + } + finally: + self._close(connection) + + def archive_session(self, node_id: object, messages: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + try: + messages_json = json.dumps(messages, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + except (TypeError, ValueError) as exc: + raise ValidationError("session messages must be JSON-serializable") from exc + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + node = self._node_in(connection, node_id) + if node is None: + raise NotFoundError("node not found") + connection.execute( + """INSERT INTO session_archives(node_id,hermes_session_id,messages_json,saved_at) + VALUES(?,?,?,?) + ON CONFLICT(node_id) DO UPDATE SET + hermes_session_id=excluded.hermes_session_id, + messages_json=excluded.messages_json, + saved_at=excluded.saved_at""", + (node["id"], node["hermes_session_id"], messages_json, now), + ) + connection.commit() + return { + "node_id": node["id"], + "hermes_session_id": node["hermes_session_id"], + "messages": json.loads(messages_json), + "saved_at": now, + } + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def export_workspace(self, workspace_id: object) -> dict[str, Any]: + detail = self.get_workspace(workspace_id) + connection = self._connect() + try: + rows = connection.execute( + """SELECT node_id,hermes_session_id,messages_json,saved_at + FROM session_archives + WHERE node_id IN (SELECT id FROM nodes WHERE workspace_id=?) + ORDER BY saved_at, node_id""", + (detail["workspace"]["id"],), + ).fetchall() + sessions = [ + { + "node_id": row["node_id"], + "hermes_session_id": row["hermes_session_id"], + "messages": json.loads(row["messages_json"]), + "saved_at": row["saved_at"], + } + for row in rows + ] + return { + "format": "hermes-rabbithole-archive/v1", + "workspace": detail["workspace"], + "nodes": detail["nodes"], + "sessions": sessions, + "queued_followups": detail["queued_followups"], + } finally: self._close(connection) @@ -309,7 +611,7 @@ def start_node(self, node_id: object) -> dict[str, Any]: connection = self._connect() try: cursor = connection.execute( - "UPDATE nodes SET status='running', error='', updated_at=? WHERE id=? AND status='pending'", + "UPDATE nodes SET status='running', error='', run_id='', updated_at=? WHERE id=? AND status='pending'", (now, node_id), ) if cursor.rowcount != 1: @@ -323,41 +625,67 @@ def start_node(self, node_id: object) -> dict[str, Any]: finally: self._close(connection) - def append_markdown(self, node_id: object, delta: object) -> None: - if not isinstance(delta, str) or not delta: - return + def continue_node(self, node_id: object, prompt: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) + quoted_prompt = "\n".join( + f"> {line}" if line else ">" for line in prompt_text.splitlines() + ) + separator = f"\n\n---\n\n### Follow-up\n\n{quoted_prompt}\n\n" now = utc_now() connection = self._connect() try: cursor = connection.execute( - "UPDATE nodes SET markdown=markdown || ?, updated_at=? WHERE id=? AND status='running'", - (delta, now, node_id), + """UPDATE nodes + SET markdown=markdown || ?, status='running', error='', run_id='', updated_at=? + WHERE id=? AND status IN ('completed','failed')""", + (separator, now, node_id), ) if cursor.rowcount != 1: - raise ConflictError("node is not running") + row = connection.execute("SELECT status FROM nodes WHERE id=?", (node_id,)).fetchone() + if row is None: + raise NotFoundError("node not found") + raise ConflictError(f"node is already {row['status']}") + node = self._node_in(connection, node_id) + assert node is not None + return node finally: self._close(connection) - def complete_node(self, node_id: object, markdown: object | None = None) -> dict[str, Any]: - if markdown is not None and not isinstance(markdown, str): - raise ValidationError("markdown must be a string") + def retry_node(self, node_id: object) -> dict[str, Any]: + """Move a failed node back to running so its original prompt can re-run. + + The stored prompt and hermes_session_id are left untouched, so the retry + replays the same turn on the same session. Partial markdown from the + failed attempt is kept and separated by a marker: discarding it would + lose work the user already read, and appending keeps the document a + truthful record of what happened. + """ + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") now = utc_now() connection = self._connect() try: connection.execute("BEGIN IMMEDIATE") - if markdown is None: - cursor = connection.execute( - "UPDATE nodes SET status='completed', error='', updated_at=? WHERE id=? AND status='running'", - (now, node_id), - ) - else: - cursor = connection.execute( - "UPDATE nodes SET markdown=?, status='completed', error='', updated_at=? WHERE id=? AND status='running'", - (markdown, now, node_id), - ) + row = connection.execute( + "SELECT status, markdown FROM nodes WHERE id=?", (node_id,) + ).fetchone() + if row is None: + raise NotFoundError("node not found") + if row["status"] != "failed": + raise ConflictError(f"node is already {row['status']}") + # Only mark the boundary when there is earlier output to separate. + marker = "\n\n---\n\n### Retry\n\n" if row["markdown"] else "" + cursor = connection.execute( + """UPDATE nodes + SET markdown=markdown || ?, status='running', error='', run_id='', updated_at=? + WHERE id=? AND status='failed'""", + (marker, now, node_id), + ) if cursor.rowcount != 1: - raise ConflictError("node is not running") - node = self._node_in(connection, node_id) + raise ConflictError("node cannot be retried from its current state") + node = self._node_in(connection, str(node_id)) assert node is not None connection.execute( "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) @@ -370,60 +698,926 @@ def complete_node(self, node_id: object, markdown: object | None = None) -> dict finally: self._close(connection) - def fail_node(self, node_id: object, error: object) -> dict[str, Any]: - message = str(error).strip()[:2000] or "Hermes request failed" + def set_run_id(self, node_id: object, run_id: object) -> None: + """Record the in-flight Hermes run id so the node can be interrupted. + + Best-effort and confined to running nodes: a late event that arrives + after the node already settled must not resurrect a stale handle. + """ + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + value = str(run_id or "").strip()[:200] + connection = self._connect() + try: + connection.execute( + "UPDATE nodes SET run_id=? WHERE id=? AND status='running'", + (value, node_id), + ) + connection.commit() + finally: + self._close(connection) + + def get_run_id(self, node_id: object) -> str: + """Return the active run id for a running node, or '' when there is none.""" + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + connection = self._connect() + try: + row = connection.execute( + "SELECT status, run_id FROM nodes WHERE id=?", (node_id,) + ).fetchone() + if row is None: + raise NotFoundError("node not found") + if row["status"] != "running": + raise ConflictError(f"node is not running (it is {row['status']})") + return str(row["run_id"] or "") + finally: + self._close(connection) + + def merge_node(self, node_id: object, source_title: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + title = _clean_text(source_title, "source title", required=True, maximum=500) + marker = f"\n\n---\n\n### Merging from {title}\n\n" now = utc_now() connection = self._connect() try: - connection.execute("BEGIN IMMEDIATE") cursor = connection.execute( - "UPDATE nodes SET status='failed', error=?, updated_at=? WHERE id=? AND status IN ('pending','running')", - (message, now, node_id), + """UPDATE nodes + SET markdown=markdown || ?, status='running', error='', updated_at=? + WHERE id=? AND status='completed'""", + (marker, now, node_id), ) if cursor.rowcount != 1: - raise ConflictError("node cannot be failed from its current state") + row = connection.execute("SELECT status FROM nodes WHERE id=?", (node_id,)).fetchone() + if row is None: + raise NotFoundError("node not found") + raise ConflictError(f"node is already {row['status']}") node = self._node_in(connection, node_id) assert node is not None + return node + finally: + self._close(connection) + + def queue_follow_up(self, node_id: object, prompt: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) + queue_id = new_queued_follow_up_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + node = self._node_in(connection, node_id) + if node is None: + raise NotFoundError("node not found") + if node["status"] != "running": + raise ConflictError("node is not running") + ordinal = connection.execute( + "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM queued_followups WHERE node_id=?", + (node_id,), + ).fetchone()[0] connection.execute( - "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) + "INSERT INTO queued_followups(id,node_id,prompt,ordinal,created_at) VALUES(?,?,?,?,?)", + (queue_id, node_id, prompt_text, ordinal, now), ) connection.commit() - return node + return { + "id": queue_id, + "node_id": node_id, + "prompt": prompt_text, + "ordinal": ordinal, + "created_at": now, + } except Exception: connection.rollback() raise finally: self._close(connection) - def update_layout( - self, node_id: object, x: object, y: object, width: object, height: object - ) -> dict[str, Any]: - values: list[float] = [] - for field, value in (("x", x), ("y", y), ("width", width), ("height", height)): - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): - raise ValidationError(f"{field} must be a finite number") - values.append(float(value)) - if values[2] <= 0 or values[3] <= 0: - raise ValidationError("width and height must be greater than zero") - if values[2] > 10000 or values[3] > 10000: - raise ValidationError("width and height are too large") - now = utc_now() + def delete_node(self, node_id: object) -> dict[str, Any]: + """Delete a window and its descendants, deepest first. + + Returns the removed nodes so callers can release the matching Hermes + sessions. The workspace root is refused: it *is* the workspace. + """ + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") connection = self._connect() try: connection.execute("BEGIN IMMEDIATE") - cursor = connection.execute( - "UPDATE nodes SET x=?,y=?,width=?,height=?,updated_at=? WHERE id=?", - (*values, now, node_id), - ) - if cursor.rowcount != 1: + target = connection.execute( + f"SELECT {_NODE_COLUMNS} FROM nodes WHERE id = ?", (node_id,) + ).fetchone() + if target is None: raise NotFoundError("node not found") - node = self._node_in(connection, node_id) - assert node is not None + is_root = connection.execute( + "SELECT 1 FROM workspaces WHERE root_node_id = ?", (node_id,) + ).fetchone() + if is_root is not None: + raise ConflictError("the root window cannot be deleted") + + # Breadth-first collection, then reverse so children precede parents + # and the ON DELETE RESTRICT parent constraint is never violated. + ordered: list[dict[str, Any]] = [] + frontier = [target] + while frontier: + current = frontier.pop(0) + ordered.append(dict(current)) + frontier.extend( + connection.execute( + f"SELECT {_NODE_COLUMNS} FROM nodes WHERE parent_node_id = ? ORDER BY ordinal", + (current["id"],), + ).fetchall() + ) + ordered.reverse() + + for row in ordered: + connection.execute("DELETE FROM nodes WHERE id = ?", (row["id"],)) connection.execute( - "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) + "UPDATE workspaces SET updated_at = ? WHERE id = ?", + (utc_now(), target["workspace_id"]), ) connection.commit() - return node + return {"workspace_id": target["workspace_id"], "nodes": ordered} + except (NotFoundError, ConflictError, ValidationError): + connection.rollback() + raise + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def cancel_queued_follow_up(self, queue_id: object) -> dict[str, Any]: + if not is_valid_id(queue_id, "qf"): + raise NotFoundError("queued follow-up not found") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT id,node_id,prompt,ordinal,created_at FROM queued_followups WHERE id=?", (queue_id,) + ).fetchone() + if row is None: + raise NotFoundError("queued follow-up not found") + connection.execute("DELETE FROM queued_followups WHERE id=?", (queue_id,)) + connection.commit() + return dict(row) + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def take_queued_follow_up(self, node_id: object) -> dict[str, Any] | None: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """SELECT id,node_id,prompt,ordinal,created_at FROM queued_followups + WHERE node_id=? ORDER BY ordinal,id LIMIT 1""", + (node_id,), + ).fetchone() + if row is not None: + connection.execute("DELETE FROM queued_followups WHERE id=?", (row["id"],)) + connection.commit() + return dict(row) if row is not None else None + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def append_markdown(self, node_id: object, delta: object) -> None: + if not isinstance(delta, str) or not delta: + return + now = utc_now() + connection = self._connect() + try: + cursor = connection.execute( + "UPDATE nodes SET markdown=markdown || ?, updated_at=? WHERE id=? AND status='running'", + (delta, now, node_id), + ) + if cursor.rowcount != 1: + raise ConflictError("node is not running") + finally: + self._close(connection) + + def complete_node(self, node_id: object, markdown: object | None = None) -> dict[str, Any]: + if markdown is not None and not isinstance(markdown, str): + raise ValidationError("markdown must be a string") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + if markdown is None: + cursor = connection.execute( + "UPDATE nodes SET status='completed', error='', run_id='', updated_at=? WHERE id=? AND status='running'", + (now, node_id), + ) + else: + cursor = connection.execute( + "UPDATE nodes SET markdown=?, status='completed', error='', run_id='', updated_at=? WHERE id=? AND status='running'", + (markdown, now, node_id), + ) + if cursor.rowcount != 1: + raise ConflictError("node is not running") + node = self._node_in(connection, node_id) + assert node is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) + ) + connection.commit() + return node + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def fail_node(self, node_id: object, error: object) -> dict[str, Any]: + message = str(error).strip()[:2000] or "Hermes request failed" + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( + "UPDATE nodes SET status='failed', error=?, run_id='', updated_at=? WHERE id=? AND status IN ('pending','running')", + (message, now, node_id), + ) + if cursor.rowcount != 1: + raise ConflictError("node cannot be failed from its current state") + node = self._node_in(connection, node_id) + assert node is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) + ) + connection.commit() + return node + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def update_settings( + self, node_id: object, model: object, reasoning_effort: object + ) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + model_alias, effort = _clean_settings(model, reasoning_effort) + now = utc_now() + connection = self._connect() + try: + cursor = connection.execute( + """UPDATE nodes SET model_alias=?,reasoning_effort=?,updated_at=? + WHERE id=? AND status='completed'""", + (model_alias, effort, now, node_id), + ) + if cursor.rowcount != 1: + row = connection.execute("SELECT status FROM nodes WHERE id=?", (node_id,)).fetchone() + if row is None: + raise NotFoundError("node not found") + raise ConflictError("settings can only change after the session completes") + node = self._node_in(connection, node_id) + assert node is not None + return node + finally: + self._close(connection) + + def update_layout( + self, node_id: object, x: object, y: object, width: object, height: object + ) -> dict[str, Any]: + values: list[float] = [] + for field, value in (("x", x), ("y", y), ("width", width), ("height", height)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + values.append(float(value)) + if values[2] <= 0 or values[3] <= 0: + raise ValidationError("width and height must be greater than zero") + if values[2] > 10000 or values[3] > 10000: + raise ValidationError("width and height are too large") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( + "UPDATE nodes SET x=?,y=?,width=?,height=?,updated_at=? WHERE id=?", + (*values, now, node_id), + ) + if cursor.rowcount != 1: + raise NotFoundError("node not found") + node = self._node_in(connection, node_id) + assert node is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) + ) + connection.commit() + return node + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + # ------------------------------------------------------------------ + # Screen windows + # ------------------------------------------------------------------ + + def _screen_in( + self, connection: sqlite3.Connection, screen_id: str + ) -> dict[str, Any] | None: + return _row_dict( + connection.execute( + f"SELECT {_SCREEN_COLUMNS} FROM screen_windows WHERE id = ?", (screen_id,) + ).fetchone() + ) + + def create_screen( + self, + workspace_id: object, + screen_key: object, + title: object, + x: object, + y: object, + ) -> dict[str, Any]: + """Place a screen window on a workspace canvas. + + ``screen_key`` is validated against the server-side registry by the + request handler before it reaches the store; here it is only shape + checked so the database never holds arbitrary text. + """ + if not is_valid_id(workspace_id, "rw"): + raise NotFoundError("workspace not found") + key = _clean_text(screen_key, "screen_key", required=True, maximum=64) + title_text = _clean_text(title, "title", required=True, maximum=500) + coordinates: list[float] = [] + for field, value in (("x", x), ("y", y)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + coordinates.append(float(value)) + screen_id = new_screen_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + if self._workspace_in(connection, workspace_id) is None: + raise NotFoundError("workspace not found") + connection.execute( + """INSERT INTO screen_windows + (id,workspace_id,screen_key,title,x,y,width,height,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)""", + ( + screen_id, + workspace_id, + key, + title_text, + coordinates[0], + coordinates[1], + DEFAULT_SCREEN_WIDTH, + DEFAULT_SCREEN_HEIGHT, + now, + now, + ), + ) + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, workspace_id) + ) + screen = self._screen_in(connection, screen_id) + assert screen is not None + connection.commit() + return screen + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def get_screen(self, screen_id: object) -> dict[str, Any]: + if not is_valid_id(screen_id, "rs"): + raise NotFoundError("screen not found") + connection = self._connect() + try: + screen = self._screen_in(connection, screen_id) + if screen is None: + raise NotFoundError("screen not found") + return screen + finally: + self._close(connection) + + def update_screen_layout( + self, screen_id: object, x: object, y: object, width: object, height: object + ) -> dict[str, Any]: + if not is_valid_id(screen_id, "rs"): + raise NotFoundError("screen not found") + values: list[float] = [] + for field, value in (("x", x), ("y", y), ("width", width), ("height", height)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + values.append(float(value)) + if values[2] <= 0 or values[3] <= 0: + raise ValidationError("width and height must be greater than zero") + if values[2] > 10000 or values[3] > 10000: + raise ValidationError("width and height are too large") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( + "UPDATE screen_windows SET x=?,y=?,width=?,height=?,updated_at=? WHERE id=?", + (*values, now, screen_id), + ) + if cursor.rowcount != 1: + raise NotFoundError("screen not found") + screen = self._screen_in(connection, screen_id) + assert screen is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, screen["workspace_id"]) + ) + connection.commit() + return screen + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def delete_screen(self, screen_id: object) -> dict[str, Any]: + """Remove a screen window. Screens own no children and no Hermes session.""" + if not is_valid_id(screen_id, "rs"): + raise NotFoundError("screen not found") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + screen = self._screen_in(connection, screen_id) + if screen is None: + raise NotFoundError("screen not found") + connection.execute("DELETE FROM screen_windows WHERE id=?", (screen_id,)) + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, screen["workspace_id"]) + ) + connection.commit() + return {"deleted_screen_ids": [screen_id], "workspace_id": screen["workspace_id"]} + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + # ------------------------------------------------------------------ + # Drawing windows + # + # A drawing window owns one tldraw document stored as an opaque JSON + # snapshot. Drawings belong to the workspace only: they hold no Hermes + # session, take no parent, and are never fed to a model. + # ------------------------------------------------------------------ + + def _drawing_in( + self, connection: sqlite3.Connection, drawing_id: object + ) -> dict[str, Any] | None: + return _row_dict( + connection.execute( + f"SELECT {_DRAWING_COLUMNS} FROM drawing_windows WHERE id = ?", (drawing_id,) + ).fetchone() + ) + + def create_drawing( + self, workspace_id: object, title: object, x: object, y: object + ) -> dict[str, Any]: + """Place an empty drawing window on a workspace canvas.""" + if not is_valid_id(workspace_id, "rw"): + raise NotFoundError("workspace not found") + title_text = _clean_text(title, "title", required=False, maximum=500) or "Drawing" + coordinates: list[float] = [] + for field, value in (("x", x), ("y", y)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + coordinates.append(float(value)) + drawing_id = new_drawing_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + if self._workspace_in(connection, workspace_id) is None: + raise NotFoundError("workspace not found") + connection.execute( + """INSERT INTO drawing_windows + (id,workspace_id,title,snapshot,x,y,width,height,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)""", + ( + drawing_id, + workspace_id, + title_text, + "", + coordinates[0], + coordinates[1], + DEFAULT_DRAWING_WIDTH, + DEFAULT_DRAWING_HEIGHT, + now, + now, + ), + ) + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, workspace_id) + ) + drawing = self._drawing_in(connection, drawing_id) + assert drawing is not None + connection.commit() + return drawing + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def get_drawing(self, drawing_id: object) -> dict[str, Any]: + if not is_valid_id(drawing_id, "rd"): + raise NotFoundError("drawing not found") + connection = self._connect() + try: + drawing = self._drawing_in(connection, drawing_id) + if drawing is None: + raise NotFoundError("drawing not found") + return drawing + finally: + self._close(connection) + + def save_drawing_snapshot( + self, drawing_id: object, snapshot: object, preview: object = None + ) -> dict[str, Any]: + """Persist the tldraw document for a drawing window. + + The snapshot is treated as opaque: it is only checked for being valid + JSON of a sane size so a corrupt payload cannot poison the canvas on + reload. + + `preview` is an optional rendered image (a `data:image/...` URL) shown + while the window is idle. It is a cache of the snapshot, so a rejected + or absent preview never blocks the document itself from saving. + """ + if not is_valid_id(drawing_id, "rd"): + raise NotFoundError("drawing not found") + if not isinstance(snapshot, str): + raise ValidationError("snapshot must be a string") + if len(snapshot.encode("utf-8")) > MAX_DRAWING_SNAPSHOT_BYTES: + raise ValidationError("snapshot is too large") + if snapshot: + try: + parsed = json.loads(snapshot) + except (TypeError, ValueError) as exc: + raise ValidationError("snapshot must be valid JSON") from exc + if not isinstance(parsed, dict): + raise ValidationError("snapshot must be a JSON object") + preview_text = "" + if preview is not None: + if not isinstance(preview, str): + raise ValidationError("preview must be a string") + if preview and not preview.startswith("data:image/"): + raise ValidationError("preview must be a data:image URL") + if len(preview.encode("utf-8")) > MAX_DRAWING_PREVIEW_BYTES: + raise ValidationError("preview is too large") + preview_text = preview + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + if preview is None: + cursor = connection.execute( + "UPDATE drawing_windows SET snapshot=?,updated_at=? WHERE id=?", + (snapshot, now, drawing_id), + ) + else: + cursor = connection.execute( + "UPDATE drawing_windows SET snapshot=?,preview=?,updated_at=? WHERE id=?", + (snapshot, preview_text, now, drawing_id), + ) + if cursor.rowcount != 1: + raise NotFoundError("drawing not found") + drawing = self._drawing_in(connection, drawing_id) + assert drawing is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, drawing["workspace_id"]) + ) + connection.commit() + return drawing + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def rename_drawing(self, drawing_id: object, title: object) -> dict[str, Any]: + if not is_valid_id(drawing_id, "rd"): + raise NotFoundError("drawing not found") + title_text = _clean_text(title, "title", required=True, maximum=500) + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( + "UPDATE drawing_windows SET title=?,updated_at=? WHERE id=?", + (title_text, now, drawing_id), + ) + if cursor.rowcount != 1: + raise NotFoundError("drawing not found") + drawing = self._drawing_in(connection, drawing_id) + assert drawing is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, drawing["workspace_id"]) + ) + connection.commit() + return drawing + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def update_drawing_layout( + self, drawing_id: object, x: object, y: object, width: object, height: object + ) -> dict[str, Any]: + if not is_valid_id(drawing_id, "rd"): + raise NotFoundError("drawing not found") + values: list[float] = [] + for field, value in (("x", x), ("y", y), ("width", width), ("height", height)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + values.append(float(value)) + if values[2] <= 0 or values[3] <= 0: + raise ValidationError("width and height must be greater than zero") + if values[2] > 10000 or values[3] > 10000: + raise ValidationError("width and height are too large") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( + "UPDATE drawing_windows SET x=?,y=?,width=?,height=?,updated_at=? WHERE id=?", + (*values, now, drawing_id), + ) + if cursor.rowcount != 1: + raise NotFoundError("drawing not found") + drawing = self._drawing_in(connection, drawing_id) + assert drawing is not None + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, drawing["workspace_id"]) + ) + connection.commit() + return drawing + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def delete_drawing(self, drawing_id: object) -> dict[str, Any]: + """Remove a drawing window. Drawings own no children and no session.""" + if not is_valid_id(drawing_id, "rd"): + raise NotFoundError("drawing not found") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + drawing = self._drawing_in(connection, drawing_id) + if drawing is None: + raise NotFoundError("drawing not found") + connection.execute("DELETE FROM drawing_windows WHERE id=?", (drawing_id,)) + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, drawing["workspace_id"]) + ) + connection.commit() + return {"deleted_drawing_ids": [drawing_id], "workspace_id": drawing["workspace_id"]} + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + # ------------------------------------------------------------------ + # Attachments + # + # Files uploaded onto a node. Images ride into the prompt as inline + # ``image_url`` parts (real vision); every other kind is surfaced to the + # agent as a filesystem path note, mirroring how the Telegram adapter + # hands attachments to the agent via ``cache_media_bytes``. + # ------------------------------------------------------------------ + + def _attachment_in( + self, connection: sqlite3.Connection, attachment_id: object + ) -> dict[str, Any] | None: + return _row_dict( + connection.execute( + f"SELECT {_ATTACHMENT_COLUMNS} FROM attachments WHERE id = ?", (attachment_id,) + ).fetchone() + ) + + def create_attachment( + self, + node_id: object, + *, + filename: object, + media_type: object, + kind: object, + byte_size: object, + storage_path: object, + ) -> dict[str, Any]: + """Record one uploaded file against a node.""" + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + name = _clean_text(filename, "filename", required=True, maximum=255) + mime = _clean_text(media_type, "media_type", required=True, maximum=255) + if kind not in ("image", "video", "audio", "document"): + raise ValidationError("kind must be image, video, audio or document") + path_text = _clean_text(storage_path, "storage_path", required=False, maximum=4096) + try: + size = int(byte_size) + except (TypeError, ValueError): + raise ValidationError("byte_size must be an integer") from None + if size < 0: + raise ValidationError("byte_size must not be negative") + + attachment_id = new_attachment_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + node = self._node_in(connection, node_id) + if node is None: + raise NotFoundError("node not found") + connection.execute( + "INSERT INTO attachments" + "(id,node_id,filename,media_type,kind,byte_size,storage_path,delivered,created_at)" + " VALUES(?,?,?,?,?,?,?,0,?)", + (attachment_id, node_id, name, mime, kind, size, path_text, now), + ) + connection.execute("UPDATE nodes SET updated_at=? WHERE id=?", (now, node_id)) + connection.execute( + "UPDATE workspaces SET updated_at=? WHERE id=?", (now, node["workspace_id"]) + ) + connection.commit() + return self._attachment_in(connection, attachment_id) or {} + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def get_attachment(self, attachment_id: object) -> dict[str, Any]: + if not is_valid_id(attachment_id, "ra"): + raise NotFoundError("attachment not found") + connection = self._connect() + try: + attachment = self._attachment_in(connection, attachment_id) + if attachment is None: + raise NotFoundError("attachment not found") + return attachment + finally: + self._close(connection) + + def set_attachment_path(self, attachment_id: object, storage_path: object) -> dict[str, Any]: + """Record where an attachment's bytes were written. + + Split from ``create_attachment`` so the row id can name the file on + disk, which keeps a hostile upload filename out of the filesystem. + """ + if not is_valid_id(attachment_id, "ra"): + raise NotFoundError("attachment not found") + path_text = _clean_text(storage_path, "storage_path", required=True, maximum=4096) + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + attachment = self._attachment_in(connection, attachment_id) + if attachment is None: + raise NotFoundError("attachment not found") + connection.execute( + "UPDATE attachments SET storage_path=? WHERE id=?", (path_text, attachment_id) + ) + connection.commit() + return self._attachment_in(connection, attachment_id) or {} + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def list_attachments(self, node_id: object) -> list[dict[str, Any]]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + connection = self._connect() + try: + rows = connection.execute( + f"SELECT {_ATTACHMENT_COLUMNS} FROM attachments WHERE node_id = ?" + " ORDER BY created_at, id", + (node_id,), + ).fetchall() + return [dict(row) for row in rows] + finally: + self._close(connection) + + def take_pending_attachments(self, node_id: object) -> list[dict[str, Any]]: + """Claim a node's undelivered attachments, marking them delivered. + + Claiming is transactional so a re-run or a concurrent stream cannot + attach the same file to two turns. + """ + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + rows = connection.execute( + f"SELECT {_ATTACHMENT_COLUMNS} FROM attachments" + " WHERE node_id = ? AND delivered = 0 ORDER BY created_at, id", + (node_id,), + ).fetchall() + claimed = [dict(row) for row in rows] + if claimed: + connection.execute( + "UPDATE attachments SET delivered=1 WHERE node_id=? AND delivered=0", + (node_id,), + ) + connection.commit() + return claimed + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def reassign_pending_attachments( + self, from_node_id: object, to_node_id: object + ) -> list[dict[str, Any]]: + """Hand a node's *undelivered* attachments to another node. + + Branching starts a new child node, but the files were staged against + the card the user was looking at -- the parent. Without this the rows + stay pinned to the parent at ``delivered = 0``: the child turn goes out + with no image, and because the parent is already completed nothing ever + claims them again, so the upload is stranded for good. + + Only undelivered rows move. Anything already sent belongs to the turn + that sent it and must stay in the parent's transcript. Returns the rows + as they now are, so the caller can relocate the bytes to match. + """ + if not is_valid_id(from_node_id, "rn"): + raise NotFoundError("node not found") + if not is_valid_id(to_node_id, "rn"): + raise NotFoundError("node not found") + if from_node_id == to_node_id: + return [] + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + if self._node_in(connection, str(to_node_id)) is None: + raise NotFoundError("node not found") + rows = connection.execute( + f"SELECT {_ATTACHMENT_COLUMNS} FROM attachments" + " WHERE node_id = ? AND delivered = 0 ORDER BY created_at, id", + (from_node_id,), + ).fetchall() + if not rows: + connection.commit() + return [] + connection.execute( + "UPDATE attachments SET node_id=? WHERE node_id=? AND delivered=0", + (to_node_id, from_node_id), + ) + connection.execute("UPDATE nodes SET updated_at=? WHERE id=?", (now, to_node_id)) + connection.execute("UPDATE nodes SET updated_at=? WHERE id=?", (now, from_node_id)) + moved = [ + self._attachment_in(connection, row["id"]) or dict(row) for row in rows + ] + connection.commit() + return moved + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def delete_attachment(self, attachment_id: object) -> dict[str, Any]: + """Remove an attachment row and return it so callers can unlink bytes.""" + if not is_valid_id(attachment_id, "ra"): + raise NotFoundError("attachment not found") + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + attachment = self._attachment_in(connection, attachment_id) + if attachment is None: + raise NotFoundError("attachment not found") + if attachment["delivered"]: + raise ConflictError("attachment was already sent to Hermes") + connection.execute("DELETE FROM attachments WHERE id=?", (attachment_id,)) + connection.execute( + "UPDATE nodes SET updated_at=? WHERE id=?", (now, attachment["node_id"]) + ) + connection.commit() + return attachment except Exception: connection.rollback() raise diff --git a/backend/vncproxy.py b/backend/vncproxy.py new file mode 100644 index 0000000..ee12e2d --- /dev/null +++ b/backend/vncproxy.py @@ -0,0 +1,421 @@ +"""A stdlib WebSocket-to-RFB (VNC) bridge. + +noVNC speaks RFB over a WebSocket; a VNC server speaks RFB over a raw TCP +socket. This module bridges the two without pulling in ``websockify``. + +It does one thing beyond dumb byte shuffling: it **terminates RFB +authentication server-side**. The browser is offered security type 1 (None) +while the proxy performs the real handshake -- including VNC DES challenge +response (type 2) -- against the remote host using a password that never +leaves the backend. That keeps screen credentials out of the frontend and out +of SQLite, per the repository boundaries. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import select +import socket +import struct +import threading +from typing import Callable + +WS_GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +OPCODE_CONTINUATION = 0x0 +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE = 0x8 +OPCODE_PING = 0x9 +OPCODE_PONG = 0xA + +_RFB_SEC_NONE = 1 +_RFB_SEC_VNC_AUTH = 2 + +_HANDSHAKE_TIMEOUT = 15.0 +_MAX_FRAME_PAYLOAD = 8 * 1024 * 1024 + + +class ProxyError(Exception): + """Raised when the RFB handshake with the remote screen fails.""" + + +# -------------------------------------------------------------------------- +# WebSocket framing +# -------------------------------------------------------------------------- + + +def accept_key(client_key: str) -> str: + """Return the ``Sec-WebSocket-Accept`` value for a client key.""" + digest = hashlib.sha1(client_key.strip().encode("ascii") + WS_GUID).digest() + return base64.b64encode(digest).decode("ascii") + + +def encode_frame(payload: bytes, opcode: int = OPCODE_BINARY) -> bytes: + """Encode a single unmasked (server->client) WebSocket frame.""" + header = bytearray() + header.append(0x80 | opcode) + length = len(payload) + if length < 126: + header.append(length) + elif length < (1 << 16): + header.append(126) + header.extend(struct.pack(">H", length)) + else: + header.append(127) + header.extend(struct.pack(">Q", length)) + return bytes(header) + payload + + +class FrameDecoder: + """Incremental decoder for masked (client->server) WebSocket frames.""" + + def __init__(self) -> None: + self._buffer = bytearray() + + def feed(self, data: bytes) -> list[tuple[int, bytes]]: + """Add bytes and return every complete ``(opcode, payload)`` frame.""" + self._buffer.extend(data) + frames: list[tuple[int, bytes]] = [] + while True: + frame = self._take_frame() + if frame is None: + return frames + frames.append(frame) + + def _take_frame(self) -> tuple[int, bytes] | None: + buf = self._buffer + if len(buf) < 2: + return None + opcode = buf[0] & 0x0F + masked = bool(buf[1] & 0x80) + length = buf[1] & 0x7F + offset = 2 + if length == 126: + if len(buf) < offset + 2: + return None + length = struct.unpack(">H", bytes(buf[offset : offset + 2]))[0] + offset += 2 + elif length == 127: + if len(buf) < offset + 8: + return None + length = struct.unpack(">Q", bytes(buf[offset : offset + 8]))[0] + offset += 8 + if length > _MAX_FRAME_PAYLOAD: + raise ProxyError("websocket frame too large") + mask = b"" + if masked: + if len(buf) < offset + 4: + return None + mask = bytes(buf[offset : offset + 4]) + offset += 4 + if len(buf) < offset + length: + return None + payload = bytes(buf[offset : offset + length]) + if masked: + payload = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload)) + del buf[: offset + length] + return opcode, payload + + +# -------------------------------------------------------------------------- +# VNC (DES) authentication +# -------------------------------------------------------------------------- + +_DES_PC1 = [ + 56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, + 10, 2, 59, 51, 43, 35, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, + 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3, +] +_DES_PC2 = [ + 13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, + 40, 51, 30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, + 28, 31, +] +_DES_IP = [ + 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, + 13, 5, 63, 55, 47, 39, 31, 23, 15, 7, 56, 48, 40, 32, 24, 16, 8, 0, 58, 50, 42, 34, + 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, +] +_DES_FP = [ + 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, + 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, + 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25, 32, 0, 40, 8, 48, 16, 56, 24, +] +_DES_E = [ + 31, 0, 1, 2, 3, 4, 3, 4, 5, 6, 7, 8, 7, 8, 9, 10, 11, 12, 11, 12, 13, 14, 15, 16, + 15, 16, 17, 18, 19, 20, 19, 20, 21, 22, 23, 24, 23, 24, 25, 26, 27, 28, 27, 28, 29, + 30, 31, 0, +] +_DES_P = [ + 15, 6, 19, 20, 28, 11, 27, 16, 0, 14, 22, 25, 4, 17, 30, 9, 1, 7, 23, 13, 31, 26, 2, + 8, 18, 12, 29, 5, 21, 10, 3, 24, +] +_DES_SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1] +_DES_SBOX = [ + [14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7,0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8, + 4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0,15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13], + [15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10,3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5, + 0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15,13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9], + [10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8,13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1, + 13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7,1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12], + [7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15,13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9, + 10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4,3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14], + [2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9,14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6, + 4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14,11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3], + [12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11,10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8, + 9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6,4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13], + [4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1,13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6, + 1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2,6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12], + [13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7,1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2, + 7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8,2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11], +] + + +def _bytes_to_bits(data: bytes) -> list[int]: + return [(byte >> (7 - i)) & 1 for byte in data for i in range(8)] + + +def _bits_to_bytes(bits: list[int]) -> bytes: + out = bytearray(len(bits) // 8) + for index, bit in enumerate(bits): + if bit: + out[index // 8] |= 1 << (7 - (index % 8)) + return bytes(out) + + +def _des_subkeys(key: bytes) -> list[list[int]]: + bits = _bytes_to_bits(key) + permuted = [bits[i] for i in _DES_PC1] + left, right = permuted[:28], permuted[28:] + subkeys = [] + for shift in _DES_SHIFTS: + left = left[shift:] + left[:shift] + right = right[shift:] + right[:shift] + combined = left + right + subkeys.append([combined[i] for i in _DES_PC2]) + return subkeys + + +def _des_encrypt_block(key: bytes, block: bytes) -> bytes: + subkeys = _des_subkeys(key) + bits = _bytes_to_bits(block) + permuted = [bits[i] for i in _DES_IP] + left, right = permuted[:32], permuted[32:] + for subkey in subkeys: + expanded = [right[i] for i in _DES_E] + xored = [a ^ b for a, b in zip(expanded, subkey)] + out: list[int] = [] + for box in range(8): + chunk = xored[box * 6 : box * 6 + 6] + row = (chunk[0] << 1) | chunk[5] + column = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4] + value = _DES_SBOX[box][row * 16 + column] + out.extend([(value >> (3 - i)) & 1 for i in range(4)]) + mixed = [out[i] for i in _DES_P] + left, right = right, [a ^ b for a, b in zip(left, mixed)] + final = right + left + return _bits_to_bytes([final[i] for i in _DES_FP]) + + +def vnc_challenge_response(password: str, challenge: bytes) -> bytes: + """Return the RFB type-2 response for ``challenge``. + + VNC uses DES with each key byte's bit order reversed, and the password + truncated/padded to exactly 8 bytes. + """ + if len(challenge) != 16: + raise ProxyError("VNC challenge must be 16 bytes") + raw = password.encode("latin-1", "ignore")[:8].ljust(8, b"\x00") + key = bytes(int(f"{byte:08b}"[::-1], 2) for byte in raw) + return _des_encrypt_block(key, challenge[:8]) + _des_encrypt_block(key, challenge[8:]) + + +# -------------------------------------------------------------------------- +# RFB handshake +# -------------------------------------------------------------------------- + + +def _recv_exactly(sock: socket.socket, count: int) -> bytes: + chunks = bytearray() + while len(chunks) < count: + chunk = sock.recv(count - len(chunks)) + if not chunk: + raise ProxyError("remote screen closed the connection") + chunks.extend(chunk) + return bytes(chunks) + + +def authenticate(sock: socket.socket, password: str) -> bytes: + """Perform the RFB handshake up to (and including) ServerInit. + + Returns the ServerInit message so the caller can replay it to the browser, + which believes it negotiated security type ``None``. + """ + server_version = _recv_exactly(sock, 12) + if not server_version.startswith(b"RFB "): + raise ProxyError("remote host is not an RFB server") + # Apple servers advertise 003.889 but negotiate 003.008 correctly, which is + # the dialect that exposes the standard VNC auth type. + sock.sendall(b"RFB 003.008\n") + + count = _recv_exactly(sock, 1)[0] + if count == 0: + reason_length = struct.unpack(">I", _recv_exactly(sock, 4))[0] + reason = _recv_exactly(sock, reason_length).decode("utf-8", "replace") + raise ProxyError(f"remote screen refused the connection: {reason}") + offered = set(_recv_exactly(sock, count)) + + if _RFB_SEC_VNC_AUTH in offered and password: + sock.sendall(bytes([_RFB_SEC_VNC_AUTH])) + challenge = _recv_exactly(sock, 16) + sock.sendall(vnc_challenge_response(password, challenge)) + elif _RFB_SEC_NONE in offered: + sock.sendall(bytes([_RFB_SEC_NONE])) + elif _RFB_SEC_VNC_AUTH in offered: + raise ProxyError("remote screen requires a password but none is configured") + else: + raise ProxyError( + "remote screen offers no supported security type " + f"(got {sorted(offered)}); enable 'VNC viewers may control screen " + "with password' in macOS Screen Sharing" + ) + + result = struct.unpack(">I", _recv_exactly(sock, 4))[0] + if result != 0: + # 003.008 servers append a reason string on failure. + reason = "authentication failed" + try: + length = struct.unpack(">I", _recv_exactly(sock, 4))[0] + reason = _recv_exactly(sock, length).decode("utf-8", "replace") + except ProxyError: + pass + raise ProxyError(f"remote screen rejected the password: {reason}") + + # ClientInit: request a shared session so we never boot the console user. + sock.sendall(b"\x01") + header = _recv_exactly(sock, 24) + name_length = struct.unpack(">I", header[20:24])[0] + name = _recv_exactly(sock, name_length) + return header + name + + +def connect_screen(host: str, port: int, password: str, timeout: float = _HANDSHAKE_TIMEOUT): + """Open an authenticated RFB connection, returning ``(socket, server_init)``.""" + sock = socket.create_connection((host, port), timeout=timeout) + try: + sock.settimeout(timeout) + server_init = authenticate(sock, password) + sock.settimeout(None) + return sock, server_init + except Exception: + sock.close() + raise + + +# -------------------------------------------------------------------------- +# Bridging +# -------------------------------------------------------------------------- + + +def pump(ws_sock: socket.socket, vnc_sock: socket.socket, server_init: bytes, + should_stop: Callable[[], bool] | None = None) -> None: + """Shuttle bytes between an upgraded WebSocket and an authenticated RFB socket. + + The browser is handed the ``None`` security flow followed by the real + ServerInit, so noVNC proceeds as though it authenticated itself. + """ + decoder = FrameDecoder() + # Replay a synthetic 003.008 / security-None handshake to the browser. + ws_sock.sendall(encode_frame(b"RFB 003.008\n")) + state = {"phase": "version"} + + sockets = [ws_sock, vnc_sock] + try: + while True: + if should_stop is not None and should_stop(): + return + readable, _, errored = select.select(sockets, [], sockets, 1.0) + if errored: + return + for sock in readable: + if sock is vnc_sock: + data = vnc_sock.recv(65536) + if not data: + return + ws_sock.sendall(encode_frame(data)) + else: + data = ws_sock.recv(65536) + if not data: + return + for opcode, payload in decoder.feed(data): + if opcode == OPCODE_CLOSE: + return + if opcode == OPCODE_PING: + ws_sock.sendall(encode_frame(payload, OPCODE_PONG)) + continue + if opcode not in (OPCODE_BINARY, OPCODE_CONTINUATION, OPCODE_TEXT): + continue + remainder = _advance_client_handshake(state, payload, ws_sock, server_init) + if remainder: + vnc_sock.sendall(remainder) + except (OSError, ProxyError): + return + + +def _advance_client_handshake(state: dict, payload: bytes, ws_sock: socket.socket, + server_init: bytes) -> bytes: + """Absorb the browser's handshake bytes; return anything meant for the server. + + noVNC sends: ClientVersion (12) -> security type (1) -> ClientInit (1). + None of those reach the real server, which the proxy already handshook. + """ + phase = state["phase"] + if phase == "open": + return payload + + buffer = state.get("buffer", b"") + payload + while True: + if phase == "version": + if len(buffer) < 12: + break + buffer = buffer[12:] + # Offer exactly one security type: None. + ws_sock.sendall(encode_frame(b"\x01" + bytes([_RFB_SEC_NONE]))) + phase = "security" + elif phase == "security": + if len(buffer) < 1: + break + buffer = buffer[1:] + # SecurityResult: OK. + ws_sock.sendall(encode_frame(struct.pack(">I", 0))) + phase = "clientinit" + elif phase == "clientinit": + if len(buffer) < 1: + break + buffer = buffer[1:] + ws_sock.sendall(encode_frame(server_init)) + phase = "open" + state["phase"] = phase + state["buffer"] = b"" + return buffer + else: + break + state["phase"] = phase + state["buffer"] = buffer + return b"" + + +def bridge(ws_sock: socket.socket, host: str, port: int, password: str) -> None: + """Connect to ``host:port`` and bridge it onto an already-upgraded WebSocket.""" + vnc_sock, server_init = connect_screen(host, port, password) + try: + pump(ws_sock, vnc_sock, server_init) + finally: + try: + vnc_sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + vnc_sock.close() diff --git a/build-hermes-workspace.mjs b/build-hermes-workspace.mjs index b83b85a..d85c8fd 100644 --- a/build-hermes-workspace.mjs +++ b/build-hermes-workspace.mjs @@ -48,6 +48,64 @@ await esbuild.build({ logLevel: "silent", }); +// The drawing frame is a separate document/bundle on purpose: it carries React +// and the tldraw SDK, and it is served under a relaxed style-src that the main +// application must not inherit. See backend/server.py `_serve_static`. +await esbuild.build({ + entryPoints: [path.join(root, "src", "drawing-frame", "main.js")], + outfile: path.join(outdir, "drawing.js"), + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + minify: true, + sourcemap: false, + legalComments: "none", + logLevel: "silent", + // tldraw ships JSX in .js files. + loader: { ".js": "jsx" }, + // React ships dev-only branches behind this flag. + define: { "process.env.NODE_ENV": '"production"' }, +}); + +// tldraw fetches fonts, icons and translations at runtime. Left on the default +// CDN they are blocked by `connect-src 'self'` (and would break this +// tailnet-only app offline), so the asset tree is served from our own origin. +const tldrawAssetRoot = path.join(root, "node_modules", "@tldraw", "assets"); +for (const assetDir of ["fonts", "icons", "embed-icons", "translations"]) { + await fs.cp(path.join(tldrawAssetRoot, assetDir), path.join(outdir, assetDir), { + recursive: true, + }); +} +await fs.copyFile( + path.join(root, "node_modules", "tldraw", "tldraw.css"), + path.join(outdir, "tldraw.css"), +); + +await fs.writeFile( + path.join(outdir, "drawing.html"), + ` + + + + + + Drawing + + + + +
+ + + +`, + "utf8", +); + const workspaceStyles = await fs.readFile(path.join(root, "src", "hermes-workspace", "styles.css"), "utf8"); await fs.writeFile(path.join(outdir, "styles.css"), `${fontStyles}\n${CANVAS_STYLES}\n${workspaceStyles}`, "utf8"); await fs.writeFile(path.join(outdir, "index.html"), ` diff --git a/docs/hermes-workspace/API-CONTRACT.md b/docs/hermes-workspace/API-CONTRACT.md index a72e05e..1552dc1 100644 --- a/docs/hermes-workspace/API-CONTRACT.md +++ b/docs/hermes-workspace/API-CONTRACT.md @@ -13,6 +13,11 @@ All endpoints are same-origin. JSON errors use `{ "error": { "code", "message" } "markdown": "...", "status": "pending|running|completed|failed", "error": "", "x": 0, "y": 0, "width": 520, "height": 620, "created_at": "...", "updated_at": "..." + }, + "screen": { + "id": "rs_...", "workspace_id": "rw_...", "screen_key": "admin-mini", + "title": "Admin Mac mini", "x": 0, "y": 0, "width": 960, "height": 620, + "created_at": "...", "updated_at": "..." } } ``` @@ -28,6 +33,65 @@ All endpoints are same-origin. JSON errors use `{ "error": { "code", "message" } - `POST /api/nodes/{node_id}/branch/stream` body `{prompt, title?, selection?}` → fork parent Hermes session, create child and stream it. - `PATCH /api/nodes/{node_id}/layout` body `{x,y,width,height}`. +### Attachments + +Files uploaded onto a node and sent with its next turn. + +- `POST /api/nodes/{node_id}/attachments` — `multipart/form-data`, one or more + file parts. Max 20 MB per file (matching Hermes core's platform limit) and 10 + files per request. Returns `{attachments: [...]}`. +- `GET /api/nodes/{node_id}/attachments` → `{attachments: [...]}`. +- `GET /api/attachments/{attachment_id}/content` — the bytes. Images are served + `inline` for preview; every other kind is `attachment` so a click downloads. +- `DELETE /api/attachments/{attachment_id}` — only while pending; `409` once the + file has been handed to Hermes. +- `GET /api/workspaces/{workspace_id}` also returns an `attachments` array. + +The public shape is +`{id, node_id, filename, media_type, kind, byte_size, delivered, created_at, url}`. +`storage_path` is server-side only and never leaves the backend. + +**How attachments reach the model.** The Hermes session chat API accepts `text` +and `image_url`/`input_image` content parts and *rejects* `input_file` document +parts outright. So the backend splits by kind, exactly as +`gateway/platforms/base.py::cache_media_bytes` does for Telegram and the other +platform adapters: + +- **images** → inlined as a base64 `data:image/...` `image_url` part, so the + model genuinely sees them; +- **everything else** → written to disk and named in the text as + `[document 'name.csv' saved at: /path]`, which the agent opens with its own + tools. + +Attachments are claimed transactionally on the first turn that sends them, so a +re-run or a concurrent stream cannot deliver the same file twice. +`HERMES_VISUAL_UPLOAD_DIR` overrides the storage root (default: `uploads/` +beside the SQLite file) and `HERMES_VISUAL_AGENT_PATH_MAP` rewrites the paths in +those notes when the agent reaches the same bytes at a different absolute path. +Give it as `from:to` (e.g. `/root/.hermes:/opt/data` when the agent's +`/opt/data` is a bind mount of the host's `/root/.hermes`); a bare value with no +colon is prepended instead. Leave it unset when both processes share one +filesystem view. Getting this wrong is silent: images still work, because they +are inlined, but the agent finds nothing at the path named for other files. + +### Screens + +Screen windows stream a remote desktop (RFB/VNC) onto the canvas. Targets are +configured server-side via `HERMES_VISUAL_SCREENS`; the browser only ever +receives an opaque `key` and a display `label`, never a host or password. + +- `GET /api/screens` → `{screens: [{key, label}]}`, the configured catalog. +- `GET /api/workspaces/{workspace_id}` also returns a `screens` array. +- `POST /api/workspaces/{workspace_id}/screens` body `{screen_key, x, y}` → + places a screen window; 404 when `screen_key` is not configured. +- `PATCH /api/screens/{screen_id}/layout` body `{x,y,width,height}`. +- `DELETE /api/screens/{screen_id}` → `{deleted_screen_ids, workspace_id}`. +- `GET /api/screens/{screen_id}/stream` — WebSocket upgrade. The backend + authenticates to the remote host (RFB security type 2, DES challenge) and + then presents the browser a security-type-`None` handshake, so noVNC connects + without ever holding a credential. Returns `502 screen_unreachable` with the + underlying reason when the host refuses or is unavailable. + ## Normalized SSE Each event is framed as `event: \ndata: \n\n`. diff --git a/integrations/hermes-api-session-settings.patch b/integrations/hermes-api-session-settings.patch new file mode 100644 index 0000000..80405cf --- /dev/null +++ b/integrations/hermes-api-session-settings.patch @@ -0,0 +1,217 @@ +--- a/gateway/platforms/api_server.py ++++ b/gateway/platforms/api_server.py +@@ -1718,10 +1718,52 @@ + return routes + + def _resolve_route(self, model_alias: Any) -> Optional[Dict[str, Any]]: +- """Return the model_routes entry for *model_alias*, or None.""" +- if not self._model_routes or not isinstance(model_alias, str): ++ """Resolve static API routes, then the user's global model aliases.""" ++ if not isinstance(model_alias, str) or not model_alias.strip(): + return None +- return self._model_routes.get(model_alias) ++ alias = model_alias.strip() ++ configured = self._model_routes.get(alias) if self._model_routes else None ++ if configured: ++ return dict(configured) ++ if alias == self._model_name: ++ return None ++ try: ++ from hermes_cli.model_switch import _load_direct_aliases ++ ++ direct = _load_direct_aliases().get(alias.lower()) ++ if direct: ++ return { ++ "model": direct.model, ++ "provider": direct.provider, ++ "base_url": direct.base_url, ++ } ++ except Exception: ++ logger.debug("Could not resolve global model alias %s", alias, exc_info=True) ++ return None ++ ++ @staticmethod ++ def _session_runtime_settings(session: Optional[Dict[str, Any]]) -> Dict[str, str]: ++ """Extract only the client-safe per-session runtime controls.""" ++ if not isinstance(session, dict): ++ return {"model_alias": "hermes-agent", "reasoning_effort": ""} ++ model_alias = str(session.get("model") or "hermes-agent").strip() or "hermes-agent" ++ raw_config = session.get("model_config") ++ try: ++ config = json.loads(raw_config) if isinstance(raw_config, str) else raw_config ++ except (TypeError, ValueError): ++ config = {} ++ settings = config.get("api_session_settings", {}) if isinstance(config, dict) else {} ++ if isinstance(settings, dict): ++ selected = settings.get("model_alias") ++ if isinstance(selected, str) and selected.strip(): ++ model_alias = selected.strip() ++ effort = str(settings.get("reasoning_effort") or "").strip().lower() ++ else: ++ effort = "" ++ from hermes_constants import parse_reasoning_effort ++ if effort and parse_reasoning_effort(effort) is None: ++ effort = "" ++ return {"model_alias": model_alias, "reasoning_effort": effort} + + def _session_model_override_for(self, session_key: Optional[str]) -> Optional[Dict[str, Any]]: + """Return the gateway's session ``/model`` override for *session_key*, if any. +@@ -1801,6 +1843,21 @@ + if runtime_model: + model = runtime_model + ++ stored_session = None ++ if session_id: ++ try: ++ stored_session = self._ensure_session_db().get_session(session_id) ++ except Exception: ++ logger.debug("Could not load API session runtime settings for %s", session_id, exc_info=True) ++ stored_settings = self._session_runtime_settings(stored_session) ++ if route is None and stored_settings["model_alias"] != self._model_name: ++ route = self._resolve_route(stored_settings["model_alias"]) ++ if stored_settings["reasoning_effort"]: ++ from hermes_constants import parse_reasoning_effort ++ parsed_reasoning = parse_reasoning_effort(stored_settings["reasoning_effort"]) ++ if parsed_reasoning is not None: ++ reasoning_config = parsed_reasoning ++ + # Per-client model routing (model_routes config). The route was + # resolved from the request's ``model`` field by the HTTP handler. + # Precedence (highest first): session ``/model`` override → model_routes +@@ -1988,6 +2045,26 @@ + "parent": model_name, + }) + ++ seen_ids = {item["id"] for item in models} ++ try: ++ from hermes_cli.model_switch import _load_direct_aliases ++ ++ for alias, direct in _load_direct_aliases().items(): ++ if alias in seen_ids: ++ continue ++ seen_ids.add(alias) ++ models.append({ ++ "id": alias, ++ "object": "model", ++ "created": now, ++ "owned_by": direct.provider or "hermes", ++ "permission": [], ++ "root": direct.model or alias, ++ "parent": model_name, ++ }) ++ except Exception: ++ logger.debug("Could not enumerate global model aliases", exc_info=True) ++ + return web.json_response({"object": "list", "data": models}) + + async def _handle_capabilities(self, request: "web.Request") -> "web.Response": +@@ -2187,6 +2264,7 @@ + # callers only need to know whether those snapshots exist. + payload["has_system_prompt"] = bool(session.get("system_prompt")) + payload["has_model_config"] = bool(session.get("model_config")) ++ payload.update(APIServerAdapter._session_runtime_settings(session)) + return payload + + @staticmethod +@@ -2287,7 +2365,23 @@ + if len(session_id) > self._MAX_SESSION_HEADER_LEN: + return web.json_response(_openai_error("Session ID too long", code="invalid_session_id"), status=400) + +- model = body.get("model") or self._model_name ++ model = str(body.get("model") or self._model_name).strip() ++ if model != self._model_name and self._resolve_route(model) is None: ++ return web.json_response( ++ _openai_error(f"Unsupported model: {model}", code="unsupported_model"), status=400 ++ ) ++ reasoning_effort = str(body.get("reasoning_effort") or "").strip().lower() ++ from hermes_constants import parse_reasoning_effort ++ if reasoning_effort and parse_reasoning_effort(reasoning_effort) is None: ++ return web.json_response( ++ _openai_error("Unsupported reasoning_effort", code="invalid_reasoning_effort"), status=400 ++ ) ++ model_config = json.dumps({ ++ "api_session_settings": { ++ "model_alias": model, ++ "reasoning_effort": reasoning_effort, ++ } ++ }) + system_prompt = body.get("system_prompt") + if system_prompt is not None and not isinstance(system_prompt, str): + return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400) +@@ -2308,12 +2402,13 @@ + import time as _time + conn.execute( + """INSERT INTO sessions ( +- id, source, model, system_prompt, started_at +- ) VALUES (?, ?, ?, ?, ?)""", ++ id, source, model, model_config, system_prompt, started_at ++ ) VALUES (?, ?, ?, ?, ?, ?)""", + ( + session_id, + "api_server", +- str(model) if model else None, ++ model, ++ model_config, + system_prompt, + _time.time(), + ), +@@ -2372,12 +2467,39 @@ + body, err = await self._read_json_body(request) + if err: + return err +- allowed = {"title", "end_reason"} ++ allowed = {"title", "end_reason", "model", "reasoning_effort"} + unknown = sorted(set(body) - allowed) + if unknown: + return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400) + + db = await self._ensure_session_db_async() ++ if "model" in body or "reasoning_effort" in body: ++ current_settings = self._session_runtime_settings(session) ++ model = str(body.get("model", current_settings["model_alias"]) or "").strip() ++ if not model or (model != self._model_name and self._resolve_route(model) is None): ++ return web.json_response( ++ _openai_error(f"Unsupported model: {model}", code="unsupported_model"), status=400 ++ ) ++ reasoning_effort = str( ++ body.get("reasoning_effort", current_settings["reasoning_effort"]) or "" ++ ).strip().lower() ++ from hermes_constants import parse_reasoning_effort ++ if reasoning_effort and parse_reasoning_effort(reasoning_effort) is None: ++ return web.json_response( ++ _openai_error("Unsupported reasoning_effort", code="invalid_reasoning_effort"), status=400 ++ ) ++ raw_config = session.get("model_config") ++ try: ++ model_config = json.loads(raw_config) if isinstance(raw_config, str) else dict(raw_config or {}) ++ except (TypeError, ValueError): ++ model_config = {} ++ model_config["api_session_settings"] = { ++ "model_alias": model, ++ "reasoning_effort": reasoning_effort, ++ } ++ await asyncio.to_thread( ++ db.update_session_meta, session_id, json.dumps(model_config), model ++ ) + if "title" in body: + try: + await asyncio.to_thread(db.set_session_title, session_id, "" if body["title"] is None else str(body["title"])) +@@ -2443,10 +2565,18 @@ + # SessionDB's native parent_session_id/end_reason visibility model rather + # than inventing a parallel fork store. + await asyncio.to_thread(db.end_session, source_id, "branched") ++ raw_source_config = source.get("model_config") ++ try: ++ source_model_config = ( ++ json.loads(raw_source_config) if isinstance(raw_source_config, str) else raw_source_config ++ ) ++ except (TypeError, ValueError): ++ source_model_config = None + await asyncio.to_thread(db.create_session, + fork_id, + "api_server", + model=source.get("model"), ++ model_config=source_model_config if isinstance(source_model_config, dict) else None, + system_prompt=source.get("system_prompt"), + parent_session_id=source_id, + ) diff --git a/package-lock.json b/package-lock.json index 7d1a47c..ed5d6e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,14 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", + "@novnc/novnc": "^1.7.0", + "@tldraw/assets": "^5.2.5", "highlight.js": "^11.11.1", "katex": "^0.17.0", - "marked": "^15.0.7" + "marked": "^15.0.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tldraw": "^5.2.5" }, "devDependencies": { "esbuild": "0.28.1", @@ -466,24 +471,2196 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", "license": "OFL-1.1", "funding": { - "url": "https://github.com/sponsors/ayuhito" + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", + "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@novnc/novnc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", + "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", + "license": "MPL-2.0" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@tiptap/core": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz", + "integrity": "sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz", + "integrity": "sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz", + "integrity": "sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.29.2.tgz", + "integrity": "sha512-kzcWarcpr031rZu+R3Hzut5uxb3Qj/xxMDEYZIQ3uiq1yb5vEMXj8/SIyWautk6Nu8Rr1leV3rGdR4NzhzyFAA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz", + "integrity": "sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz", + "integrity": "sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz", + "integrity": "sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz", + "integrity": "sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz", + "integrity": "sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.29.2" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.29.2.tgz", + "integrity": "sha512-CBTq4Xs5aGWr65uFCIkKBms796OhmirWf/ax//iJ4fl9IfwqjwFuc2vQs9PmuwpKn9ctDHo2sMTtvyeCvIt5LQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz", + "integrity": "sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.29.2" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz", + "integrity": "sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz", + "integrity": "sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.29.2.tgz", + "integrity": "sha512-bSeZ1C8OlthyK7BzgtqOGfQymdap9V38W2hbr9KGlhVOIAmTqneOwjm27iFcu7jKqkMArVu6WBYN18Lom6umHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz", + "integrity": "sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz", + "integrity": "sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz", + "integrity": "sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz", + "integrity": "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz", + "integrity": "sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz", + "integrity": "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz", + "integrity": "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz", + "integrity": "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz", + "integrity": "sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz", + "integrity": "sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" } }, - "node_modules/@fontsource/ibm-plex-sans": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", - "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", - "license": "OFL-1.1", + "node_modules/@tiptap/extension-underline": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz", + "integrity": "sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ayuhito" + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz", + "integrity": "sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz", + "integrity": "sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.29.2.tgz", + "integrity": "sha512-ZMKgteYmZXnq7C22U9fbCTC6jAF8XlQM5n2K2FLWCdYAlP4FNV3XeQ9hY2a13KSQ0Q3yltWXZlcWBpt5ClxScg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.29.2", + "@tiptap/extension-floating-menu": "^3.29.2" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz", + "integrity": "sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.29.2", + "@tiptap/extension-blockquote": "^3.29.2", + "@tiptap/extension-bold": "^3.29.2", + "@tiptap/extension-bullet-list": "^3.29.2", + "@tiptap/extension-code": "^3.29.2", + "@tiptap/extension-code-block": "^3.29.2", + "@tiptap/extension-document": "^3.29.2", + "@tiptap/extension-dropcursor": "^3.29.2", + "@tiptap/extension-gapcursor": "^3.29.2", + "@tiptap/extension-hard-break": "^3.29.2", + "@tiptap/extension-heading": "^3.29.2", + "@tiptap/extension-horizontal-rule": "^3.29.2", + "@tiptap/extension-italic": "^3.29.2", + "@tiptap/extension-link": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-list-item": "^3.29.2", + "@tiptap/extension-list-keymap": "^3.29.2", + "@tiptap/extension-ordered-list": "^3.29.2", + "@tiptap/extension-paragraph": "^3.29.2", + "@tiptap/extension-strike": "^3.29.2", + "@tiptap/extension-text": "^3.29.2", + "@tiptap/extension-underline": "^3.29.2", + "@tiptap/extensions": "^3.29.2", + "@tiptap/pm": "^3.29.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tldraw/assets": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/assets/-/assets-5.2.5.tgz", + "integrity": "sha512-dnwCWb9x/ZVUqRcKGEtgyvzwuaK4ZMcHl5WUA0u99Updr7F39dUDCorqjq7Lye3NrFyj0ztxSCy289s6hUm1gg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@tldraw/utils": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@tldraw/driver": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/driver/-/driver-5.2.5.tgz", + "integrity": "sha512-smu4NnqTdIM59/hHTvuXxqJWQ5aLpFUR59ibS//o30P9pgMAoN4v/7NhYx9g+V7RAeOzIpDqREouz3hZdoEt9Q==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@tldraw/editor": "5.2.5", + "@tldraw/utils": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@tldraw/editor": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/editor/-/editor-5.2.5.tgz", + "integrity": "sha512-/2aIuOLik7+tIzdmnjZ5XuxPRfSzZnNXVbxqCiFIVgbwe1W4jwm2lr2K4EpGduzdnbTNNvh6nPDJGg/jamAvnA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@tiptap/core": "^3.12.1", + "@tiptap/pm": "^3.12.1", + "@tiptap/react": "^3.12.1", + "@tldraw/state": "5.2.5", + "@tldraw/state-react": "5.2.5", + "@tldraw/store": "5.2.5", + "@tldraw/tlschema": "5.2.5", + "@tldraw/utils": "5.2.5", + "@tldraw/validate": "5.2.5", + "classnames": "^2.5.1", + "eventemitter3": "^4.0.7", + "idb": "^7.1.1", + "is-plain-object": "^5.0.0", + "rbush": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19.2.1", + "react-dom": "^18.2.0 || ^19.2.1" + } + }, + "node_modules/@tldraw/state": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/state/-/state-5.2.5.tgz", + "integrity": "sha512-fTn6YG6+MMlc3Rshb0/9k2YQ5TnKjy9FLwiGD7ph5maj9quSdpWQJNRgYxk7o2tRd/m7j+NhgylIb/F+2iKwbw==", + "license": "MIT", + "dependencies": { + "@tldraw/utils": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@tldraw/state-react": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/state-react/-/state-react-5.2.5.tgz", + "integrity": "sha512-/DbTo2Oulg/ZlQwfqlyJq+rjpYBIdwl9iELB01ImQ3aDIfM5TEyabc65baj/eCW3UNlFwKVTRft9m5z4jlPbVw==", + "license": "MIT", + "dependencies": { + "@tldraw/state": "5.2.5", + "@tldraw/utils": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19.2.1", + "react-dom": "^18.2.0 || ^19.2.1" + } + }, + "node_modules/@tldraw/store": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/store/-/store-5.2.5.tgz", + "integrity": "sha512-d3sWGoFH1j9jip71VdbVYQC5WLebn2ydo2n1BG3J+PhRhv7DGP9B1bojPUd9lEPBlB4VaS6/BmP4zyk/sLmAnw==", + "license": "MIT", + "dependencies": { + "@tldraw/state": "5.2.5", + "@tldraw/utils": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19.2.1" + } + }, + "node_modules/@tldraw/tlschema": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/tlschema/-/tlschema-5.2.5.tgz", + "integrity": "sha512-Oe9f1e0pcO6M42RVeBTAMqUXdWMJTeCFnM9DsBvJoY94ukIau5huUARAJ2mIV+oqUQS3pkDOrDz/L0KSN9+Eig==", + "license": "MIT", + "dependencies": { + "@tldraw/state": "5.2.5", + "@tldraw/store": "5.2.5", + "@tldraw/utils": "5.2.5", + "@tldraw/validate": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19.2.1", + "react-dom": "^18.2.0 || ^19.2.1" + } + }, + "node_modules/@tldraw/utils": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/utils/-/utils-5.2.5.tgz", + "integrity": "sha512-8f2KqXVsPdXhZsGC2c1c+V19hpct7BQIcKnsq1b+zKUqDf7B/lNSHm/S63NeRhmsOTgzEIeOXVQYHX7OYXo2Xg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "lodash.isequal": "^4.5.0", + "lodash.isequalwith": "^4.4.0", + "lodash.throttle": "^4.1.1", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@tldraw/validate": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@tldraw/validate/-/validate-5.2.5.tgz", + "integrity": "sha512-FgFbMuKVdFUWyZFq0wr+ojZ/zeXlm4/Ed47duvYfDWoGkQ5r/j2q4vnfeB0MVyGHxGbvVMqDOgGbWPbCJoDspA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@tldraw/utils": "5.2.5" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -493,6 +2670,19 @@ "node": ">= 12" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -535,6 +2725,21 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -550,6 +2755,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -559,6 +2773,21 @@ "node": ">=12.0.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/katex": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", @@ -575,6 +2804,46 @@ "katex": "cli.js" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isequalwith": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", + "integrity": "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -587,6 +2856,12 @@ "node": ">= 18" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -619,6 +2894,374 @@ "node": ">=18" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/radix-ui": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "license": "MIT", + "dependencies": { + "quickselect": "^2.0.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/tldraw": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/tldraw/-/tldraw-5.2.5.tgz", + "integrity": "sha512-bTnOkeZhi+RgdHXPjxp0/nG6kbXzLC8ZlwNMpmGjWjT2Oel6uaJWni7PxFPIK4p2zInmnRamdU06ArDStLvPag==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@tiptap/core": "^3.12.1", + "@tiptap/extension-code": "^3.12.1", + "@tiptap/extension-highlight": "^3.12.1", + "@tiptap/extension-list": "^3.12.1", + "@tiptap/pm": "^3.12.1", + "@tiptap/react": "^3.12.1", + "@tiptap/starter-kit": "^3.12.1", + "@tldraw/driver": "5.2.5", + "@tldraw/editor": "5.2.5", + "@tldraw/store": "5.2.5", + "classnames": "^2.5.1", + "idb": "^7.1.1", + "lz-string": "^1.5.0", + "radix-ui": "^1.4.2" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "react": "^18.2.0 || ^19.2.1", + "react-dom": "^18.2.0 || ^19.2.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -632,6 +3275,64 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 62e261d..2d26819 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "node build-hermes-workspace.mjs", - "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs", + "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && PYTHONPATH=/opt/hermes /opt/hermes/.venv/bin/python -m unittest tests.hermes_workspace.hermes_api_overlay_test && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs && node test/hermes-workspace/queued-follow-up.test.mjs && node test/hermes-workspace/workspace-actions.test.mjs && node test/hermes-workspace/model-settings.test.mjs && node test/hermes-workspace/header-colors.test.mjs && node test/hermes-workspace/delete-window.test.mjs && node test/hermes-workspace/screen-window.test.mjs && node test/hermes-workspace/drawing-window.test.mjs", "check:types": "tsc --noEmit", "check:purity": "node scripts/check-ui-purity.mjs", "verify": "npm run build && npm test && npm run check:types && npm run check:purity" @@ -17,9 +17,14 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", + "@novnc/novnc": "^1.7.0", + "@tldraw/assets": "^5.2.5", "highlight.js": "^11.11.1", "katex": "^0.17.0", - "marked": "^15.0.7" + "marked": "^15.0.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tldraw": "^5.2.5" }, "devDependencies": { "esbuild": "0.28.1", diff --git a/scripts/start.py b/scripts/start.py index b9aeb15..46dea52 100644 --- a/scripts/start.py +++ b/scripts/start.py @@ -9,6 +9,33 @@ from read_api_key import read_key ROOT = Path(__file__).resolve().parent.parent + + +def load_tldraw_license() -> None: + """Load the public tldraw deployment key from the managed Hermes env file. + + The SDK checks its key in the browser, so the key is inevitably delivered + to the drawing frame. Keeping its source in the managed .env still keeps + it out of the checkout, service unit, logs, and build artifacts. + """ + if os.environ.get("TLDRAW_LICENSE_KEY"): + return + env_path = Path(os.environ.get("HERMES_ENV_FILE", "/opt/hermes-data/.env")) + try: + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if line.startswith("TLDRAW_LICENSE_KEY="): + value = line.partition("=")[2].strip() + if value: + os.environ["TLDRAW_LICENSE_KEY"] = value + return + except OSError: + # The drawing frame will show the SDK's normal license state if no + # managed environment file exists yet. + return + + +load_tldraw_license() host = os.environ.get("HERMES_VISUAL_HOST", "127.0.0.1") port = os.environ.get("HERMES_VISUAL_PORT", "4180") hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")).expanduser() diff --git a/src/drawing-frame/main.js b/src/drawing-frame/main.js new file mode 100644 index 0000000..f3808c5 --- /dev/null +++ b/src/drawing-frame/main.js @@ -0,0 +1,170 @@ +/** + * Drawing frame: a self-contained tldraw document. + * + * This module runs inside a sandboxed same-origin iframe, NOT on the main + * workspace canvas. It is isolated for one reason: tldraw applies inline style + * attributes at runtime, which CSP cannot cover with nonces or hashes, so this + * document is served with a relaxed `style-src`. Keeping it in its own frame + * means the main application - which renders model-authored markdown - keeps + * the strict policy. + * + * The drawing id arrives via the query string. Everything this frame reads and + * writes belongs to that one drawing; it never touches nodes or sessions. + */ + +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { Tldraw, getSnapshot, loadSnapshot } from "tldraw"; +import { getAssetUrls } from "@tldraw/assets/selfHosted"; + +// Point tldraw at our own copies of the fonts, icons and translations. Left on +// its defaults the SDK fetches these from cdn.tldraw.com, which `connect-src +// 'self'` blocks and which would break the editor offline on the tailnet. +const assetUrls = getAssetUrls({ baseUrl: "/" }); + +const SAVE_DEBOUNCE_MS = 900; + +const drawingId = new URLSearchParams(window.location.search).get("id") || ""; +const host = document.getElementById("drawing-root"); + +function post(status, extra) { + // Let the parent surface save state in the window header. + window.parent?.postMessage( + { type: "drawing-status", drawingId, status, ...extra }, + window.location.origin, + ); +} + +async function loadInitialState() { + const [snapshotResponse, licenseResponse] = await Promise.all([ + fetch(`/api/drawings/${encodeURIComponent(drawingId)}/snapshot`, { + headers: { Accept: "application/json" }, + }), + fetch("/api/drawings/license", { headers: { Accept: "application/json" } }), + ]); + if (!snapshotResponse.ok) throw new Error(`load failed (${snapshotResponse.status})`); + const snapshotPayload = await snapshotResponse.json(); + // A missing configuration endpoint must not cost a saved drawing: tldraw will + // surface its normal license state while the document still opens. + let licenseKey = ""; + if (licenseResponse.ok) { + const licensePayload = await licenseResponse.json(); + if (typeof licensePayload.licenseKey === "string") licenseKey = licensePayload.licenseKey; + } + return { snapshot: snapshotPayload.snapshot || "", licenseKey }; +} + +async function persist(snapshotText, preview) { + const body = preview === undefined + ? { snapshot: snapshotText } + : { snapshot: snapshotText, preview }; + const response = await fetch(`/api/drawings/${encodeURIComponent(drawingId)}/snapshot`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`save failed (${response.status})`); +} + +/** + * Rasterise the document for the idle preview. + * + * Returns undefined rather than throwing: the preview is a nicety, and a + * failure here must never cost the user their drawing. + */ +async function renderPreview(editor) { + try { + const shapes = editor.getCurrentPageShapes(); + if (!shapes.length) return ""; + const result = await editor.toImage(shapes, { + format: "png", + background: true, + scale: 0.5, + padding: 16, + }); + if (!result?.blob) return undefined; + // Guard against a pathological raster before it reaches the network. + if (result.blob.size > 1_500_000) return undefined; + return await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => resolve(undefined); + reader.readAsDataURL(result.blob); + }); + } catch { + return undefined; + } +} + +function mount({ snapshot: initialSnapshot, licenseKey }) { + let timer = null; + let inFlight = false; + let pending = false; + + const flush = async (editor) => { + if (inFlight) { + pending = true; + return; + } + inFlight = true; + try { + post("saving"); + const { document: doc, session } = getSnapshot(editor.store); + const preview = await renderPreview(editor); + await persist(JSON.stringify({ document: doc, session }), preview); + post("saved", typeof preview === "string" ? { preview } : undefined); + } catch (error) { + post("error"); + } finally { + inFlight = false; + if (pending) { + pending = false; + void flush(editor); + } + } + }; + + createRoot(host).render( + createElement(Tldraw, { + assetUrls, + licenseKey: licenseKey || undefined, + onMount: (editor) => { + if (initialSnapshot) { + try { + const parsed = JSON.parse(initialSnapshot); + loadSnapshot(editor.store, parsed); + // Camera state belongs to the temporary editor session, not the + // drawing document. Persisted session snapshots otherwise reopen + // a drawing at an arbitrary zoom (e.g. the 108% in the report). + // Keep the saved artwork, but start every edit session at 100%. + editor.setCamera({ ...editor.getCamera(), z: 1 }); + } catch { + // A corrupt snapshot must not wedge the editor; start blank. + post("error"); + } + } + // Persist only real document edits. Pointer moves and camera changes + // are `ephemeral` scopes and would otherwise save constantly. + editor.store.listen( + () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void flush(editor), SAVE_DEBOUNCE_MS); + }, + { source: "user", scope: "document" }, + ); + window.__DRAWING_READY__ = true; + // Exposed for acceptance tests to drive the editor directly. + window.__EDITOR_FOR_TEST__ = editor; + post("ready"); + }, + }), + ); +} + +loadInitialState() + .then(mount) + .catch(() => { + // Still mount an empty editor so the window is usable offline. + post("error"); + mount({ snapshot: "", licenseKey: "" }); + }); diff --git a/src/hermes-workspace/api.js b/src/hermes-workspace/api.js index a672a64..43b14ab 100644 --- a/src/hermes-workspace/api.js +++ b/src/hermes-workspace/api.js @@ -5,11 +5,32 @@ export async function listWorkspaces() { return Array.isArray(payload) ? payload : (payload.workspaces || []); } -export async function createWorkspace(prompt, title = "") { +export async function getModels() { + return requestJson("/api/models"); +} + +export async function createWorkspace(prompt, title = "", model = "hermes-agent", reasoningEffort = "medium") { return requestJson("/api/workspaces", { method: "POST", headers: JSON_HEADERS, - body: JSON.stringify({ prompt, ...(title ? { title } : {}) }), + body: JSON.stringify({ + prompt, + model, + reasoning_effort: reasoningEffort, + ...(title ? { title } : {}), + }), + }); +} + +export async function createStandaloneSession( + workspaceId, prompt, x, y, title = "", model = "hermes-agent", reasoningEffort = "medium", +) { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/nodes`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + prompt, x, y, model, reasoning_effort: reasoningEffort, ...(title ? { title } : {}), + }), }); } @@ -17,6 +38,57 @@ export async function loadWorkspace(workspaceId) { return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}`); } +export async function getWorkspaceArchive(workspaceId) { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/archive`); +} + +export async function queueFollowUp(nodeId, prompt) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/queue`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt }), + }); +} + +export async function cancelQueuedFollowUp(queueId) { + return requestJson(`/api/queued-follow-ups/${encodeURIComponent(queueId)}`, { + method: "DELETE", + }); +} + +export async function deleteNode(nodeId) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}`, { method: "DELETE" }); +} + +export async function uploadAttachments(nodeId, files) { + // multipart/form-data, with the boundary left to the browser: setting + // Content-Type by hand would omit it and the backend parser would reject. + const form = new FormData(); + for (const file of files) form.append("files", file, file.name); + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/attachments`, { + method: "POST", + body: form, + }); +} + +export async function listAttachments(nodeId) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/attachments`); +} + +export async function deleteAttachment(attachmentId) { + return requestJson(`/api/attachments/${encodeURIComponent(attachmentId)}`, { + method: "DELETE", + }); +} + +export async function updateNodeSettings(nodeId, model, reasoningEffort) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/settings`, { + method: "PATCH", + headers: JSON_HEADERS, + body: JSON.stringify({ model, reasoning_effort: reasoningEffort }), + }); +} + export async function saveNodeLayout(node) { return requestJson(`/api/nodes/${encodeURIComponent(node.id)}/layout`, { method: "PATCH", @@ -25,6 +97,61 @@ export async function saveNodeLayout(node) { }); } +export async function getScreenCatalog() { + const payload = await requestJson("/api/screens"); + return payload.screens || []; +} + +export async function createDrawing(workspaceId, x, y) { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/drawings`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ x, y }), + }); +} + +export async function saveDrawingLayout(drawing) { + return requestJson(`/api/drawings/${encodeURIComponent(drawing.id)}/layout`, { + method: "PATCH", + headers: JSON_HEADERS, + body: JSON.stringify({ + x: drawing.x, + y: drawing.y, + width: drawing.width, + height: drawing.height, + }), + }); +} + +export async function deleteDrawing(drawingId) { + return requestJson(`/api/drawings/${encodeURIComponent(drawingId)}`, { method: "DELETE" }); +} + +export async function createScreen(workspaceId, screenKey, x, y) { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/screens`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ screen_key: screenKey, x, y }), + }); +} + +export async function saveScreenLayout(screen) { + return requestJson(`/api/screens/${encodeURIComponent(screen.id)}/layout`, { + method: "PATCH", + headers: JSON_HEADERS, + body: JSON.stringify({ x: screen.x, y: screen.y, width: screen.width, height: screen.height }), + }); +} + +export async function deleteScreen(screenId) { + return requestJson(`/api/screens/${encodeURIComponent(screenId)}`, { method: "DELETE" }); +} + +export function screenStreamUrl(screenId) { + const scheme = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${scheme}//${window.location.host}/api/screens/${encodeURIComponent(screenId)}/stream`; +} + export function streamRoot(nodeId, onEvent, options = {}) { return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/run/stream`, { method: "POST", @@ -41,6 +168,37 @@ export function streamBranch(nodeId, input, onEvent, options = {}) { }, onEvent); } +export function streamMerge(nodeId, destination, instruction, onEvent, options = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/merge/stream`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ destination, instruction }), + signal: options.signal, + }, onEvent); +} + +export function streamContinue(nodeId, prompt, onEvent, options = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/continue/stream`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt }), + signal: options.signal, + }, onEvent); +} + +export async function stopNode(nodeId) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/stop`, { + method: "POST", + }); +} + +export function streamRetry(nodeId, onEvent, options = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/retry/stream`, { + method: "POST", + signal: options.signal, + }, onEvent); +} + export async function getHealth() { return requestJson("/api/health", { cache: "no-store" }); } diff --git a/src/hermes-workspace/app.js b/src/hermes-workspace/app.js index 523281b..05390e6 100644 --- a/src/hermes-workspace/app.js +++ b/src/hermes-workspace/app.js @@ -2,28 +2,87 @@ import { createMarkdownRenderer } from "../core/markdown-renderer.js"; import { BUNNY_MARK_SVG } from "../core/html/bunny-markup.js"; import { LENSES } from "../core/model.js"; import { + cancelQueuedFollowUp, + createDrawing, + createScreen, + createStandaloneSession, createWorkspace, + deleteAttachment, + deleteDrawing, + deleteScreen, getHealth, + getModels, + getScreenCatalog, + getWorkspaceArchive, listWorkspaces, loadWorkspace, + queueFollowUp, + updateNodeSettings, + uploadAttachments, + deleteNode, saveNodeLayout, + saveDrawingLayout, + saveScreenLayout, streamBranch, + streamContinue, + streamMerge, streamRoot, + streamRetry, + stopNode, } from "./api.js"; +import { + MIN_SCREEN_SIZE, + applyScreenGeometry, + connectScreen, + createScreenCard, + disconnectAllScreens, + disconnectScreen, + normalizeScreen, +} from "./screens.js"; +import { + MIN_DRAWING_SIZE, + activateDrawing, + applyDrawingGeometry, + applyDrawingPreview, + createDrawingCard, + deactivateDrawing, + isDrawingActive, + normalizeDrawing, + setDrawingStatus, +} from "./drawings.js"; const LAST_WORKSPACE_KEY = "rh-hermes-last-workspace"; const VIEW_KEY_PREFIX = "rh-hermes-view:"; +const UNREAD_KEY_PREFIX = "rh-hermes-unread:"; const DEFAULT_SIZE = Object.freeze({ width: 440, height: 430 }); const OVERVIEW_ZOOM_THRESHOLD = 0.72; const FOCUS_ZOOM_THRESHOLD = 1.2; +const HEADER_COLOR_RE = /^#[0-9a-f]{6}$/i; const state = { workspaces: [], workspace: null, nodes: new Map(), selectedNodeId: null, mode: "canvas", railOpen: false, activeStreams: 0, camera: { x: 96, y: 96, scale: 1 }, layoutPatchCount: 0, - selectionByParent: new Map(), + selectionByParent: new Map(), queuedByNode: new Map(), unreadNodeIds: new Set(), + attachmentsByNode: new Map(), uploadingNodeIds: new Set(), + models: [{ id: "hermes-agent", label: "Default" }], + reasoningLevels: ["none", "minimal", "low", "medium", "high", "xhigh"], + defaults: { model: "hermes-agent", reasoning_effort: "medium" }, + screens: new Map(), screenCatalog: [], + drawings: new Map(), }; const renderer = createMarkdownRenderer({ encodeBase64: encodeUtf8Base64 }); let newDialog = null; +let newSessionDialog = null; +let screenDialog = null; +let pendingScreenPoint = null; +let canvasMenuPoint = null; +let mergeDialog = null; +let settingsDialog = null; +let deleteDialog = null; +let deleteNodeId = null; +let settingsNodeId = null; +let pendingSessionPoint = null; +let mergeSourceId = null; let selectionAsk = null; let pendingSelection = null; let selectionAnchorRect = null; @@ -40,10 +99,19 @@ boot().catch(showFatal); async function boot() { installEvents(); setHealth("checking", "Checking Hermes…"); - const [workspaces] = await Promise.all([ + const [workspaces, modelCatalog, screenCatalog] = await Promise.all([ listWorkspaces(), + getModels().catch(() => null), + getScreenCatalog().catch(() => []), getHealth().then(() => setHealth("ready", "Hermes ready")).catch(() => setHealth("offline", "Hermes unavailable")), ]); + state.screenCatalog = screenCatalog; + if (modelCatalog) { + state.models = modelCatalog.models || state.models; + state.reasoningLevels = modelCatalog.reasoning_levels || state.reasoningLevels; + state.defaults = modelCatalog.defaults || state.defaults; + } + populateSettingsControls(); state.workspaces = workspaces; renderWorkspaceList(); const lastId = safeStorageGet(LAST_WORKSPACE_KEY); @@ -61,6 +129,11 @@ function renderShell() {
+