From 98796a82c60cc6981e5f38414e94082f20ef44c6 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 28 Aug 2026 22:14:15 +0200 Subject: [PATCH] feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot --- .gitignore | 3 + pyproject.toml | 1 - src/beaver_gateway/backends/claude_sdk.py | 1 + src/beaver_gateway/cli.py | 1 + src/beaver_gateway/core/auth.py | 5 + src/beaver_gateway/core/conversations.py | 133 ++- src/beaver_gateway/core/turn_capture.py | 2 + .../frontends/admin/__init__.py | 8 +- .../frontends/admin/frontend.py | 1057 +++-------------- .../frontends/admin/templates/_layout.html | 206 ---- .../admin/templates/_token_created.html | 12 - .../admin/templates/_token_error.html | 1 - .../frontends/admin/templates/_token_row.html | 25 - .../frontends/admin/templates/audit.html | 37 - .../frontends/admin/templates/chat.html | 355 ------ .../frontends/admin/templates/dashboard.html | 490 -------- .../frontends/admin/templates/login.html | 83 -- .../frontends/admin/templates/pty.html | 146 --- .../frontends/admin/templates/pty_list.html | 46 - .../frontends/admin/templates/tokens.html | 64 - src/beaver_gateway/frontends/api/frontend.py | 520 +++++++- src/beaver_gateway/storage/__init__.py | 2 + src/beaver_gateway/storage/models.py | 34 + tests/test_api.py | 387 ++++++ tests/test_conversations.py | 9 +- ui/bun.lock | 9 +- ui/package.json | 3 +- ui/src/app.html | 20 +- ui/src/lib/api/client.ts | 323 +++++ ui/src/lib/api/live.svelte.ts | 140 +++ ui/src/lib/api/sse.ts | 66 + ui/src/lib/api/types.ts | 278 +++++ ui/src/lib/components/app-sidebar.svelte | 75 ++ ui/src/lib/components/bottom-nav.svelte | 79 ++ ui/src/lib/components/empty-state.svelte | 31 + ui/src/lib/components/error-note.svelte | 18 + ui/src/lib/components/kind-badge.svelte | 25 + ui/src/lib/components/limit-bar.svelte | 69 ++ ui/src/lib/components/live-dot.svelte | 50 + ui/src/lib/components/page-header.svelte | 44 + ui/src/lib/components/stat.svelte | 37 + ui/src/lib/components/status-pill.svelte | 39 + ui/src/lib/components/theme-toggle.svelte | 20 + .../components/ui/dialog/dialog-close.svelte | 16 + .../ui/dialog/dialog-content.svelte | 53 + .../ui/dialog/dialog-description.svelte | 17 + .../components/ui/dialog/dialog-footer.svelte | 32 + .../components/ui/dialog/dialog-header.svelte | 20 + .../ui/dialog/dialog-overlay.svelte | 17 + .../components/ui/dialog/dialog-portal.svelte | 7 + .../components/ui/dialog/dialog-title.svelte | 17 + .../ui/dialog/dialog-trigger.svelte | 16 + ui/src/lib/components/ui/dialog/dialog.svelte | 8 + ui/src/lib/components/ui/dialog/index.ts | 34 + .../dropdown-menu-checkbox-group.svelte | 16 + .../dropdown-menu-checkbox-item.svelte | 44 + .../dropdown-menu-content.svelte | 33 + .../dropdown-menu-group-heading.svelte | 22 + .../dropdown-menu/dropdown-menu-group.svelte | 14 + .../dropdown-menu/dropdown-menu-item.svelte | 27 + .../dropdown-menu/dropdown-menu-label.svelte | 24 + .../dropdown-menu/dropdown-menu-portal.svelte | 7 + .../dropdown-menu-radio-group.svelte | 16 + .../dropdown-menu-radio-item.svelte | 34 + .../dropdown-menu-separator.svelte | 17 + .../dropdown-menu-shortcut.svelte | 20 + .../dropdown-menu-sub-content.svelte | 17 + .../dropdown-menu-sub-trigger.svelte | 29 + .../ui/dropdown-menu/dropdown-menu-sub.svelte | 10 + .../dropdown-menu-trigger.svelte | 14 + .../ui/dropdown-menu/dropdown-menu.svelte | 10 + .../lib/components/ui/dropdown-menu/index.ts | 54 + ui/src/lib/components/ui/input/index.ts | 7 + ui/src/lib/components/ui/input/input.svelte | 54 + ui/src/lib/components/ui/label/index.ts | 7 + ui/src/lib/components/ui/label/label.svelte | 20 + ui/src/lib/components/ui/select/index.ts | 37 + .../ui/select/select-content.svelte | 45 + .../ui/select/select-group-heading.svelte | 21 + .../components/ui/select/select-group.svelte | 17 + .../components/ui/select/select-item.svelte | 40 + .../components/ui/select/select-label.svelte | 20 + .../components/ui/select/select-portal.svelte | 7 + .../select/select-scroll-down-button.svelte | 20 + .../ui/select/select-scroll-up-button.svelte | 20 + .../ui/select/select-separator.svelte | 18 + .../ui/select/select-trigger.svelte | 29 + ui/src/lib/components/ui/select/select.svelte | 11 + ui/src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 23 + ui/src/lib/components/ui/sheet/index.ts | 34 + .../components/ui/sheet/sheet-close.svelte | 8 + .../components/ui/sheet/sheet-content.svelte | 60 + .../ui/sheet/sheet-description.svelte | 17 + .../components/ui/sheet/sheet-footer.svelte | 20 + .../components/ui/sheet/sheet-header.svelte | 20 + .../components/ui/sheet/sheet-overlay.svelte | 17 + .../components/ui/sheet/sheet-portal.svelte | 7 + .../components/ui/sheet/sheet-title.svelte | 17 + .../components/ui/sheet/sheet-trigger.svelte | 8 + ui/src/lib/components/ui/sheet/sheet.svelte | 8 + ui/src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 + ui/src/lib/components/ui/sonner/index.ts | 1 + ui/src/lib/components/ui/sonner/sonner.svelte | 37 + ui/src/lib/components/ui/switch/index.ts | 7 + ui/src/lib/components/ui/switch/switch.svelte | 31 + ui/src/lib/components/ui/table/index.ts | 28 + .../lib/components/ui/table/table-body.svelte | 20 + .../components/ui/table/table-caption.svelte | 20 + .../lib/components/ui/table/table-cell.svelte | 20 + .../components/ui/table/table-footer.svelte | 20 + .../lib/components/ui/table/table-head.svelte | 20 + .../components/ui/table/table-header.svelte | 20 + .../lib/components/ui/table/table-row.svelte | 20 + ui/src/lib/components/ui/table/table.svelte | 22 + ui/src/lib/components/ui/tabs/index.ts | 21 + .../components/ui/tabs/tabs-content.svelte | 17 + .../lib/components/ui/tabs/tabs-list.svelte | 42 + .../components/ui/tabs/tabs-trigger.svelte | 23 + ui/src/lib/components/ui/tabs/tabs.svelte | 19 + ui/src/lib/components/ui/tooltip/index.ts | 19 + .../ui/tooltip/tooltip-content.svelte | 52 + .../ui/tooltip/tooltip-portal.svelte | 7 + .../ui/tooltip/tooltip-provider.svelte | 8 + .../ui/tooltip/tooltip-trigger.svelte | 10 + .../lib/components/ui/tooltip/tooltip.svelte | 8 + ui/src/lib/format.ts | 184 +++ ui/src/lib/gateway.svelte.ts | 134 +++ ui/src/lib/limits.ts | 11 + ui/src/lib/nav.ts | 38 + ui/src/lib/panel/activity-feed.svelte | 52 + ui/src/lib/panel/activity.svelte.ts | 396 ++++++ ui/src/lib/panel/bindings-list.svelte | 60 + ui/src/lib/panel/composer.svelte | 128 ++ ui/src/lib/panel/conversation-header.svelte | 292 +++++ ui/src/lib/panel/conversation-view.svelte | 194 +++ ui/src/lib/panel/conversation.svelte.ts | 48 + ui/src/lib/panel/history-view.svelte | 130 ++ ui/src/lib/panel/question-card.svelte | 75 ++ ui/src/lib/panel/queue-list.svelte | 36 + ui/src/lib/panel/raw-entries.svelte | 182 +++ ui/src/lib/panel/tool-node.svelte | 100 ++ ui/src/lib/panel/turn-card.svelte | 96 ++ ui/src/lib/session.svelte.ts | 92 ++ ui/src/routes/+layout.svelte | 57 +- ui/src/routes/+page.svelte | 433 ++++++- ui/src/routes/audit/+page.svelte | 105 ++ ui/src/routes/conversations/+page.svelte | 287 +++++ ui/src/routes/conversations/[id]/+page.svelte | 23 + ui/src/routes/jobs/+page.svelte | 81 ++ ui/src/routes/layout.css | 97 ++ ui/src/routes/login/+page.svelte | 70 ++ ui/src/routes/memory/+page.svelte | 193 +++ ui/src/routes/tokens/+page.svelte | 287 +++++ ui/src/routes/usage/+page.svelte | 299 +++++ ui/vite.config.ts | 10 + uv.lock | 66 - 158 files changed, 8459 insertions(+), 2531 deletions(-) delete mode 100644 src/beaver_gateway/frontends/admin/templates/_layout.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/_token_created.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/_token_error.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/_token_row.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/audit.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/chat.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/dashboard.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/login.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/pty.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/pty_list.html delete mode 100644 src/beaver_gateway/frontends/admin/templates/tokens.html create mode 100644 tests/test_api.py create mode 100644 ui/src/lib/api/client.ts create mode 100644 ui/src/lib/api/live.svelte.ts create mode 100644 ui/src/lib/api/sse.ts create mode 100644 ui/src/lib/api/types.ts create mode 100644 ui/src/lib/components/app-sidebar.svelte create mode 100644 ui/src/lib/components/bottom-nav.svelte create mode 100644 ui/src/lib/components/empty-state.svelte create mode 100644 ui/src/lib/components/error-note.svelte create mode 100644 ui/src/lib/components/kind-badge.svelte create mode 100644 ui/src/lib/components/limit-bar.svelte create mode 100644 ui/src/lib/components/live-dot.svelte create mode 100644 ui/src/lib/components/page-header.svelte create mode 100644 ui/src/lib/components/stat.svelte create mode 100644 ui/src/lib/components/status-pill.svelte create mode 100644 ui/src/lib/components/theme-toggle.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-close.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-content.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-description.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-footer.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-header.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-overlay.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-portal.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-title.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog-trigger.svelte create mode 100644 ui/src/lib/components/ui/dialog/dialog.svelte create mode 100644 ui/src/lib/components/ui/dialog/index.ts create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/index.ts create mode 100644 ui/src/lib/components/ui/input/index.ts create mode 100644 ui/src/lib/components/ui/input/input.svelte create mode 100644 ui/src/lib/components/ui/label/index.ts create mode 100644 ui/src/lib/components/ui/label/label.svelte create mode 100644 ui/src/lib/components/ui/select/index.ts create mode 100644 ui/src/lib/components/ui/select/select-content.svelte create mode 100644 ui/src/lib/components/ui/select/select-group-heading.svelte create mode 100644 ui/src/lib/components/ui/select/select-group.svelte create mode 100644 ui/src/lib/components/ui/select/select-item.svelte create mode 100644 ui/src/lib/components/ui/select/select-label.svelte create mode 100644 ui/src/lib/components/ui/select/select-portal.svelte create mode 100644 ui/src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 ui/src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 ui/src/lib/components/ui/select/select-separator.svelte create mode 100644 ui/src/lib/components/ui/select/select-trigger.svelte create mode 100644 ui/src/lib/components/ui/select/select.svelte create mode 100644 ui/src/lib/components/ui/separator/index.ts create mode 100644 ui/src/lib/components/ui/separator/separator.svelte create mode 100644 ui/src/lib/components/ui/sheet/index.ts create mode 100644 ui/src/lib/components/ui/sheet/sheet-close.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-content.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-description.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-footer.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-header.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-portal.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-title.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet.svelte create mode 100644 ui/src/lib/components/ui/skeleton/index.ts create mode 100644 ui/src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 ui/src/lib/components/ui/sonner/index.ts create mode 100644 ui/src/lib/components/ui/sonner/sonner.svelte create mode 100644 ui/src/lib/components/ui/switch/index.ts create mode 100644 ui/src/lib/components/ui/switch/switch.svelte create mode 100644 ui/src/lib/components/ui/table/index.ts create mode 100644 ui/src/lib/components/ui/table/table-body.svelte create mode 100644 ui/src/lib/components/ui/table/table-caption.svelte create mode 100644 ui/src/lib/components/ui/table/table-cell.svelte create mode 100644 ui/src/lib/components/ui/table/table-footer.svelte create mode 100644 ui/src/lib/components/ui/table/table-head.svelte create mode 100644 ui/src/lib/components/ui/table/table-header.svelte create mode 100644 ui/src/lib/components/ui/table/table-row.svelte create mode 100644 ui/src/lib/components/ui/table/table.svelte create mode 100644 ui/src/lib/components/ui/tabs/index.ts create mode 100644 ui/src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 ui/src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 ui/src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 ui/src/lib/components/ui/tabs/tabs.svelte create mode 100644 ui/src/lib/components/ui/tooltip/index.ts create mode 100644 ui/src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 ui/src/lib/components/ui/tooltip/tooltip-portal.svelte create mode 100644 ui/src/lib/components/ui/tooltip/tooltip-provider.svelte create mode 100644 ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 ui/src/lib/components/ui/tooltip/tooltip.svelte create mode 100644 ui/src/lib/format.ts create mode 100644 ui/src/lib/gateway.svelte.ts create mode 100644 ui/src/lib/limits.ts create mode 100644 ui/src/lib/nav.ts create mode 100644 ui/src/lib/panel/activity-feed.svelte create mode 100644 ui/src/lib/panel/activity.svelte.ts create mode 100644 ui/src/lib/panel/bindings-list.svelte create mode 100644 ui/src/lib/panel/composer.svelte create mode 100644 ui/src/lib/panel/conversation-header.svelte create mode 100644 ui/src/lib/panel/conversation-view.svelte create mode 100644 ui/src/lib/panel/conversation.svelte.ts create mode 100644 ui/src/lib/panel/history-view.svelte create mode 100644 ui/src/lib/panel/question-card.svelte create mode 100644 ui/src/lib/panel/queue-list.svelte create mode 100644 ui/src/lib/panel/raw-entries.svelte create mode 100644 ui/src/lib/panel/tool-node.svelte create mode 100644 ui/src/lib/panel/turn-card.svelte create mode 100644 ui/src/lib/session.svelte.ts create mode 100644 ui/src/routes/audit/+page.svelte create mode 100644 ui/src/routes/conversations/+page.svelte create mode 100644 ui/src/routes/conversations/[id]/+page.svelte create mode 100644 ui/src/routes/jobs/+page.svelte create mode 100644 ui/src/routes/login/+page.svelte create mode 100644 ui/src/routes/memory/+page.svelte create mode 100644 ui/src/routes/tokens/+page.svelte create mode 100644 ui/src/routes/usage/+page.svelte diff --git a/.gitignore b/.gitignore index 04c2cf0..1de1aae 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ t/ # Local env .env db.db + +# Impeccable review screenshots +.impeccable/review/ diff --git a/pyproject.toml b/pyproject.toml index acd44ea..c23991c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ "fastmcp>=3.3.1", "greenlet>=3.5.0", "itsdangerous>=2.2.0", - "jinja2>=3.1.6", "psutil>=7.2.2", "psycopg[binary]>=3.3.4", "pydantic>=2.13.4", diff --git a/src/beaver_gateway/backends/claude_sdk.py b/src/beaver_gateway/backends/claude_sdk.py index ddbed32..0f5d992 100644 --- a/src/beaver_gateway/backends/claude_sdk.py +++ b/src/beaver_gateway/backends/claude_sdk.py @@ -848,6 +848,7 @@ def _usage_of(result: ResultMessage | None) -> TurnUsage: cost_usd=result.total_cost_usd, duration_ms=result.duration_ms, num_turns=result.num_turns, + model_usage=cast("dict[str, Any] | None", result.model_usage), ) diff --git a/src/beaver_gateway/cli.py b/src/beaver_gateway/cli.py index bd19015..78d6f1d 100644 --- a/src/beaver_gateway/cli.py +++ b/src/beaver_gateway/cli.py @@ -357,6 +357,7 @@ async def _build_backends( cost_usd=event.usage.cost_usd, duration_ms=event.usage.duration_ms, num_turns=event.usage.num_turns, + model_usage=event.usage.model_usage, ) try: async with db.session() as session: diff --git a/src/beaver_gateway/core/auth.py b/src/beaver_gateway/core/auth.py index b1824a7..df202aa 100644 --- a/src/beaver_gateway/core/auth.py +++ b/src/beaver_gateway/core/auth.py @@ -236,6 +236,11 @@ class TokenStore: self._flusher_task = None await self._flush_now() + def grant(self, name: str, value: str, *, scope: str = _BOOTSTRAP_SCOPE) -> None: + """Add an in-memory token for the process lifetime (the admin UI's bearer).""" + self._bootstrap_by_value[value] = name + self._bootstrap_scopes[name] = scope + async def invalidate(self) -> None: """Force the next verify to re-read from DB (Phase 4.3 admin hook).""" self._loaded_at = 0.0 diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index c3e2e38..53034ef 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any, cast from claude_agent_sdk import ( AssistantMessage, + RateLimitEvent, ResultMessage, StreamEvent, ToolResultBlock, @@ -43,6 +44,7 @@ from beaver_gateway.core.transcript import ( messages_from_entries, render_messages, strip_tool_entries, + text_of, window_entries, ) from beaver_gateway.core.turn_capture import TurnCapture @@ -51,6 +53,7 @@ from beaver_gateway.storage.models import ( Conversation, ConversationBinding, InjectQueueItem, + RateLimit, Schedule, ) @@ -133,6 +136,23 @@ class _Runner: wake: asyncio.Event = field(default_factory=asyncio.Event) task: asyncio.Task[None] | None = None turn_id: str | None = None + origin: str | None = None + text: str | None = None + started_at: datetime | None = None + tools: dict[str, dict[str, Any]] = field(default_factory=dict) + """Tool calls of the running turn, in order; ``describe`` hands them to a + panel that subscribed mid-turn.""" + + def snapshot(self) -> dict[str, Any] | None: + if self.turn_id is None: + return None + return { + "id": self.turn_id, + "origin": self.origin, + "text": self.text, + "started_at": _iso(self.started_at), + "tools": list(self.tools.values()), + } @dataclass(frozen=True, slots=True) @@ -442,8 +462,21 @@ class Conversations: live = self._pool.get(conv.external_id) out["live"] = live is not None out["busy"] = live.busy if live is not None else False + runner = self._runners.get(cast("int", conv.id)) + out["turn"] = runner.snapshot() if runner is not None else None + pending = self.pending_question(conv.external_id) + out["question"] = ( + {"id": pending[0], "questions": pending[1]} if pending else None + ) return out + async def rate_limits(self, *, limit: int = 100) -> list[RateLimit]: + async with self._db.session() as session: + result = await session.exec( + select(RateLimit).order_by(col(RateLimit.id).desc()).limit(limit) + ) + return list(result.all()) + # ---- routing ------------------------------------------------------- @property @@ -593,14 +626,21 @@ class Conversations: return ForkResult(conversation=child, text=text, capture=capture) async def read(self, conv: Conversation, *, window: int | None = None) -> str: + return render_messages(await self.history(conv), window=window) + + async def history(self, conv: Conversation) -> list[dict[str, Any]]: + return messages_from_entries(cast("Any", await self.entries(conv))) + + async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]: if conv.session_id is None: - return "" - entries = await self._store.load(cast("Any", self._store_key(conv))) - if not entries: - return "" - return render_messages( - messages_from_entries(cast("Any", entries)), window=window - ) + return [] + key = {**self._store_key(conv), "subpath": subpath} + return list(await self._store.load(cast("Any", key)) or []) + + async def subpaths(self, conv: Conversation) -> list[str]: + if conv.session_id is None: + return [] + return list(await self._store.list_subkeys(cast("Any", self._store_key(conv)))) async def inject( self, @@ -816,6 +856,10 @@ class Conversations: resume = session_id if session_id is not None else conv.session_id async with runner.lock: runner.turn_id = turn_id + runner.origin = origin + runner.text = _prompt_preview(messages) + runner.started_at = datetime.now(UTC) + runner.tools = {} await self._mark_running(conv, turn_id) self._bus.publish( "turn.start", @@ -823,6 +867,7 @@ class Conversations: turn_id=turn_id, origin=origin, item_origin=item_origin, + text=runner.text, ) stop = "error" cut = False @@ -836,7 +881,7 @@ class Conversations: kind=conv.kind, pinned=conv.kind == "master", tools=tools, - observer=self._observer(conv.external_id, turn_id, origin), + observer=self._observer(conv, runner, turn_id, origin), turn_id=turn_id, ) async for event in events: @@ -1117,11 +1162,15 @@ class Conversations: ) def _observer( - self, conversation_id: str, turn_id: str, origin: str + self, conv: Conversation, runner: _Runner, turn_id: str, origin: str ) -> Callable[[Any], None]: + conversation_id = conv.external_id + def observe(message: Any) -> None: parent = getattr(message, "parent_tool_use_id", None) - if isinstance(message, StreamEvent): + if isinstance(message, RateLimitEvent): + self._observe_rate_limit(conv, message) + elif isinstance(message, StreamEvent): self._bus.publish( "stream", conversation_id=conversation_id, @@ -1133,7 +1182,7 @@ class Conversations: elif isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, ToolUseBlock): - self._bus.publish( + event = self._bus.publish( "tool", conversation_id=conversation_id, turn_id=turn_id, @@ -1143,11 +1192,21 @@ class Conversations: name=block.name, input=block.input, ) + runner.tools[block.id] = { + "tool_use_id": block.id, + "name": block.name, + "input": block.input, + "parent_tool_use_id": parent, + "started_at": event["ts"], + "ended_at": None, + "is_error": None, + "content": None, + } elif isinstance(message, UserMessage): blocks = message.content if isinstance(message.content, list) else () for block in blocks: if isinstance(block, ToolResultBlock): - self._bus.publish( + event = self._bus.publish( "tool.result", conversation_id=conversation_id, turn_id=turn_id, @@ -1157,6 +1216,11 @@ class Conversations: is_error=bool(block.is_error), content=_result_preview(block.content), ) + node = runner.tools.get(block.tool_use_id) + if node is not None: + node["ended_at"] = event["ts"] + node["is_error"] = event["is_error"] + node["content"] = event["content"] elif isinstance(message, ResultMessage) and parent is None: self._bus.publish( "result", @@ -1170,6 +1234,40 @@ class Conversations: return observe + def _observe_rate_limit(self, conv: Conversation, message: RateLimitEvent) -> None: + info = message.rate_limit_info + row = RateLimit( + window=info.rate_limit_type or "unknown", + status=info.status, + utilization=info.utilization, + resets_at=_from_unix(info.resets_at), + overage_status=info.overage_status, + overage_resets_at=_from_unix(info.overage_resets_at), + agent_name=conv.agent_name, + session_id=message.session_id, + raw=dict(info.raw), + ) + self._bus.publish( + "rate_limit", + conversation_id=conv.external_id, + window=row.window, + status=row.status, + utilization=row.utilization, + resets_at=_iso(row.resets_at), + overage_status=row.overage_status, + ) + task = asyncio.create_task(self._record_rate_limit(row)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _record_rate_limit(self, row: RateLimit) -> None: + try: + async with self._db.session() as session: + session.add(row) + await session.commit() + except Exception: # noqa: BLE001 + _log.exception("rate limit write failed") + async def _mark_running(self, conv: Conversation, turn_id: str) -> None: async def apply(row: Conversation) -> None: row.running_turn = turn_id @@ -1254,6 +1352,17 @@ def _iso(value: datetime | None) -> str | None: return _aware(value).isoformat(timespec="seconds") if value is not None else None +def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None: + if not messages: + return None + text = text_of(messages[-1].get("content")) + return text[:limit] if text else None + + +def _from_unix(value: int | None) -> datetime | None: + return datetime.fromtimestamp(value, tz=UTC) if value is not None else None + + def _result_preview( content: str | list[dict[str, Any]] | None, limit: int = 400 ) -> str: diff --git a/src/beaver_gateway/core/turn_capture.py b/src/beaver_gateway/core/turn_capture.py index 5526b13..cfeacf2 100644 --- a/src/beaver_gateway/core/turn_capture.py +++ b/src/beaver_gateway/core/turn_capture.py @@ -24,6 +24,8 @@ class TurnUsage: cost_usd: float | None = None duration_ms: int | None = None num_turns: int | None = None + model_usage: dict[str, Any] | None = None + """``ResultMessage.model_usage`` verbatim: per-model tokens, cost, web searches.""" @dataclass diff --git a/src/beaver_gateway/frontends/admin/__init__.py b/src/beaver_gateway/frontends/admin/__init__.py index 94a050f..f50ad6f 100644 --- a/src/beaver_gateway/frontends/admin/__init__.py +++ b/src/beaver_gateway/frontends/admin/__init__.py @@ -1,10 +1,4 @@ -"""Admin UI (Phase 4.3). - -Browser-facing console: login, dashboard, token CRUD, audit viewer. -Templates live in ``./templates``; the package loader picks them up via -``importlib.resources`` so the layout works editable and inside the -Docker image without copy hacks. -""" +"""Admin console: the ``ui/`` SPA served from the admin port plus its login.""" from beaver_gateway.frontends.admin.frontend import AdminFrontend diff --git a/src/beaver_gateway/frontends/admin/frontend.py b/src/beaver_gateway/frontends/admin/frontend.py index f48b2bb..bcc2070 100644 --- a/src/beaver_gateway/frontends/admin/frontend.py +++ b/src/beaver_gateway/frontends/admin/frontend.py @@ -1,149 +1,82 @@ -"""Single-operator admin console. +"""Admin console: serves the ``ui/`` SPA and signs the operator in. -Jinja2 + HTMX, no SPA framework. Session cookies signed with -``SESSION_SECRET`` via ``itsdangerous`` (8h TTL). CSRF is double-submit: -the signed session payload carries a random token that must echo back -on every state-changing request as a ``csrf_token`` form field. +Everything the console shows comes from ``/api/*`` (``ApiFrontend``) +with a bearer. The admin port only owns three JSON routes under +``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env, +session cookie signed with ``SESSION_SECRET``, 8 h), logout, and +``session``, which hands a signed-in browser the process-lifetime admin +bearer plus the API origin - and the static build under ``/admin/``. -What lives in here: - -* ``GET /login`` / ``POST /login`` — env-cred check (``ADMIN_USER`` / - ``ADMIN_PASS`` compared with :func:`hmac.compare_digest`). -* ``POST /logout`` — clears the cookie. -* ``GET /`` — dashboard: declared agents + MCP namespaces + the last - audit slice. -* ``GET /tokens`` + ``POST /tokens`` + ``POST /tokens/{id}/revoke`` — - bearer-token CRUD. Plaintext is rendered exactly once at creation; the - DB only ever holds the Argon2 hash. HTMX fragments swap rows in place. -* ``GET /audit`` — paginated audit list (id-cursor). - -Things the admin frontend is *not* responsible for: HTTP-token verify -on ``/v1/messages`` and ``/mcp`` — that lives on those frontends and -uses :class:`TokenStore`. Admin's only authentication path is the -cookie session. +The bearer is minted at startup, registered in the token store with +scope ``*`` and never persisted; a gateway restart rotates it, and the +SPA refetches ``session`` on a 401. """ from __future__ import annotations -import asyncio -import contextlib import hmac import json import logging import secrets import time from collections import deque -from typing import TYPE_CHECKING, Annotated, Any -from urllib.parse import urlsplit +from pathlib import Path +from typing import TYPE_CHECKING, Any import itsdangerous -from fastapi import FastAPI, Form, HTTPException, Request, WebSocket, status -from fastapi.responses import ( - HTMLResponse, - RedirectResponse, - Response, - StreamingResponse, -) -from jinja2 import Environment, PackageLoader, select_autoescape +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from beaver_gateway.core import audit -from beaver_gateway.core.auth import VALID_SCOPES, hash_token -from beaver_gateway.core.turn_record import TurnRecord -from beaver_gateway.frontends._accumulate import StreamAccumulator from beaver_gateway.frontends.base import Frontend -from beaver_gateway.storage import ( - create_token, - list_audit_records, - list_tokens, - revoke_token, -) if TYPE_CHECKING: - from collections.abc import AsyncIterator - from datetime import datetime - - from anthropic.types import MessageParam - - from beaver_gateway.core.events import MessageStreamEvent from beaver_gateway.frontends.base import GatewayRuntime _log = logging.getLogger("beaver_gateway.frontends.admin") -# Cookie name is namespaced so a user can run multiple gateway instances -# behind one host header without sessions colliding. ``_v1`` lets us -# bump the payload schema later by invalidating old cookies via salt. +BASE = "/admin" SESSION_COOKIE = "beaver_admin_session" SESSION_MAX_AGE = 8 * 3600 -SESSION_SALT = "beaver-gateway.admin.session.v1" - -# Scopes the admin is allowed to mint. ``admin`` is reserved (no bearer -# route consumes it yet, but listing it keeps future programmatic -# admin access first-class) — see ``core.auth.VALID_SCOPES``. -CREATABLE_SCOPES = ("*", "messages", "mcp", "admin") - -# Audit-log page size — also used by the dashboard's "recent activity" -# panel. Small enough to keep the dashboard cheap, big enough to be -# useful at a glance. -AUDIT_PAGE_SIZE = 50 - -# /login brute-force ceiling: 5 failed attempts per source IP within -# LOGIN_WINDOW_SECONDS shuts the door with a 429 until the oldest -# failure ages out. Single-operator admin, so a legit user only ever -# burns one IP — a forgiving window beats a tight one with a lockout. +SESSION_SALT = "beaver-gateway.admin.session.v2" +ADMIN_TOKEN_NAME = "admin-ui" # noqa: S105 LOGIN_MAX_ATTEMPTS = 5 LOGIN_WINDOW_SECONDS = 300.0 - +DEFAULT_UI_DIR = Path(__file__).resolve().parents[4] / "ui" / "build" __all__ = ["AdminFrontend"] class AdminFrontend(Frontend): - """FastAPI app behind ``/login`` / ``/tokens`` / ``/audit`` / ``/``.""" - def __init__( self, *, host: str = "0.0.0.0", # noqa: S104 port: int = 8002, public_base_url: str | None = None, + ui_dir: Path | None = None, ) -> None: self.host = host self.port = port - # The reverse proxy in front (Caddy `handle_path /admin/*`, nginx - # `location /admin/`, …) may strip a path prefix before forwarding. - # If it does, the admin's self-emitted URLs (redirects, form - # actions, HTMX `hx-post`, nav links, JS `fetch`, the session - # cookie's `Path`) need that prefix back on or the browser will - # walk off the mounted subtree. We derive the prefix from - # ``public_base_url``'s path component: - # - # public_base_url=https://api.example.com/admin - # → url_prefix = "/admin" - # → links rendered as `/admin/login`, cookie scoped to /admin - # - # Empty / unset = root-mounted; the historical no-proxy behaviour. - if public_base_url: - self.public_base_url = public_base_url.rstrip("/") - self.url_prefix = urlsplit(self.public_base_url).path.rstrip("/") - else: - self.public_base_url = None - self.url_prefix = "" - self._runtime: GatewayRuntime | None = None + self.public_base_url = public_base_url.rstrip("/") if public_base_url else None + self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve() self._app: FastAPI | None = None def configure(self, runtime: GatewayRuntime) -> None: - # Refuse to wire up if the env didn't carry the bits we need — - # an admin UI with empty creds is a footgun, and an unsigned - # session cookie is no session at all. if not runtime.session_secret: msg = "AdminFrontend requires SESSION_SECRET in env" raise RuntimeError(msg) if not runtime.admin_user or not runtime.admin_pass: msg = "AdminFrontend requires ADMIN_USER and ADMIN_PASS in env" raise RuntimeError(msg) - self._runtime = runtime - self._app = self._build_app(runtime) + token = secrets.token_urlsafe(32) + runtime.token_store.grant(ADMIN_TOKEN_NAME, token) + self._app = build_app(runtime, token=token, ui_dir=self.ui_dir) + if not (self.ui_dir / "index.html").is_file(): + _log.warning( + "admin UI build missing at %s (run `make ui-build`)", self.ui_dir + ) async def serve(self) -> None: import uvicorn @@ -156,822 +89,151 @@ class AdminFrontend(Frontend): ) await uvicorn.Server(config).serve() - # ---- app construction ---------------------------------------------- - def _build_app(self, runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 - # Routes are declared inline so they close over ``runtime`` / - # ``signer`` / ``templates`` instead of threading them through - # self-state. Splitting the function would just trade size for - # bookkeeping — same total surface, harder to follow. - templates = _build_template_env() - url_prefix = self.url_prefix - signer = itsdangerous.URLSafeTimedSerializer( - runtime.session_secret, salt=SESSION_SALT +def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI: + app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None) + signer = itsdangerous.URLSafeTimedSerializer( + runtime.session_secret, salt=SESSION_SALT + ) + login_limit = _LoginRateLimit( + max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS + ) + index = ui_dir / "index.html" + + @app.get("/") + async def root() -> Response: + return RedirectResponse( + f"{BASE}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT ) - login_limit = _LoginRateLimit( - max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS + + @app.post(f"{BASE}/auth/login") + async def login(request: Request) -> Response: + try: + data = await request.json() + except json.JSONDecodeError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid JSON") from exc + username = str(data.get("username") or "") + password = str(data.get("password") or "") + ip = _client_ip(request) + if not login_limit.check(ip): + _log.warning("admin login rate-limited: ip=%s user=%r", ip, username) + await audit.log( + runtime, + actor=f"admin:{username}", + kind="login_failed", + reason="rate_limited", + ip=ip, + ) + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + "too many attempts; try again in a few minutes", + headers={"Retry-After": str(int(LOGIN_WINDOW_SECONDS))}, + ) + ok = hmac.compare_digest(username, runtime.admin_user) and hmac.compare_digest( + password, runtime.admin_pass ) - # Cookies scoped to ``url_prefix`` (or ``/`` at the root) so a - # single domain can host multiple gateways without cookie cross- - # talk. Browsers ignore a Set-Cookie / delete-cookie whose Path - # doesn't match, so the matching path also has to flow into - # ``_set_session_cookie`` *and* the logout `delete_cookie`. - cookie_path = url_prefix or "/" - app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None) - - def render(name: str, **ctx: Any) -> str: - # ``p`` is the prefix Jinja templates prepend to every - # internal path (``{{ p }}/login``, ``{{ p }}/tokens`` …). - # Injected here rather than on ``templates.globals`` because - # ty types the globals dict against Jinja's built-ins. - return templates.get_template(name).render(p=url_prefix, **ctx) - - # ---- login ---- - - @app.get("/login", response_class=HTMLResponse) - async def login_page(request: Request) -> Response: - # Already-signed-in users skip the form. Login flash is - # carried in a one-shot query param so a failed POST can - # redirect back here without leaking creds in the URL. - if _current_user(request, signer): - return RedirectResponse( - f"{url_prefix}/", status_code=status.HTTP_303_SEE_OTHER - ) - error = request.query_params.get("error") - return HTMLResponse(render("login.html", error=error)) - - @app.post("/login", response_class=HTMLResponse) - async def login_submit( - request: Request, - username: Annotated[str, Form(...)], - password: Annotated[str, Form(...)], - ) -> Response: - ip = _client_ip(request) - if not login_limit.check(ip): - _log.warning("admin login rate-limited: ip=%s user=%r", ip, username) - await audit.log( - runtime, - actor=f"admin:{username}", - kind="login_failed", - reason="rate_limited", - ip=ip, - ) - # 429 carries the rendered form back inline so the user - # sees the same page with an error banner — no redirect - # roundtrip, and an explicit Retry-After for any well- - # behaved client (or Caddy doing its own bookkeeping). - return HTMLResponse( - render( - "login.html", - error="too many attempts; try again in a few minutes", - ), - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - headers={"Retry-After": str(int(LOGIN_WINDOW_SECONDS))}, - ) - ok = hmac.compare_digest( - username, runtime.admin_user - ) and hmac.compare_digest(password, runtime.admin_pass) - if not ok: - login_limit.record_failure(ip) - _log.info("admin login failed: user=%r ip=%s", username, ip) - await audit.log( - runtime, - actor=f"admin:{username}", - kind="login_failed", - reason="bad_credentials", - ip=ip, - ) - # 303-redirect after POST so the browser doesn't replay - # the form on refresh. Status-303 forces GET on the - # follow-up regardless of the original method. - return RedirectResponse( - f"{url_prefix}/login?error=invalid+credentials", - status_code=status.HTTP_303_SEE_OTHER, - ) - login_limit.clear(ip) - csrf = secrets.token_urlsafe(24) - cookie = signer.dumps({"user": username, "csrf": csrf}) - response = RedirectResponse( - f"{url_prefix}/", status_code=status.HTTP_303_SEE_OTHER - ) - _set_session_cookie(response, cookie, path=cookie_path) - _log.info("admin login ok: user=%s ip=%s", username, ip) - await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip) - return response - - @app.post("/logout") - async def logout(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - await _require_csrf(request, session) - response = RedirectResponse( - f"{url_prefix}/login", status_code=status.HTTP_303_SEE_OTHER - ) - # `path` must match the Set-Cookie path, otherwise the - # browser silently keeps the cookie and re-login looks fine - # but the next request re-uses the stale session. - response.delete_cookie(SESSION_COOKIE, path=cookie_path) - await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout") - return response - - # ---- dashboard ---- - - @app.get("/", response_class=HTMLResponse) - async def dashboard(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - async with runtime.db.session() as db_session: - audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE) - tokens = await list_tokens(db_session, include_revoked=False) - endpoints = _build_endpoint_catalog(request, runtime) - return HTMLResponse( - render( - "dashboard.html", - user=session["user"], - csrf=session["csrf"], - agents=list(runtime.agents), - mcps=list(runtime.mcps), - audit=audit, - tokens=tokens, - endpoints=endpoints, - ) - ) - - # ---- tokens ---- - - @app.get("/tokens", response_class=HTMLResponse) - async def tokens_page(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - include_revoked = request.query_params.get("include_revoked") == "1" - async with runtime.db.session() as db_session: - tokens = await list_tokens(db_session, include_revoked=include_revoked) - return HTMLResponse( - render( - "tokens.html", - user=session["user"], - csrf=session["csrf"], - tokens=tokens, - scopes=CREATABLE_SCOPES, - include_revoked=include_revoked, - ) - ) - - @app.post("/tokens", response_class=HTMLResponse) - async def tokens_create( - request: Request, - name: Annotated[str, Form(...)], - scope: Annotated[str, Form(...)], - ) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - await _require_csrf(request, session) - name = name.strip() - if not name: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, "token name is required" - ) - if scope not in VALID_SCOPES: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, f"invalid scope: {scope!r}" - ) - # Generate the plaintext server-side — clients never pick - # their own. URL-safe so it copies cleanly into ``.env``. - plaintext = secrets.token_urlsafe(32) - hashed = hash_token(plaintext) - async with runtime.db.session() as db_session: - try: - row = await create_token( - db_session, name=name, scope=scope, hashed_value=hashed - ) - except Exception as exc: # noqa: BLE001 - # Likely UNIQUE(name) violation; the column is - # indexed so collisions are common. We surface a - # readable HTMX-friendly response rather than 500. - _log.warning("token create failed: %s", exc) - return HTMLResponse( - render( - "_token_error.html", - message=f"could not create token {name!r}: {exc}", - ), - status_code=status.HTTP_409_CONFLICT, - ) - await runtime.token_store.invalidate() + if not ok: + login_limit.record_failure(ip) + _log.info("admin login failed: user=%r ip=%s", username, ip) await audit.log( runtime, - actor=f"admin:{session['user']}", - kind="token_create", - name=name, - scope=scope, - token_id=row.id, + actor=f"admin:{username}", + kind="login_failed", + reason="bad_credentials", + ip=ip, ) - return HTMLResponse( - render( - "_token_created.html", - token=row, - plaintext=plaintext, - csrf=session["csrf"], + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials") + login_limit.clear(ip) + response = JSONResponse({"user": username}) + response.set_cookie( + SESSION_COOKIE, + signer.dumps({"user": username}), + max_age=SESSION_MAX_AGE, + httponly=True, + samesite="lax", + path=BASE, + ) + _log.info("admin login ok: user=%s ip=%s", username, ip) + await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip) + return response + + @app.post(f"{BASE}/auth/logout", status_code=status.HTTP_204_NO_CONTENT) + async def logout(request: Request) -> Response: + user = _current_user(request, signer) + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.delete_cookie(SESSION_COOKIE, path=BASE) + if user: + await audit.log(runtime, actor=f"admin:{user}", kind="logout") + return response + + @app.get(f"{BASE}/auth/session") + async def session(request: Request) -> dict[str, Any]: + user = _current_user(request, signer) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in") + return {"user": user, "token": token, "api_base": _api_base(request, runtime)} + + @app.get(BASE) + @app.get(f"{BASE}/{{path:path}}") + async def spa(path: str = "") -> Response: + if path: + target = (ui_dir / path).resolve() + if target.is_relative_to(ui_dir) and target.is_file(): + headers = ( + {"Cache-Control": "public, max-age=31536000, immutable"} + if "/immutable/" in f"/{path}" + else {} ) - ) + return FileResponse(target, headers=headers) + if index.is_file(): + return FileResponse(index, headers={"Cache-Control": "no-store"}) + return Response( + "ui/build is missing - run `make ui-build`.", + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + media_type="text/plain", + ) - @app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse) - async def tokens_revoke(request: Request, token_id: int) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - await _require_csrf(request, session) - async with runtime.db.session() as db_session: - ok = await revoke_token(db_session, token_id=token_id) - if not ok: - raise HTTPException( - status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}" - ) - await runtime.token_store.invalidate() - await audit.log( - runtime, - actor=f"admin:{session['user']}", - kind="token_revoke", - token_id=token_id, - ) - # Re-fetch so the row reflects the just-stamped revoked_at. - async with runtime.db.session() as db_session: - rows = await list_tokens(db_session, include_revoked=True) - row = next((r for r in rows if r.id == token_id), None) - if row is None: - # Shouldn't happen — revoke_token said ok — but stay - # honest: respond empty so the row disappears from the - # table. - return HTMLResponse("") - return HTMLResponse( - render("_token_row.html", token=row, csrf=session["csrf"]) - ) + @app.exception_handler(HTTPException) + async def http_error(_request: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + headers=exc.headers, + ) - # ---- audit ---- - - @app.get("/audit", response_class=HTMLResponse) - async def audit_page(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - before_raw = request.query_params.get("before") - before_id: int | None = None - if before_raw and before_raw.isdigit(): - before_id = int(before_raw) - async with runtime.db.session() as db_session: - rows = await list_audit_records( - db_session, limit=AUDIT_PAGE_SIZE, before_id=before_id - ) - # Cursor for the "next page" link — the smallest id on this - # page; ``None`` if we've run out of rows. - next_before = rows[-1].id if rows and len(rows) == AUDIT_PAGE_SIZE else None - return HTMLResponse( - render( - "audit.html", - user=session["user"], - csrf=session["csrf"], - audit=rows, - next_before=next_before, - ) - ) - - # ---- chat ---- - - # In-process playground: lets the operator drive any configured - # agent without minting a bearer token. Auth comes from the - # admin cookie; CSRF rides an ``X-CSRF-Token`` header because - # the body is JSON (no form to read). We call the backend - # directly — no HTTP hop to ``/v1/messages`` — so a chat turn - # bypasses the token store but is still audited as - # ``kind="messages"`` with ``actor="admin:"`` and - # ``source="admin_chat"`` in the detail. - @app.get("/chat", response_class=HTMLResponse) - async def chat_page(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - available = [ - a for a in runtime.agents if runtime.backends.get(a.name) is not None - ] - return HTMLResponse( - render( - "chat.html", - user=session["user"], - csrf=session["csrf"], - agents=available, - ) - ) - - @app.post("/chat/send") - async def chat_send(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - submitted = request.headers.get("x-csrf-token") - if not isinstance(submitted, str) or not hmac.compare_digest( - submitted, session["csrf"] - ): - raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed") - try: - body = await request.json() - except json.JSONDecodeError as exc: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}" - ) from exc - model = body.get("model") - if not isinstance(model, str): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, "missing or non-string `model`" - ) - agent = runtime.agents.get(model) - if agent is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, f"unknown agent: {model!r}" - ) - backend = runtime.backends.get(agent.name) - if backend is None: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - f"no backend configured for agent {agent.name!r}", - ) - messages = body.get("messages") or [] - if not isinstance(messages, list): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, "`messages` must be a list" - ) - system = body.get("system") - await audit.log( - runtime, - actor=f"admin:{session['user']}", - kind="messages", - agent_name=agent.name, - source="admin_chat", - msgs=len(messages), - ) - events = backend.complete( - agent=agent, - messages=messages, - system=system if isinstance(system, str) else None, - ) - return StreamingResponse( - _sse_events_and_broadcast( - events, - runtime=runtime, - agent_name=agent.name, - input_messages=messages, - system=system if isinstance(system, str) else None, - model=agent.model or agent.name, - ), - media_type="text/event-stream", - ) - - # ---- PTY terminal viewer ---- - - @app.get("/pty", response_class=HTMLResponse) - async def pty_list(request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - sessions = _collect_pty_sessions(runtime) - return HTMLResponse( - render( - "pty_list.html", - user=session["user"], - csrf=session["csrf"], - sessions=sessions, - ) - ) - - @app.get("/pty/{session_id}", response_class=HTMLResponse) - async def pty_view(session_id: str, request: Request) -> Response: - session = _require_session(request, signer, url_prefix=url_prefix) - return HTMLResponse( - render( - "pty.html", - user=session["user"], - csrf=session["csrf"], - session_id=session_id, - ) - ) - - @app.websocket("/pty/{session_id}/ws") - async def pty_ws(websocket: WebSocket, session_id: str) -> None: - # Session check via cookie — websockets carry cookies, same - # signed payload as the HTTP routes. Reject before accepting - # so the client gets a clean 403 (which manifests as a WS - # connection failure on the JS side). - if _current_user_from_cookie(websocket, signer) is None: - await websocket.close(code=status.WS_1008_POLICY_VIOLATION) - return - pty = _find_pty(runtime, session_id) - if pty is None: - await websocket.close(code=status.WS_1008_POLICY_VIOLATION) - return - await _bridge_pty_websocket(websocket, pty) - - return app + return app -# ---- helpers ------------------------------------------------------------ - - -def _collect_pty_sessions(runtime: GatewayRuntime) -> list[dict[str, Any]]: - """Enumerate live PTY sessions across all backends. - - A backend qualifies if it exposes a ``live_sessions`` mapping - (none since the SDK backend; kept for M1b). Other backend types - are quietly skipped — the admin terminal viewer only makes sense for - PTY-backed agents. - - Sorted most-recently-used first. The underlying mapping is keyed by - conversation fingerprint and reshuffled on every pool hit, so its - iteration order carries no meaning a human could act on. - """ - now = time.time() - out: list[dict[str, Any]] = [] - for agent_name, backend in runtime.backends.items(): - live = getattr(backend, "live_sessions", None) - if not isinstance(live, dict): +def _api_base(request: Request, runtime: GatewayRuntime) -> str | None: + for fe in runtime.frontends: + if fe.name != "api": continue - for session_id, pty in live.items(): - last_used = getattr(pty, "last_activity_at", None) or now - out.append( - { - "agent": agent_name, - "session_id": session_id, - "pid": getattr(pty, "pid", None), - "buffer_size": len(pty.captured_output()) - if hasattr(pty, "captured_output") - else 0, - "last_used": last_used, - "idle": _humanize_duration(now - last_used), - "age": _humanize_duration( - now - (getattr(pty, "created_at", None) or now) - ), - } - ) - out.sort(key=lambda s: s["last_used"], reverse=True) - return out - - -def _humanize_duration(seconds: float) -> str: - """Render a duration as the coarsest unit that still reads precisely.""" - secs = max(0, int(seconds)) - if secs < 60: - return f"{secs}s" - if secs < 3600: - return f"{secs // 60}m {secs % 60}s" - hours, rem = divmod(secs, 3600) - return f"{hours}h {rem // 60}m" - - -def _find_pty(runtime: GatewayRuntime, session_id: str) -> Any: - """Locate a live PTY by its claude session_id, across all backends.""" - for backend in runtime.backends.values(): - live = getattr(backend, "live_sessions", None) - if not isinstance(live, dict): - continue - pty = live.get(session_id) - if pty is not None: - return pty + public = getattr(fe, "public_base_url", None) + if public: + return public.removesuffix("/api") + port = getattr(fe, "port", None) + if port is None: + return None + scheme = request.headers.get("x-forwarded-proto") or request.url.scheme + return f"{scheme}://{request.url.hostname}:{port}" return None -def _current_user_from_cookie( - websocket: WebSocket, signer: itsdangerous.URLSafeTimedSerializer -) -> dict[str, Any] | None: - """Validate the admin session cookie on a WebSocket handshake. - - Mirrors :func:`_current_user` but reads from the websocket's - request-style cookies. ``BadSignature`` / ``SignatureExpired`` / - ``BadData`` all collapse to None — the caller closes the connection. - """ - raw = websocket.cookies.get(SESSION_COOKIE) +def _current_user( + request: Request, signer: itsdangerous.URLSafeTimedSerializer +) -> str | None: + raw = request.cookies.get(SESSION_COOKIE) if not raw: return None try: payload = signer.loads(raw, max_age=SESSION_MAX_AGE) except itsdangerous.BadData: return None - if not isinstance(payload, dict) or "user" not in payload: - return None - return payload - - -async def _bridge_pty_websocket(websocket: WebSocket, pty: Any) -> None: - """Bidirectional bridge between a WebSocket and a live PtyClaudeProcess. - - Output direction: the drain thread already runs continuously and - fans incoming bytes to all subscribed listeners. We register a - listener that drops each chunk into an :class:`asyncio.Queue` via - ``call_soon_threadsafe`` (the drain runs on a thread, not the loop). - A dedicated sender task drains the queue into ``websocket.send_bytes``. - - Input direction: each client text frame is encoded UTF-8 and written - to the PTY raw (``newline=False``) so xterm.js can pass through key - sequences (Enter, arrows, control chars) verbatim. - - On either side closing, both tasks unwind and the listener is - removed. The initial ``captured_output()`` snapshot is sent so a - new client sees the current screen state, not a blank terminal. - """ - await websocket.accept() - loop = asyncio.get_running_loop() - out_queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1024) - - def listener(chunk: bytes) -> None: - loop.call_soon_threadsafe(out_queue.put_nowait, chunk) - - pty.add_output_listener(listener) - try: - # Initial replay so the client doesn't see a blank terminal. - initial = pty.captured_output() - if initial: - await websocket.send_bytes(bytes(initial)) - - async def writer() -> None: - while True: - chunk = await out_queue.get() - if chunk is None: - return - await websocket.send_bytes(chunk) - - async def reader() -> None: - while True: - msg = await websocket.receive() - if msg["type"] == "websocket.disconnect": - return - data = msg.get("text") or msg.get("bytes") - if data is None: - continue - payload = data.encode("utf-8") if isinstance(data, str) else bytes(data) - await pty.write(payload, newline=False) - - writer_task = asyncio.create_task(writer(), name="pty-ws-writer") - reader_task = asyncio.create_task(reader(), name="pty-ws-reader") - try: - done, pending = await asyncio.wait( - {writer_task, reader_task}, return_when=asyncio.FIRST_COMPLETED - ) - for t in pending: - t.cancel() - with contextlib.suppress(BaseException): - await t - for t in done: - # Surface a real error instead of hiding it in the - # task's stored exception — but never crash the bridge - # on a clean disconnect. - exc = t.exception() - if exc is not None and not isinstance( - exc, asyncio.CancelledError | RuntimeError | OSError - ): - _log.warning("pty websocket task failed: %s", exc) - finally: - out_queue.put_nowait(None) - finally: - pty.remove_output_listener(listener) - with contextlib.suppress(BaseException): - await websocket.close() - - -# ---- helpers (originals) ------------------------------------------------ - - -def _build_template_env() -> Environment: - env = Environment( - loader=PackageLoader("beaver_gateway.frontends.admin", "templates"), - autoescape=select_autoescape(["html"]), - trim_blocks=True, - lstrip_blocks=True, - ) - env.filters["fmt_dt"] = _fmt_dt - env.filters["fmt_detail"] = _fmt_detail - return env - - -def _fmt_dt(value: datetime | None) -> str: - if value is None: - return "—" - # ISO without microseconds, with explicit ``Z`` when UTC — easier to - # scan than the default ``+00:00``. - s = value.replace(microsecond=0).isoformat() - return s.replace("+00:00", "Z") - - -def _fmt_detail(raw: str) -> str: - """Pretty-print the audit detail blob; leave invalid JSON alone.""" - if not raw or raw == "{}": - return "" - try: - return json.dumps(json.loads(raw), separators=(", ", ": ")) - except json.JSONDecodeError: - return raw - - -def _build_endpoint_catalog( - request: Request, runtime: GatewayRuntime -) -> dict[str, Any]: - """Collect copy-pastable URLs for sibling bearer frontends. - - Precedence per frontend: - - 1. ``frontend.public_base_url`` if set — the operator's explicit - statement of "this is the URL my reverse proxy (Caddy / nginx / - Cloudflare / …) puts in front of me". Used verbatim with the - internal path (``/v1/messages``, ``//`` for MCP) appended. - 2. ``{scheme}://{request_hostname}:{frontend_port}`` — derived from - the browser's own request so dev / no-proxy setups Just Work. - Scheme honours ``X-Forwarded-Proto`` so a TLS terminator in - front of the admin gets the right protocol. - - Imports happen inside the function to avoid a hard dep from - ``admin.frontend`` on the other frontend modules — they're optional - and may have non-trivial transitive deps (aiohttp etc.). - """ - from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend - from beaver_gateway.frontends.markdown import MarkdownFrontend - from beaver_gateway.frontends.mcp_server import McpServerFrontend - - scheme = request.headers.get("x-forwarded-proto") or request.url.scheme - hostname = request.url.hostname or "localhost" - - def _base_for( - fe: AnthropicMessagesFrontend | McpServerFrontend | MarkdownFrontend, - ) -> str: - if fe.public_base_url: - return fe.public_base_url - return f"{scheme}://{hostname}:{fe.port}" - - anthropic_base: str | None = None - mcp_base: str | None = None - markdown_fe: MarkdownFrontend | None = None - markdown_base: str | None = None - for fe in runtime.frontends: - if isinstance(fe, AnthropicMessagesFrontend) and anthropic_base is None: - anthropic_base = _base_for(fe) - elif isinstance(fe, McpServerFrontend) and mcp_base is None: - mcp_base = _base_for(fe) - elif isinstance(fe, MarkdownFrontend) and markdown_fe is None: - markdown_fe = fe - markdown_base = _base_for(fe) - - agent_rows: list[dict[str, Any]] = [] - if anthropic_base is not None: - messages_url = f"{anthropic_base}/v1/messages" - agent_rows.extend( - { - "agent": a.name, - "model": a.model, - "agent_type": a.__class__.__name__, - "url": messages_url, - } - for a in runtime.agents - ) - - mcp_rows: list[dict[str, Any]] = [] - if mcp_base is not None: - mcp_rows.extend( - {"namespace": m.name, "kind": m.kind, "url": f"{mcp_base}/{m.name}/"} - for m in runtime.mcps - ) - # ``all`` is synthesised by the aggregator whenever at least - # one MCP is configured — see McpServerFrontend._upstream_url. - if runtime.mcps: - mcp_rows.append( - {"namespace": "all", "kind": "bundle", "url": f"{mcp_base}/all/"} - ) - - markdown_row: dict[str, Any] | None = None - if markdown_fe is not None and markdown_base is not None: - # Pick a sane example filename from the vault, prefer something - # the user can recognise. Falls back to a generic ``chat.md``. - sample_agent = markdown_fe.default_agent or next( - (a.name for a in runtime.agents), "" - ) - markdown_row = { - "url": f"{markdown_base}/chat", - # Bare frontend root for the Obsidian companion plugin — its - # settings field asks for the base, then appends ``/chat``, - # ``/chat/stream`` itself. Curl + admin /chat tester want the - # full ``url`` above; the plugin wants this. - "base": markdown_base, - "vault_path": str(markdown_fe.vault_path), - "default_agent": markdown_fe.default_agent, - "log_all_chats": markdown_fe.log_all_chats, - "logged_subdir": markdown_fe.logged_subdir, - "sample_agent": sample_agent, - } - - return { - "anthropic_base": anthropic_base, - "mcp_base": mcp_base, - "markdown_base": markdown_base, - "agents": agent_rows, - "mcps": mcp_rows, - "markdown": markdown_row, - } - - -async def _sse_events_and_broadcast( - events: AsyncIterator[MessageStreamEvent], - *, - runtime: GatewayRuntime, - agent_name: str, - input_messages: list[MessageParam], - system: str | None, - model: str, -) -> AsyncIterator[bytes]: - r"""Serialize a backend stream as SSE and broadcast a ``TurnRecord`` after. - - Same wire shape as :mod:`beaver_gateway.frontends.anthropic` — - duplicated rather than imported so the admin frontend stays - independent of that module's private helpers, and so a mid-stream - failure surfaces as an in-band ``error`` event the chat UI can - render rather than a dangling connection. The events also feed a - side ``StreamAccumulator`` so once the SSE response closes we hand - the assembled ``Message`` to ``runtime.turn_log_handlers`` (the - markdown frontend's archive logger lives there). ``source="admin"`` - so the cross-frontend logger knows where the turn came from. - """ - acc = StreamAccumulator() - try: - async for ev in events: - acc.feed(ev) - payload = ev.model_dump_json() - yield f"event: {ev.type}\ndata: {payload}\n\n".encode() - except Exception as exc: # noqa: BLE001 - _log.exception("admin chat backend stream failed") - err = json.dumps( - {"type": "error", "error": {"type": "api_error", "message": str(exc)}} - ) - yield f"event: error\ndata: {err}\n\n".encode() - return - if not runtime.turn_log_handlers: - return - message = acc.finalize(model=model) - record = TurnRecord( - agent_name=agent_name, - input_messages=list(input_messages), - output_message=message, - system=system, - source="admin", - ) - for handler in runtime.turn_log_handlers: - try: - await handler(record) - except Exception: # noqa: BLE001 - _log.exception("turn_log_handler raised; continuing") - - -def _set_session_cookie(response: Response, value: str, *, path: str = "/") -> None: - # ``samesite=lax`` keeps the cookie out of cross-site POSTs but - # follows top-level navigation; ``httponly`` keeps it out of JS; - # ``secure`` is gated on the deployment scheme — toggled by reverse - # proxies in front. Skip the secure flag here so localhost dev - # works over plain HTTP; production deployments behind a TLS - # terminator should set ``Secure`` via the proxy. - response.set_cookie( - SESSION_COOKIE, - value, - max_age=SESSION_MAX_AGE, - httponly=True, - samesite="lax", - path=path, - ) - - -def _current_user( - request: Request, signer: itsdangerous.URLSafeTimedSerializer -) -> dict[str, Any] | None: - raw = request.cookies.get(SESSION_COOKIE) - if not raw: - return None - try: - payload = signer.loads(raw, max_age=SESSION_MAX_AGE) - except itsdangerous.BadSignature: - return None - if not isinstance(payload, dict): - return None - user = payload.get("user") - csrf = payload.get("csrf") - if not isinstance(user, str) or not isinstance(csrf, str): - return None - return {"user": user, "csrf": csrf} - - -def _require_session( - request: Request, - signer: itsdangerous.URLSafeTimedSerializer, - *, - url_prefix: str = "", -) -> dict[str, Any]: - session = _current_user(request, signer) - if session is None: - # GET endpoints want a redirect (so the browser walks the user - # to the login form), not a JSON 401. Mutating endpoints will - # still trip CSRF below, so the redirect is harmless for those. - raise HTTPException( - status.HTTP_303_SEE_OTHER, headers={"Location": f"{url_prefix}/login"} - ) - return session + user = payload.get("user") if isinstance(payload, dict) else None + return user if isinstance(user, str) else None def _client_ip(request: Request) -> str: - """Best-effort source IP, in precedence order. - - 1. ``Cf-Connecting-IP`` — Cloudflare's edge writes this and strips - anything inbound, so when it's present it's authoritative. - 2. ``X-Forwarded-For`` leftmost entry — what Caddy / nginx set when - they're the only proxy. We trust this because the deploy plan - puts Caddy directly in front; if the chain ever grows untrusted - hops, this header becomes spoofable from the public side. - 3. Socket peer — direct Tailscale / localhost hits. - """ cf = request.headers.get("cf-connecting-ip") if cf: return cf.strip() @@ -984,17 +246,7 @@ def _client_ip(request: Request) -> str: class _LoginRateLimit: - """In-memory sliding-window failure counter for ``POST /login``. - - Keyed by source IP (see :func:`_client_ip`). Each failure appends a - monotonic timestamp; :meth:`check` drops timestamps older than - ``window`` and refuses once ``max_attempts`` remain in the bucket. - A successful login calls :meth:`clear` to wipe the IP's bucket. - - No external store: a single-operator admin only needs to survive - process lifetime. Lives entirely on the event loop thread, so no - lock is needed — every method is synchronous and doesn't ``await``. - """ + """Sliding-window failure counter per source IP for ``POST /admin/auth/login``.""" __slots__ = ("_failures", "_max_attempts", "_window") @@ -1024,12 +276,3 @@ class _LoginRateLimit: def clear(self, ip: str) -> None: self._failures.pop(ip, None) - - -async def _require_csrf(request: Request, session: dict[str, Any]) -> None: - form = await request.form() - submitted = form.get("csrf_token") - if not isinstance(submitted, str) or not hmac.compare_digest( - submitted, session["csrf"] - ): - raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed") diff --git a/src/beaver_gateway/frontends/admin/templates/_layout.html b/src/beaver_gateway/frontends/admin/templates/_layout.html deleted file mode 100644 index ede592b..0000000 --- a/src/beaver_gateway/frontends/admin/templates/_layout.html +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - {% block title %}beaver-gateway · admin{% endblock %} - - - - - {% block header %} -
-
-

beaver-gateway

- - Signed in as {{ user }} -
- - -
-
-
- {% endblock %} -
- {% block content %}{% endblock %} -
- - diff --git a/src/beaver_gateway/frontends/admin/templates/_token_created.html b/src/beaver_gateway/frontends/admin/templates/_token_created.html deleted file mode 100644 index f2728a2..0000000 --- a/src/beaver_gateway/frontends/admin/templates/_token_created.html +++ /dev/null @@ -1,12 +0,0 @@ -{# Renders into #token-create-result (hx-target on the form). The - two OOB swaps reach the rest of the page: prepend the new row, - and erase the empty-state placeholder if it's still in the DOM. #} - - - {% include "_token_row.html" %} - - diff --git a/src/beaver_gateway/frontends/admin/templates/_token_error.html b/src/beaver_gateway/frontends/admin/templates/_token_error.html deleted file mode 100644 index 75c2e61..0000000 --- a/src/beaver_gateway/frontends/admin/templates/_token_error.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/beaver_gateway/frontends/admin/templates/_token_row.html b/src/beaver_gateway/frontends/admin/templates/_token_row.html deleted file mode 100644 index 7d6624f..0000000 --- a/src/beaver_gateway/frontends/admin/templates/_token_row.html +++ /dev/null @@ -1,25 +0,0 @@ - - {{ token.name }} - - {{ token.scope }} - - {{ token.created_at | fmt_dt }} - {{ token.last_used_at | fmt_dt }} - {{ token.revoked_at | fmt_dt }} - - {% if not token.revoked_at %} -
- - -
- {% else %} - revoked - {% endif %} - - diff --git a/src/beaver_gateway/frontends/admin/templates/audit.html b/src/beaver_gateway/frontends/admin/templates/audit.html deleted file mode 100644 index 758fb19..0000000 --- a/src/beaver_gateway/frontends/admin/templates/audit.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "_layout.html" %} -{% set active = "audit" %} -{% block title %}beaver-gateway · audit{% endblock %} -{% block content %} -

Audit log

-
- {% if audit %} - - - - - - - - {% for row in audit %} - - - - - - - - {% endfor %} - -
TimeActorKindAgentDetail
{{ row.ts | fmt_dt }}{{ row.actor }}{{ row.kind }}{{ row.agent_name or "—" }}{{ row.detail_json | fmt_detail }}
-

- {% if next_before %} - Older entries → - {% else %} - End of log. - {% endif %} -

- {% else %} -

Nothing logged yet.

- {% endif %} -
-{% endblock %} diff --git a/src/beaver_gateway/frontends/admin/templates/chat.html b/src/beaver_gateway/frontends/admin/templates/chat.html deleted file mode 100644 index e27d362..0000000 --- a/src/beaver_gateway/frontends/admin/templates/chat.html +++ /dev/null @@ -1,355 +0,0 @@ -{% extends "_layout.html" %} -{% set active = "chat" %} -{% block title %}beaver-gateway · chat{% endblock %} -{% block content %} -
-
- -
- -
-
-
- - -
-
- - - - -{% endblock %} diff --git a/src/beaver_gateway/frontends/admin/templates/dashboard.html b/src/beaver_gateway/frontends/admin/templates/dashboard.html deleted file mode 100644 index dffe096..0000000 --- a/src/beaver_gateway/frontends/admin/templates/dashboard.html +++ /dev/null @@ -1,490 +0,0 @@ -{% extends "_layout.html" %} -{% set active = "dashboard" %} -{% block title %}beaver-gateway · dashboard{% endblock %} -{% block content %} -

Agents

-
- {% if agents %} - - - - {% for a in agents %} - - - - - - - {% endfor %} - -
NameTypeModelExposed MCPs
{{ a.name }}{{ a.__class__.__name__ }}{{ a.model }} - {% for em in a.expose_mcps %}{{ em.name }}{% if not loop.last %}, {% endif %}{% else %}{% endfor %} -
- {% else %} -

No agents configured.

- {% endif %} -
- -

MCP namespaces

-
- {% if mcps %} - - - - {% for m in mcps %} - - - - - {% endfor %} - -
NameKind
{{ m.name }}{{ m.kind }}
- {% else %} -

No MCP servers configured.

- {% endif %} -
- -

Endpoints

-
- {% if endpoints.agents or endpoints.mcps %} -
- - - -
-

- Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed. - Paste the value you saved at creation; if you've lost it, mint a new one. - Below: pick a token to see which endpoints its scope covers, paste the secret to fill it - into URL / curl, then click Copy. -

- - {% if endpoints.agents %} -

Agents — POST /v1/messages

- - - - {% for ep in endpoints.agents %} - - - - - - - - {% endfor %} - -
AgentModelURL
- {{ ep.agent }} - {{ ep.agent_type }} - - {{ ep.model }}{{ ep.url }} - - - -
- {% elif endpoints.anthropic_base is none and agents %} -

- Agents are declared but no AnthropicMessagesFrontend is configured — - add one to Gateway(frontends=[...]) to expose them over HTTP. -

- {% endif %} - - {% if endpoints.mcps %} -

MCP — streamable HTTP

- - - - {% for ep in endpoints.mcps %} - - - - - - - - {% endfor %} - -
NamespaceKindURL
- {{ ep.namespace }} - - {{ ep.kind }}{{ ep.url }} - - - - -
- {% elif endpoints.mcp_base is none and mcps %} -

- MCP servers are declared but no McpServerFrontend is configured — - add one to Gateway(frontends=[...]) to expose them over HTTP. -

- {% endif %} - - {% if endpoints.markdown %} -

Markdown — POST /chat

- - - - - - - - - - - -
VaultDefault agentURL
- {{ endpoints.markdown.vault_path }} - - {% if endpoints.markdown.log_all_chats %} - log_all_chats · {{ endpoints.markdown.logged_subdir }}/ - {% endif %} - - {% if endpoints.markdown.default_agent %} - {{ endpoints.markdown.default_agent }} - {% else %} - — (request must set agent or frontmatter) - {% endif %} - - {{ endpoints.markdown.url }} -
- plugin base: {{ endpoints.markdown.base }} -
-
- - - - -
- {% endif %} - {% else %} -

- Nothing exposed yet — declare agents / MCPs and the matching frontends - (AnthropicMessagesFrontend, McpServerFrontend) in your config. -

- {% endif %} -
- -

Recent activity

-
- {% if audit %} - - - - {% for row in audit %} - - - - - - - - {% endfor %} - -
TimeActorKindAgentDetail
{{ row.ts | fmt_dt }}{{ row.actor }}{{ row.kind }}{{ row.agent_name or "—" }}{{ row.detail_json | fmt_detail }}
-

Full log →

- {% else %} -

Nothing logged yet.

- {% endif %} -
- - - - -{% endblock %} diff --git a/src/beaver_gateway/frontends/admin/templates/login.html b/src/beaver_gateway/frontends/admin/templates/login.html deleted file mode 100644 index 1f18fc7..0000000 --- a/src/beaver_gateway/frontends/admin/templates/login.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - beaver-gateway · log in - - - -
-

beaver-gateway

-

Sign in to manage tokens and view audit logs.

- {% if error %} -
{{ error }}
- {% endif %} -
- - - - - -
-
- - diff --git a/src/beaver_gateway/frontends/admin/templates/pty.html b/src/beaver_gateway/frontends/admin/templates/pty.html deleted file mode 100644 index c8bbd52..0000000 --- a/src/beaver_gateway/frontends/admin/templates/pty.html +++ /dev/null @@ -1,146 +0,0 @@ -{% extends "_layout.html" %} -{% set active = "pty" %} -{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %} -{% block content %} - - - - -

PTY {{ session_id }}

-

- Live view of the claude subprocess TUI. Keystrokes you type here go - straight into its stdin — Enter, arrows, Ctrl-C, paste, all work. - ← back to list -

- -
- - - - connecting… -
- -
-
-
- - - - -{% endblock %} diff --git a/src/beaver_gateway/frontends/admin/templates/pty_list.html b/src/beaver_gateway/frontends/admin/templates/pty_list.html deleted file mode 100644 index d5e55a7..0000000 --- a/src/beaver_gateway/frontends/admin/templates/pty_list.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "_layout.html" %} -{% set active = "pty" %} -{% block title %}PTY · beaver-gateway · admin{% endblock %} -{% block content %} -

Live PTY sessions

-

- Each row is a running claude subprocess, most recently used first. - Open one to see what's currently rendered on its TUI and (if needed) type into - it directly. Sessions idle past the agent's TTL are terminated automatically. -

- -{% if sessions %} -
- - - - - - - - - - - - - - {% for s in sessions %} - - - - - - - - - - {% endfor %} - -
Session IDAgentIdleAgePIDBuffer
{{ s.session_id }}{{ s.agent }}{{ s.idle }}{{ s.age }}{{ s.pid or "?" }}{{ s.buffer_size }} bytes - Open -
-
-{% else %} - -{% endif %} -{% endblock %} diff --git a/src/beaver_gateway/frontends/admin/templates/tokens.html b/src/beaver_gateway/frontends/admin/templates/tokens.html deleted file mode 100644 index 8125241..0000000 --- a/src/beaver_gateway/frontends/admin/templates/tokens.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "_layout.html" %} -{% set active = "tokens" %} -{% block title %}beaver-gateway · tokens{% endblock %} -{% block content %} -

Create token

-
-

Plaintext is shown once, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.

-
-
- -
-
- - -
-
- - -
-
- -
-
-
-
- -

- Tokens - - {% if include_revoked %} - Hide revoked - {% else %} - Show revoked - {% endif %} - -

-
- - - - - - - {# Render the tbody unconditionally so the HTMX OOB swap on - create has a target even when the table starts empty. #} - - {% for token in tokens %} - {% include "_token_row.html" %} - {% else %} - - {% endfor %} - -
NameScopeCreatedLast usedRevoked
No tokens yet. Create one above.
-
-{% endblock %} diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index 30fa872..8e0da23 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -1,27 +1,35 @@ -"""``ApiFrontend`` - ``/api/conversations``, SSE events, sessions, usage (§3.9). +"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits (§3.9). -Bearer scope ``api``. Every write goes through ``core/conversations``; the -frontend only shapes JSON. ``/api/events`` and -``/api/conversations/{id}/events`` replay the gateway bus as SSE with the -same keepalive the markdown frontend uses, so a proxy never sees a -silent socket. +Bearer scope ``api``; token and audit management need ``admin``. Every +write goes through ``core/conversations``; the frontend only shapes JSON. +``/api/events`` and ``/api/conversations/{id}/events`` replay the gateway +bus as SSE with the same keepalive the markdown frontend uses, so a proxy +never sees a silent socket. + +Usage figures come from the ``usage`` table (one row per turn, API-price +``cost_usd`` and per-model ``model_usage`` from the SDK's +``ResultMessage``); subscription quotas come from ``rate_limits`` +(``RateLimitEvent``). The quota covers the whole subscription, so +``/api/limits`` puts the gateway's own spend for the window next to it +for calibration by eye. """ from __future__ import annotations import json import logging +import secrets +from collections import Counter from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, cast from fastapi import FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse -from sqlalchemy import func -from sqlalchemy import select as sa_select -from sqlmodel import col +from sqlmodel import col, select from beaver_gateway.core import audit +from beaver_gateway.core.auth import VALID_SCOPES, hash_token from beaver_gateway.core.conversations import SEEDS from beaver_gateway.core.kinds import Kind, as_kind from beaver_gateway.frontends._auth import require_token @@ -32,20 +40,37 @@ from beaver_gateway.frontends._sse import ( sse_pack, ) from beaver_gateway.frontends.base import Frontend -from beaver_gateway.storage.models import Usage +from beaver_gateway.storage import ( + create_token, + list_audit_records, + list_tokens, + revoke_token, +) +from beaver_gateway.storage.models import Conversation, RateLimit, Token, Usage if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Iterable, Sequence + from pathlib import Path from beaver_gateway.core.conversations import Conversations from beaver_gateway.frontends.base import GatewayRuntime - from beaver_gateway.storage.models import Conversation _log = logging.getLogger("beaver_gateway.frontends.api") __all__ = ["ApiFrontend"] SCOPE = "api" +ADMIN_SCOPE = "admin" +GROUPS = ("day", "agent", "conversation", "model") +WINDOWS: dict[str, timedelta] = { + "five_hour": timedelta(hours=5), + "seven_day": timedelta(days=7), + "seven_day_opus": timedelta(days=7), + "seven_day_sonnet": timedelta(days=7), +} +MEMORY_MAX_DEPTH = 12 +MEMORY_MAX_FILE = 2_000_000 +MEMORY_MAX_ENTRIES = 5000 class ApiFrontend(Frontend): @@ -62,6 +87,7 @@ class ApiFrontend(Frontend): branch_agent: str | None = None, deep_agent: str | None = None, job_agent: str | None = None, + memory_root: Path | None = None, ) -> None: self.host = host self.port = port @@ -70,6 +96,7 @@ class ApiFrontend(Frontend): self.branch_agent = branch_agent self.deep_agent = deep_agent self.job_agent = job_agent + self.memory_root = memory_root.resolve() if memory_root is not None else None self._app: FastAPI | None = None def agent_for(self, kind: Kind) -> str | None: @@ -84,7 +111,7 @@ class ApiFrontend(Frontend): if runtime.conversations is None or runtime.bus is None: msg = "ApiFrontend needs runtime.conversations and runtime.bus" raise RuntimeError(msg) - self._app = _build_app(runtime) + self._app = build_app(runtime, memory_root=self.memory_root) async def serve(self) -> None: import uvicorn @@ -98,7 +125,7 @@ class ApiFrontend(Frontend): await server.serve() -def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 +def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915 app = FastAPI(title="beaver-gateway / API") app.add_middleware( CORSMiddleware, @@ -146,6 +173,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 ) return value + def query_int(request: Request, key: str, default: int) -> int: + raw = request.query_params.get(key) + return int(raw) if raw and raw.isdigit() else default + @app.get("/healthz") async def healthz() -> dict[str, str]: return {"status": "ok"} @@ -158,20 +189,26 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 { "name": a.name, "model": a.model, + "type": a.__class__.__name__, "kinds": list(getattr(a, "kinds", ())), + "effort": getattr(getattr(a, "options", None), "effort", None), } for a in runtime.agents ], "frontends": [ { - "name": fe.name, + "name": fe.name or fe.__class__.__name__, + "type": fe.__class__.__name__, "kinds": list(fe.kinds), "default_agents": { k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k) }, + "port": getattr(fe, "port", None), + "public_base_url": getattr(fe, "public_base_url", None), } - for fe in conversations.frontends + for fe in runtime.frontends ], + "mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps], } @app.get("/api/conversations") @@ -179,7 +216,9 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 await require_token(request, runtime, scope=SCOPE) q = request.query_params rows = await conversations.find( - status=q.get("status"), kind=q.get("kind"), limit=int(q.get("limit", "200")) + status=q.get("status"), + kind=q.get("kind"), + limit=query_int(request, "limit", 200), ) return {"conversations": [conversations.public(r) for r in rows]} @@ -238,7 +277,7 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 "priority": i.priority, "origin": i.origin, "status": i.status, - "created_at": i.created_at.isoformat(), + "created_at": _iso(i.created_at), "text": i.text[:200], } for i in await conversations.queue.recent(cast("int", conv.id), limit=20) @@ -256,6 +295,29 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 "text": await conversations.read(conv, window=window), } + @app.get("/api/conversations/{public_id}/history") + async def get_history(public_id: str, request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + conv = await conv_of(public_id) + return {"id": conv.external_id, "messages": await conversations.history(conv)} + + @app.get("/api/conversations/{public_id}/entries") + async def get_entries(public_id: str, request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + conv = await conv_of(public_id) + subpath = request.query_params.get("subpath") or "" + entries = await conversations.entries(conv, subpath=subpath) + limit = query_int(request, "limit", 100) + offset = query_int(request, "offset", max(len(entries) - limit, 0)) + return { + "id": conv.external_id, + "subpath": subpath, + "subpaths": await conversations.subpaths(conv), + "total": len(entries), + "offset": offset, + "entries": entries[offset : offset + limit], + } + @app.post( "/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED ) @@ -309,6 +371,18 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 conv = await conv_of(public_id) return await conversations.say(conv, text_of(await body_of(request))) + @app.post("/api/conversations/{public_id}/answer") + async def post_answer(public_id: str, request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + conv = await conv_of(public_id) + data = await body_of(request) + question_id = text_of(data, "question_id") + if not conversations.answer(question_id, text_of(data, "answer")): + raise HTTPException( + status.HTTP_404_NOT_FOUND, f"no open question {question_id}" + ) + return {"id": conv.external_id, "question_id": question_id} + @app.post( "/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED ) @@ -460,11 +534,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 { "id": s.id, "conversation_row": s.conversation_id, - "execute_at": s.execute_at.isoformat(), + "execute_at": _iso(s.execute_at), "text": s.text, - "delivered_at": s.delivered_at.isoformat() - if s.delivered_at - else None, + "created_at": _iso(s.created_at), + "delivered_at": _iso(s.delivered_at), } for s in await conversations.schedules(conv) ] @@ -473,35 +546,156 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 @app.get("/api/usage") async def usage(request: Request) -> dict[str, Any]: await require_token(request, runtime, scope=SCOPE) - hours = float(request.query_params.get("hours", "24")) - since = datetime.now(UTC) - timedelta(hours=hours) - columns = ( - func.count(), - func.coalesce(func.sum(Usage.input_tokens), 0), - func.coalesce(func.sum(Usage.output_tokens), 0), - func.coalesce(func.sum(Usage.cache_read_tokens), 0), - func.coalesce(func.sum(Usage.cache_creation_tokens), 0), - func.coalesce(func.sum(Usage.cost_usd), 0.0), + q = request.query_params + group_by = q.get("group_by", "agent") + if group_by not in GROUPS: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, f"group_by must be one of {GROUPS}" + ) + until = _parse_time(q.get("until")) or datetime.now(UTC) + since = _parse_time(q.get("since")) or until - timedelta( + hours=float(q.get("hours", "24")) ) - async with runtime.db.session() as session: - by_agent = ( - await session.execute( # ty: ignore[deprecated] - sa_select(col(Usage.agent_name), *columns) - .where(col(Usage.ts) >= since.replace(tzinfo=None)) - .group_by(col(Usage.agent_name)) - ) - ).all() - by_conversation = ( - await session.execute( # ty: ignore[deprecated] - sa_select(col(Usage.conversation_id), *columns) - .where(col(Usage.ts) >= since.replace(tzinfo=None)) - .group_by(col(Usage.conversation_id)) - ) - ).all() + rows = await _usage_rows(runtime, since, until) + groups = _group_usage(rows, group_by) + if group_by == "conversation": + titles = await _conversation_titles(runtime, [g["key"] for g in groups]) + for g in groups: + g.update(titles.get(g["key"], {})) return { "since": since.isoformat(timespec="seconds"), - "by_agent": [_usage_row("agent", r) for r in by_agent], - "by_conversation": [_usage_row("conversation", r) for r in by_conversation], + "until": until.isoformat(timespec="seconds"), + "group_by": group_by, + "total": _sum_usage(rows), + "rows": groups, + } + + @app.get("/api/limits") + async def limits(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + rows = await conversations.rate_limits(limit=200) + latest: dict[str, RateLimit] = {} + for row in rows: + latest.setdefault(row.window, row) + now = datetime.now(UTC) + windows = [] + for window, row in latest.items(): + length = WINDOWS.get(window) + since = ( + _aware(row.resets_at) - length + if length is not None and row.resets_at is not None + else _aware(row.ts) + ) + gateway = _sum_usage(await _usage_rows(runtime, since, now)) + gateway["since"] = since.isoformat(timespec="seconds") + windows.append({**_limit_public(row), "gateway": gateway}) + windows.sort( + key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"]) + ) + return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]} + + @app.get("/api/memory") + async def memory_tree(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + root = _memory_root(memory_root) + return {"root": str(root), "tree": _tree(root, root, depth=0)} + + @app.get("/api/memory/file") + async def memory_file(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + root = _memory_root(memory_root) + target = _memory_path(root, request.query_params.get("path") or "") + if not target.is_file(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file") + stat = target.stat() + if stat.st_size > MEMORY_MAX_FILE: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large") + try: + content = target.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise HTTPException( + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "not a text file" + ) from exc + return { + "path": str(target.relative_to(root)), + "size": stat.st_size, + "mtime": _mtime(stat.st_mtime), + "content": content, + } + + @app.get("/api/tokens") + async def tokens(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=ADMIN_SCOPE) + include_revoked = request.query_params.get("include_revoked") == "1" + async with runtime.db.session() as session: + rows = await list_tokens(session, include_revoked=include_revoked) + return {"tokens": [_token_public(t) for t in rows]} + + @app.post("/api/tokens", status_code=status.HTTP_201_CREATED) + async def token_create(request: Request) -> dict[str, Any]: + actor = await require_token(request, runtime, scope=ADMIN_SCOPE) + data = await body_of(request) + name = text_of(data, "name").strip() + scope = str(data.get("scope") or "*") + if scope not in VALID_SCOPES: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}") + plaintext = secrets.token_urlsafe(32) + async with runtime.db.session() as session: + try: + row = await create_token( + session, name=name, scope=scope, hashed_value=hash_token(plaintext) + ) + except Exception as exc: + raise HTTPException( + status.HTTP_409_CONFLICT, f"could not create token {name!r}: {exc}" + ) from exc + await runtime.token_store.invalidate() + await audit.log( + runtime, + actor=f"token:{actor}", + kind="token_create", + name=name, + scope=scope, + token_id=row.id, + ) + return {"token": _token_public(row), "plaintext": plaintext} + + @app.post("/api/tokens/{token_id}/revoke") + async def token_revoke(token_id: int, request: Request) -> dict[str, Any]: + actor = await require_token(request, runtime, scope=ADMIN_SCOPE) + async with runtime.db.session() as session: + ok = await revoke_token(session, token_id=token_id) + if not ok: + raise HTTPException( + status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}" + ) + await runtime.token_store.invalidate() + await audit.log( + runtime, actor=f"token:{actor}", kind="token_revoke", token_id=token_id + ) + return {"id": token_id, "revoked": True} + + @app.get("/api/audit") + async def audit_list(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=ADMIN_SCOPE) + before_raw = request.query_params.get("before") + before = int(before_raw) if before_raw and before_raw.isdigit() else None + limit = min(query_int(request, "limit", 50), 500) + async with runtime.db.session() as session: + rows = await list_audit_records(session, limit=limit, before_id=before) + return { + "records": [ + { + "id": r.id, + "ts": _iso(r.ts), + "actor": r.actor, + "kind": r.kind, + "agent": r.agent_name, + "detail": _detail(r.detail_json), + } + for r in rows + ], + "next_before": rows[-1].id if len(rows) == limit else None, } @app.exception_handler(HTTPException) @@ -515,19 +709,6 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915 return app -def _usage_row(label: str, row: Any) -> dict[str, Any]: - key, turns, inp, out, cache_read, cache_creation, cost = row - return { - label: key, - "turns": turns, - "input": inp, - "output": out, - "cache_read": cache_read, - "cache_creation": cache_creation, - "cost_usd": round(float(cost or 0.0), 4), - } - - def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingResponse: async def gen() -> AsyncIterator[bytes]: stream = runtime.bus.stream(conversation_id=conversation_id) @@ -539,3 +720,220 @@ def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingRe yield sse_pack(str(event["type"]), event) return StreamingResponse(gen(), media_type="text/event-stream", headers=SSE_HEADERS) + + +def _parse_time(raw: str | None) -> datetime | None: + if not raw: + return None + try: + value = datetime.fromisoformat(raw) + except ValueError as exc: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, f"bad timestamp {raw!r}" + ) from exc + return _aware(value) + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _iso(value: datetime | None) -> str | None: + return _aware(value).isoformat(timespec="seconds") if value is not None else None + + +def _mtime(value: float) -> str: + return datetime.fromtimestamp(value, tz=UTC).isoformat(timespec="seconds") + + +async def _usage_rows( + runtime: GatewayRuntime, since: datetime, until: datetime +) -> list[Usage]: + stmt = ( + select(Usage) + .where( + col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None), + col(Usage.ts) < until.astimezone(UTC).replace(tzinfo=None), + ) + .order_by(col(Usage.ts)) + ) + async with runtime.db.session() as session: + return list((await session.exec(stmt)).all()) + + +def _empty() -> dict[str, Any]: + return { + "turns": 0, + "input": 0, + "output": 0, + "cache_read": 0, + "cache_creation": 0, + "cost_usd": 0.0, + "web_searches": 0, + } + + +def _add(acc: dict[str, Any], row: Usage) -> None: + acc["turns"] += 1 + acc["input"] += row.input_tokens + acc["output"] += row.output_tokens + acc["cache_read"] += row.cache_read_tokens + acc["cache_creation"] += row.cache_creation_tokens + acc["cost_usd"] += row.cost_usd or 0.0 + for per_model in (row.model_usage or {}).values(): + acc["web_searches"] += int(per_model.get("webSearchRequests") or 0) + + +def _add_model(acc: dict[str, Any], per_model: dict[str, Any]) -> None: + acc["turns"] += 1 + acc["input"] += int(per_model.get("inputTokens") or 0) + acc["output"] += int(per_model.get("outputTokens") or 0) + acc["cache_read"] += int(per_model.get("cacheReadInputTokens") or 0) + acc["cache_creation"] += int(per_model.get("cacheCreationInputTokens") or 0) + acc["cost_usd"] += float(per_model.get("costUSD") or 0.0) + acc["web_searches"] += int(per_model.get("webSearchRequests") or 0) + + +def _finish(acc: dict[str, Any]) -> dict[str, Any]: + acc["cost_usd"] = round(acc["cost_usd"], 4) + return acc + + +def _sum_usage(rows: Iterable[Usage]) -> dict[str, Any]: + acc = _empty() + for row in rows: + _add(acc, row) + return _finish(acc) + + +def _group_usage(rows: Sequence[Usage], group_by: str) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + agents: dict[str, Counter[str]] = {} + for row in rows: + if group_by == "model": + per_model = row.model_usage or {} + if not per_model: + _add(groups.setdefault(row.model, _empty()), row) + for model, mu in per_model.items(): + _add_model(groups.setdefault(model, _empty()), mu) + continue + key = _group_key(row, group_by) + _add(groups.setdefault(key, _empty()), row) + agents.setdefault(key, Counter())[row.agent_name] += 1 + out = [] + for key, acc in groups.items(): + entry = {"key": key, group_by: key, **_finish(acc)} + if key in agents: + entry["agent"] = agents[key].most_common(1)[0][0] + out.append(entry) + if group_by == "day": + out.sort(key=lambda g: g["key"]) + else: + out.sort(key=lambda g: (-g["cost_usd"], -g["output"], g["key"])) + return out + + +def _group_key(row: Usage, group_by: str) -> str: + if group_by == "day": + return _aware(row.ts).date().isoformat() + if group_by == "agent": + return row.agent_name + return row.conversation_id or "-" + + +async def _conversation_titles( + runtime: GatewayRuntime, ids: Sequence[str] +) -> dict[str, dict[str, Any]]: + wanted = [i for i in ids if i != "-"] + if not wanted: + return {} + async with runtime.db.session() as session: + rows = ( + await session.exec( + select(Conversation).where(col(Conversation.external_id).in_(wanted)) + ) + ).all() + return { + r.external_id: {"title": r.title, "kind": r.kind, "status": r.status} + for r in rows + } + + +def _limit_public(row: RateLimit) -> dict[str, Any]: + return { + "id": row.id, + "ts": _iso(row.ts), + "window": row.window, + "status": row.status, + "utilization": row.utilization, + "resets_at": _iso(row.resets_at), + "overage_status": row.overage_status, + "overage_resets_at": _iso(row.overage_resets_at), + "agent": row.agent_name, + "session_id": row.session_id, + } + + +def _token_public(row: Token) -> dict[str, Any]: + return { + "id": row.id, + "name": row.name, + "scope": row.scope, + "created_at": _iso(row.created_at), + "last_used_at": _iso(row.last_used_at), + "revoked_at": _iso(row.revoked_at), + } + + +def _detail(raw: str) -> Any: + try: + return json.loads(raw) if raw else {} + except json.JSONDecodeError: + return raw + + +def _memory_root(root: Path | None) -> Path: + if root is None or not root.is_dir(): + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "memory root is not configured (ApiFrontend(memory_root=...))", + ) + return root.resolve() + + +def _memory_path(root: Path, raw: str) -> Path: + target = (root / raw).resolve() + if not target.is_relative_to(root): + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file") + return target + + +def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]: + if depth > MEMORY_MAX_DEPTH: + return [] + out: list[dict[str, Any]] = [] + try: + children = sorted( + directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()) + ) + except OSError: + return out + for child in children: + if child.name.startswith(".") or len(out) >= MEMORY_MAX_ENTRIES: + continue + try: + stat = child.stat() + except OSError: + continue + node: dict[str, Any] = { + "name": child.name, + "path": str(child.relative_to(root)), + "type": "dir" if child.is_dir() else "file", + "mtime": _mtime(stat.st_mtime), + } + if child.is_dir(): + node["children"] = _tree(root, child, depth=depth + 1) + else: + node["size"] = stat.st_size + out.append(node) + return out diff --git a/src/beaver_gateway/storage/__init__.py b/src/beaver_gateway/storage/__init__.py index 751dd1e..98f1703 100644 --- a/src/beaver_gateway/storage/__init__.py +++ b/src/beaver_gateway/storage/__init__.py @@ -22,6 +22,7 @@ from beaver_gateway.storage.models import ( ConversationBinding, Delivery, InjectQueueItem, + RateLimit, Schedule, TelegramUpdate, Token, @@ -38,6 +39,7 @@ __all__ = [ "Delivery", "InjectQueueItem", "PostgresSessionStore", + "RateLimit", "Schedule", "TelegramUpdate", "Token", diff --git a/src/beaver_gateway/storage/models.py b/src/beaver_gateway/storage/models.py index 9227113..f830450 100644 --- a/src/beaver_gateway/storage/models.py +++ b/src/beaver_gateway/storage/models.py @@ -319,6 +319,39 @@ class Usage(SQLModel, table=True): cost_usd: float | None = Field(default=None) duration_ms: int | None = Field(default=None) num_turns: int | None = Field(default=None) + model_usage: dict[str, Any] | None = Field( + default=None, + sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=True), + ) + + +class RateLimit(SQLModel, table=True): + """One ``RateLimitEvent`` from the SDK; the newest row per ``window`` is the state. + + The quota is per subscription, not per agent or conversation - the row + only remembers which session reported it. + """ + + __tablename__ = "rate_limits" + + id: int | None = Field(default=None, primary_key=True) + ts: datetime = Field(default_factory=_utcnow, index=True) + window: str = Field(index=True) + status: str + utilization: float | None = Field(default=None) + resets_at: datetime | None = Field(default=None) + overage_status: str | None = Field(default=None) + overage_resets_at: datetime | None = Field(default=None) + agent_name: str | None = Field(default=None) + session_id: str | None = Field(default=None) + raw: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column( + JSON().with_variant(JSONB(), "postgresql"), + nullable=False, + server_default=text("'{}'"), + ), + ) __all__ = [ @@ -328,6 +361,7 @@ __all__ = [ "ConversationMessage", "Delivery", "InjectQueueItem", + "RateLimit", "Schedule", "TelegramUpdate", "Token", diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3f45c3a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,387 @@ +import asyncio +import tempfile +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, cast + +import pytest +from claude_agent_sdk import ( + AssistantMessage, + RateLimitEvent, + RateLimitInfo, + ResultMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from httpx import ASGITransport, AsyncClient +from test_conversations import ScriptedClient, World + +from beaver_gateway.core.auth import TokenStore +from beaver_gateway.core.registry import McpRegistry +from beaver_gateway.core.transcript import build_entries +from beaver_gateway.frontends.admin.frontend import build_app as build_admin +from beaver_gateway.frontends.api.frontend import build_app as build_api +from beaver_gateway.frontends.base import GatewayRuntime +from beaver_gateway.storage.models import RateLimit, Usage + +TOKEN = "tok" +HEADERS = {"Authorization": f"Bearer {TOKEN}"} + + +class ToolClient(ScriptedClient): + async def receive_response(self): + yield AssistantMessage( + content=[ToolUseBlock(id="tu_1", name="Bash", input={"command": "ls"})], + model="m", + ) + yield AssistantMessage( + content=[ToolUseBlock(id="tu_2", name="Read", input={"file_path": "x"})], + model="m", + parent_tool_use_id="tu_1", + ) + yield RateLimitEvent( + rate_limit_info=RateLimitInfo( + status="allowed_warning", + resets_at=int(datetime.now(UTC).timestamp()) + 3600, + rate_limit_type="five_hour", + utilization=0.8, + raw={"status": "allowed_warning"}, + ), + uuid="r", + session_id=self.session_id, + ) + hold = ScriptedClient.hold + if hold is not None: + await hold.wait() + yield UserMessage(content=[ToolResultBlock(tool_use_id="tu_1", content="done")]) + yield AssistantMessage(content=[TextBlock(text="ok")], model="m") + yield ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id=self.session_id, + stop_reason="end_turn", + total_cost_usd=0.5, + usage={"input_tokens": 10, "output_tokens": 5}, + model_usage=cast( + "Any", + { + "claude-opus-5": { + "inputTokens": 8, + "outputTokens": 4, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "webSearchRequests": 1, + "costUSD": 0.4, + }, + "claude-haiku-4-5": { + "inputTokens": 2, + "outputTokens": 1, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "webSearchRequests": 0, + "costUSD": 0.1, + }, + }, + ), + ) + + +class Api: + def __init__(self, world: World, memory_root: Path | None = None) -> None: + self.world = world + self.store = TokenStore(world.db, bootstrap={"t": TOKEN}) + self.runtime = GatewayRuntime( + agents=world.conversations._agents, # noqa: SLF001 + mcps=McpRegistry([]), + backends={"a": world.backend, "d": world.deep_backend}, + token_store=self.store, + db=world.db, + admin_user="admin", + admin_pass="secret", + session_secret="s" * 32, + frontends=(world.api, world.markdown), + conversations=world.conversations, + bus=world.bus, + pool=world.pool, + ) + self.app = build_api(self.runtime, memory_root=memory_root) + self.http = AsyncClient( + transport=ASGITransport(app=self.app), base_url="http://api" + ) + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + res = await self.http.get(path, params=params, headers=HEADERS) + assert res.status_code == 200, res.text + return res.json() + + +@pytest.fixture +async def world() -> World: + root = Path(tempfile.mkdtemp(prefix="beaver-api-")) + w = await World(root).setup() + yield w + await w.conversations.stop() + await w.pool.close_all() + await w.db.dispose() + + +async def seed_usage(world: World, rows: list[dict[str, Any]]) -> None: + async with world.db.session() as session: + for row in rows: + session.add(Usage(**row)) + await session.commit() + + +async def test_usage_groups_by_agent_day_and_model(world: World) -> None: + api = Api(world) + now = datetime.now(UTC).replace(tzinfo=None) + await seed_usage( + world, + [ + { + "ts": now - timedelta(hours=1), + "agent_name": "a", + "conversation_id": "c1", + "model": "claude-opus-5", + "input_tokens": 10, + "output_tokens": 20, + "cache_read_tokens": 100, + "cache_creation_tokens": 5, + "cost_usd": 0.5, + "model_usage": { + "claude-opus-5": { + "inputTokens": 10, + "outputTokens": 20, + "cacheReadInputTokens": 100, + "cacheCreationInputTokens": 5, + "webSearchRequests": 2, + "costUSD": 0.5, + } + }, + }, + { + "ts": now - timedelta(days=2), + "agent_name": "d", + "conversation_id": "c2", + "model": "claude-sonnet-5", + "input_tokens": 1, + "output_tokens": 2, + "cost_usd": 0.1, + }, + ], + ) + by_agent = await api.get("/api/usage", {"group_by": "agent"}) + assert [r["agent"] for r in by_agent["rows"]] == ["a"] + assert by_agent["total"] == { + "turns": 1, + "input": 10, + "output": 20, + "cache_read": 100, + "cache_creation": 5, + "cost_usd": 0.5, + "web_searches": 2, + } + since = (datetime.now(UTC) - timedelta(days=3)).isoformat() + by_day = await api.get("/api/usage", {"group_by": "day", "since": since}) + assert len(by_day["rows"]) == 2 + assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"] + by_model = await api.get("/api/usage", {"group_by": "model", "since": since}) + models = {r["model"]: r for r in by_model["rows"]} + assert models["claude-opus-5"]["web_searches"] == 2 + assert models["claude-sonnet-5"]["output"] == 2 + by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since}) + assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"} + bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS) + assert bad.status_code == 400 + + +async def test_limits_report_latest_window_with_gateway_spend(world: World) -> None: + api = Api(world) + now = datetime.now(UTC) + async with world.db.session() as session: + session.add( + RateLimit( + window="five_hour", + status="allowed", + utilization=0.2, + resets_at=now + timedelta(hours=4), + ) + ) + session.add( + RateLimit( + window="five_hour", + status="allowed_warning", + utilization=0.9, + resets_at=now + timedelta(hours=4), + ) + ) + session.add(RateLimit(window="seven_day", status="allowed", utilization=0.3)) + await session.commit() + await seed_usage( + world, + [ + { + "ts": (now - timedelta(minutes=30)).replace(tzinfo=None), + "agent_name": "a", + "model": "m", + "output_tokens": 7, + "cost_usd": 0.25, + } + ], + ) + out = await api.get("/api/limits") + windows = {w["window"]: w for w in out["windows"]} + assert windows["five_hour"]["utilization"] == 0.9 + assert windows["five_hour"]["status"] == "allowed_warning" + assert windows["five_hour"]["gateway"]["output"] == 7 + assert windows["five_hour"]["gateway"]["cost_usd"] == 0.25 + assert windows["seven_day"]["utilization"] == 0.3 + assert [w["window"] for w in out["windows"]] == ["five_hour", "seven_day"] + assert len(out["history"]) == 3 + + +async def test_memory_tree_and_file(world: World) -> None: + root = world.root / "zone" + (root / "дни").mkdir(parents=True) + (root / "дни" / "2026-08-27.md").write_text("# day", encoding="utf-8") + (root / "состояние.md").write_text("# state", encoding="utf-8") + (root / ".hidden").write_text("x", encoding="utf-8") + api = Api(world, memory_root=root) + tree = await api.get("/api/memory") + names = [n["name"] for n in tree["tree"]] + assert names == ["дни", "состояние.md"] + assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md" + file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"}) + assert file["content"] == "# day" + escape = await api.http.get( + "/api/memory/file", params={"path": "../w.db"}, headers=HEADERS + ) + assert escape.status_code == 404 + unset = Api(world) + res = await unset.http.get("/api/memory", headers=HEADERS) + assert res.status_code == 404 + + +async def test_describe_snapshots_open_tools_and_records_rate_limit( + world: World, +) -> None: + world.backend._factory = ToolClient # noqa: SLF001 + api = Api(world) + + async def record(event: Any) -> None: + async with world.db.session() as session: + session.add( + Usage( + agent_name=event.agent_name, + conversation_id=event.conversation_id, + model=event.model, + cost_usd=event.usage.cost_usd, + model_usage=event.usage.model_usage, + ) + ) + await session.commit() + + world.backend._usage_sink = record # noqa: SLF001 + conv = await world.conversations.create(kind="master", agent="a", origin="test") + ScriptedClient.hold = asyncio.Event() + await world.conversations.post(conv, "go") + await asyncio.sleep(0.3) + described = await api.get(f"/api/conversations/{conv.external_id}") + turn = described["turn"] + assert turn is not None and turn["id"] == described["running_turn"] + tools = {t["tool_use_id"]: t for t in turn["tools"]} + assert tools["tu_1"]["name"] == "Bash" and tools["tu_1"]["ended_at"] is None + assert tools["tu_2"]["parent_tool_use_id"] == "tu_1" + ScriptedClient.hold.set() + await world.settle(conv, 1) + described = await api.get(f"/api/conversations/{conv.external_id}") + assert described["turn"] is None + limits = await api.get("/api/limits") + assert limits["windows"][0]["utilization"] == 0.8 + assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5 + usage = await api.get("/api/usage", {"group_by": "model"}) + assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"} + assert usage["total"]["web_searches"] == 1 + conv = await world.conversations.get(conv.external_id) + assert conv is not None and conv.session_id is not None + await world.store.append( + world.key(conv.session_id), + build_entries( + [ + {"role": "user", "content": "go"}, + {"role": "assistant", "content": [{"type": "text", "text": "ok"}]}, + ], + session_id=conv.session_id, + cwd=str(world.root), + model="m", + ), + ) + history = await api.get(f"/api/conversations/{conv.external_id}/history") + assert [m["role"] for m in history["messages"]] == ["user", "assistant"] + entries = await api.get(f"/api/conversations/{conv.external_id}/entries") + assert entries["total"] == len(entries["entries"]) == 2 + page = await api.get( + f"/api/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0} + ) + assert page["offset"] == 0 and len(page["entries"]) == 1 + + +async def test_tokens_and_audit_need_admin_scope(world: World) -> None: + api = Api(world) + api.store.grant("api-only", "weak", scope="api") + weak = {"Authorization": "Bearer weak"} + assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403 + created = await api.http.post( + "/api/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS + ) + assert created.status_code == 201 + plaintext = created.json()["plaintext"] + await api.store.invalidate() + identity = await api.store.verify(plaintext) + assert identity is not None and identity.scope == "mcp" + listed = await api.get("/api/tokens") + assert [t["name"] for t in listed["tokens"]] == ["cursor"] + token_id = listed["tokens"][0]["id"] + revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS) + assert revoked.status_code == 200 + assert (await api.get("/api/tokens"))["tokens"] == [] + audit = await api.get("/api/audit") + assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"] + + +async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None: + api = Api(world) + ui_dir = world.root / "build" + ui_dir.mkdir() + (ui_dir / "index.html").write_text("ui", encoding="utf-8") + admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) + api.store.grant("admin-ui", "ui-bearer") + http = AsyncClient(transport=ASGITransport(app=admin), base_url="http://admin") + assert (await http.get("/admin/auth/session")).status_code == 401 + bad = await http.post( + "/admin/auth/login", json={"username": "admin", "password": "nope"} + ) + assert bad.status_code == 401 + ok = await http.post( + "/admin/auth/login", json={"username": "admin", "password": "secret"} + ) + assert ok.status_code == 200 + session = (await http.get("/admin/auth/session")).json() + assert session["user"] == "admin" and session["token"] == "ui-bearer" + assert session["api_base"] is None + spa = await http.get("/admin/conversations/abc") + assert spa.status_code == 200 and spa.text == "ui" + assert (await http.get("/")).status_code == 307 + escape = await http.get("/admin/%2e%2e/pyproject.toml") + assert escape.status_code == 200 and escape.text == "ui" + me = await api.http.get( + "/api/agents", headers={"Authorization": "Bearer ui-bearer"} + ) + assert me.status_code == 200 + assert (await http.post("/admin/auth/logout")).status_code == 204 + assert (await http.get("/admin/auth/session")).status_code == 401 diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 2874f72..fd50a5e 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -689,14 +689,17 @@ def test_agent_kinds_follow_prompts(tmp_path: Path) -> None: async def test_observer_publishes_tool_results_for_the_panel(world: World) -> None: seen: list[dict[str, Any]] = [] + conv = await world.conversations.create(kind="master", agent="a", origin="test") async def collect() -> None: - async for event in world.bus.stream(conversation_id="c1"): + async for event in world.bus.stream(conversation_id=conv.external_id): seen.append(event) task = asyncio.create_task(collect()) await asyncio.sleep(0) - observe = world.conversations._observer("c1", "t1", "user") + runner = world.conversations._runner(conv.id) + runner.turn_id = "t1" + observe = world.conversations._observer(conv, runner, "t1", "user") observe( AssistantMessage( content=[ToolUseBlock(id="toolu_1", name="Bash", input={"command": "ls"})], @@ -718,6 +721,8 @@ async def test_observer_publishes_tool_results_for_the_panel(world: World) -> No assert [e["type"] for e in seen] == ["tool", "tool.result"] tool, result = seen assert tool["tool_use_id"] == "toolu_1" and tool["parent_tool_use_id"] == "toolu_0" + snapshot = runner.snapshot() + assert snapshot is not None and snapshot["tools"][0]["content"] == "a\nb" assert result["tool_use_id"] == "toolu_1" assert result["parent_tool_use_id"] == "toolu_0" assert result["turn_id"] == "t1" and result["is_error"] is False diff --git a/ui/bun.lock b/ui/bun.lock index ef8d203..5df8a50 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -8,13 +8,14 @@ "@biomejs/biome": "2.5.9", "@fontsource-variable/inter": "^5.3.0", "@internationalized/date": "^3.12.3", - "@lucide/svelte": "^1.34.0", + "@lucide/svelte": "^1.35.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", + "@types/node": "^26.4.0", "bits-ui": "^2.19.0", "clsx": "^2.1.1", "mode-watcher": "^1.1.0", @@ -83,7 +84,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lucide/svelte": ["@lucide/svelte@1.34.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-sHFL8KVSaPXv0dsmdUTq4q/tj8clT2XYejnoSg0mHObpXWn/9SfET4f14v/7VOba1IsbPIW9+sWaq+Qq1wTdNA=="], + "@lucide/svelte": ["@lucide/svelte@1.35.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-1I5WeiEFc21HOLv/GR11vuN3MqN/k/mhCEMW7nEtwSAGOcZ0C5ZgI1Y2Rvs/egfKTUiOTw+R9Ys9BJ+GwAKSWw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -183,6 +184,8 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -449,6 +452,8 @@ "ultracite": ["ultracite@7.10.7", "", { "dependencies": { "@clack/prompts": "^1.5.1", "cli-truncate": "^6.1.1", "commander": "^15.0.0", "deepmerge": "^4.3.1", "empathic": "^2.0.1", "execa": "^10.0.1", "fast-glob": "^3.3.3", "find-workspaces": "^0.3.1", "jsonc-parser": "^3.3.1", "log-update": "^8.0.0", "magicast": "^0.5.4", "nypm": "^0.6.9", "resolve.exports": "^2.0.3", "string-width": "^8.2.2", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.79.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-rc+TG/zLCV+Uk1PL4XPmsq7CW8GtlaV4g7Q53Bt5o7UNGSKKNRoPY4syz1BDWwSoNBcvWKpSnfSVflsecok4EA=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/ui/package.json b/ui/package.json index d79e0f6..d9947df 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,13 +17,14 @@ "@biomejs/biome": "2.5.9", "@fontsource-variable/inter": "^5.3.0", "@internationalized/date": "^3.12.3", - "@lucide/svelte": "^1.34.0", + "@lucide/svelte": "^1.35.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", + "@types/node": "^26.4.0", "bits-ui": "^2.19.0", "clsx": "^2.1.1", "mode-watcher": "^1.1.0", diff --git a/ui/src/app.html b/ui/src/app.html index 14260dd..52146a4 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -2,11 +2,29 @@ - + + + Beaver %sveltekit.head% +
%sveltekit.body%
diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts new file mode 100644 index 0000000..236aad4 --- /dev/null +++ b/ui/src/lib/api/client.ts @@ -0,0 +1,323 @@ +import { readSse } from "./sse"; +import type { + AgentsResponse, + AuditPage, + BusEvent, + ConversationInfo, + ConversationSummary, + EntriesPage, + HistoryMessage, + LimitsResponse, + MemoryFile, + MemoryTree, + Schedule, + SessionsResponse, + TokenRow, + UsageGroup, + UsageResponse, +} from "./types"; + +const TRAILING_SLASHES = /\/+$/; + +export class ApiError extends Error { + status: number; + body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +function messageOf(status: number, body: unknown): string { + if (body && typeof body === "object") { + const record = body as Record; + for (const key of ["error", "detail"]) { + if (typeof record[key] === "string") { + return record[key] as string; + } + } + } + if (typeof body === "string" && body) { + return body; + } + return `HTTP ${status}`; +} + +async function bodyOf(response: Response): Promise { + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export type Params = Record; + +function query(params?: Params): string { + if (!params) { + return ""; + } + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") { + search.set(key, String(value)); + } + } + const text = search.toString(); + return text ? `?${text}` : ""; +} + +export interface ClientOptions { + onUnauthorized?: () => Promise; +} + +// One client per signed-in session: the gateway origin, a bearer, and a +// hook that refreshes the bearer once when the gateway rotated it. +export class ApiClient { + base: string; + token: string; + private readonly onUnauthorized?: () => Promise; + + constructor(base: string, token: string, options: ClientOptions = {}) { + this.base = base.replace(TRAILING_SLASHES, ""); + this.token = token; + this.onUnauthorized = options.onUnauthorized; + } + + url(path: string, params?: Params): string { + return `${this.base}${path}${query(params)}`; + } + + private headers(extra: Record = {}): Record { + return { Authorization: `Bearer ${this.token}`, ...extra }; + } + + private async request( + method: string, + path: string, + body?: unknown, + params?: Params, + retried = false + ): Promise { + const response = await fetch(this.url(path, params), { + body: body === undefined ? undefined : JSON.stringify(body), + headers: this.headers( + body === undefined ? {} : { "Content-Type": "application/json" } + ), + method, + }); + if (response.status === 401 && !retried && this.onUnauthorized) { + const token = await this.onUnauthorized(); + if (token) { + this.token = token; + return this.request(method, path, body, params, true); + } + } + if (!response.ok) { + const payload = await bodyOf(response); + throw new ApiError( + response.status, + messageOf(response.status, payload), + payload + ); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } + + get(path: string, params?: Params): Promise { + return this.request("GET", path, undefined, params); + } + + post(path: string, body?: unknown): Promise { + return this.request("POST", path, body ?? {}); + } + + patch(path: string, body?: unknown): Promise { + return this.request("PATCH", path, body ?? {}); + } + + async stream( + path: string, + onEvent: (event: BusEvent) => void, + signal: AbortSignal + ): Promise { + const response = await fetch(this.url(path), { + headers: this.headers({ Accept: "text/event-stream" }), + signal, + }); + if (!response.ok) { + const payload = await bodyOf(response); + throw new ApiError( + response.status, + messageOf(response.status, payload), + payload + ); + } + await readSse(response, onEvent, signal); + } + + agents(): Promise { + return this.get("/api/agents"); + } + + conversations(params?: { + kind?: string; + status?: string; + limit?: number; + }): Promise<{ conversations: ConversationSummary[] }> { + return this.get("/api/conversations", params); + } + + conversation(id: string): Promise { + return this.get(`/api/conversations/${id}`); + } + + history(id: string): Promise<{ id: string; messages: HistoryMessage[] }> { + return this.get(`/api/conversations/${id}/history`); + } + + entries( + id: string, + params?: { subpath?: string; offset?: number; limit?: number } + ): Promise { + return this.get(`/api/conversations/${id}/entries`, params); + } + + postMessage( + id: string, + text: string, + origin = "panel" + ): Promise<{ id: string; item: number; status: string }> { + return this.post(`/api/conversations/${id}/messages`, { origin, text }); + } + + inject( + id: string, + text: string, + urgency: "normal" | "urgent", + origin = "panel" + ): Promise<{ id: string; item: number; priority: string }> { + return this.post(`/api/conversations/${id}/inject`, { + origin, + text, + urgency, + }); + } + + answer( + id: string, + questionId: string, + answer: string + ): Promise<{ id: string; question_id: string }> { + return this.post(`/api/conversations/${id}/answer`, { + answer, + question_id: questionId, + }); + } + + branch( + id: string, + body: { seed?: string; text?: string; title?: string; window?: number } + ): Promise { + return this.post(`/api/conversations/${id}/branch`, body); + } + + merge( + id: string + ): Promise<{ id: string; status: string; fork: string; text: string }> { + return this.post(`/api/conversations/${id}/merge`); + } + + bind( + id: string, + frontend: string, + externalId: string, + visible: boolean + ): Promise { + return this.post(`/api/conversations/${id}/bind`, { + external_id: externalId, + frontend, + visible, + }); + } + + setFlags( + id: string, + flags: Record + ): Promise { + return this.patch(`/api/conversations/${id}/flags`, flags); + } + + update( + id: string, + body: { status?: string; title?: string } + ): Promise { + return this.patch(`/api/conversations/${id}`, body); + } + + spawn(body: { + kind: string; + agent?: string; + seed?: string; + text?: string; + title?: string; + }): Promise { + return this.post("/api/conversations", body); + } + + sessions(): Promise { + return this.get("/api/sessions"); + } + + schedules(): Promise<{ schedules: Schedule[] }> { + return this.get("/api/schedules"); + } + + usage(params: { + since?: string; + until?: string; + hours?: number; + group_by: UsageGroup; + }): Promise { + return this.get("/api/usage", params); + } + + limits(): Promise { + return this.get("/api/limits"); + } + + memory(): Promise { + return this.get("/api/memory"); + } + + memoryFile(path: string): Promise { + return this.get("/api/memory/file", { path }); + } + + tokens(includeRevoked = false): Promise<{ tokens: TokenRow[] }> { + return this.get("/api/tokens", { + include_revoked: includeRevoked ? 1 : undefined, + }); + } + + createToken( + name: string, + scope: string + ): Promise<{ token: TokenRow; plaintext: string }> { + return this.post("/api/tokens", { name, scope }); + } + + revokeToken(id: number): Promise<{ id: number; revoked: boolean }> { + return this.post(`/api/tokens/${id}/revoke`); + } + + audit(params?: { before?: number; limit?: number }): Promise { + return this.get("/api/audit", params); + } +} diff --git a/ui/src/lib/api/live.svelte.ts b/ui/src/lib/api/live.svelte.ts new file mode 100644 index 0000000..7fb8a97 --- /dev/null +++ b/ui/src/lib/api/live.svelte.ts @@ -0,0 +1,140 @@ +import { type ApiClient, ApiError } from "./client"; +import type { BusEvent } from "./types"; + +export type LiveState = "off" | "connecting" | "open" | "retrying" | "failed"; + +const BACKOFF_MIN_MS = 1000; +const BACKOFF_MAX_MS = 30_000; +const JITTER_MS = 250; +const FATAL = new Set([401, 403, 404]); + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const done = () => { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + }); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export interface LiveOptions { + onEvent: (event: BusEvent) => void; + // Runs before every (re)connect; the snapshot that seeds state, so + // events that follow land on the truth and not on a stale tree. + prepare?: () => Promise; +} + +// SSE subscription with reconnect and backoff. ``state`` is reactive so a +// header can show "open" / "retrying …" without polling. +export class LiveStream { + state = $state("off"); + detail = $state(null); + private controller: AbortController | null = null; + private readonly client: () => ApiClient | null; + private readonly path: string; + private readonly options: LiveOptions; + + constructor( + client: () => ApiClient | null, + path: string, + options: LiveOptions + ) { + this.client = client; + this.path = path; + this.options = options; + } + + get running(): boolean { + return this.controller !== null; + } + + start(): void { + if (this.controller) { + return; + } + const controller = new AbortController(); + this.controller = controller; + this.loop(controller.signal).catch((error: unknown) => { + this.state = "failed"; + this.detail = describe(error); + }); + } + + stop(): void { + this.controller?.abort(); + this.controller = null; + this.state = "off"; + } + + private async loop(signal: AbortSignal): Promise { + let delay = BACKOFF_MIN_MS; + while (!signal.aborted) { + this.state = delay === BACKOFF_MIN_MS ? "connecting" : "retrying"; + // biome-ignore lint/performance/noAwaitInLoops: one connection at a time, by design + const outcome = await this.connectOnce(signal, () => { + delay = BACKOFF_MIN_MS; + }); + if (signal.aborted || outcome === "fatal") { + return; + } + await sleep(delay + Math.random() * JITTER_MS, signal); + delay = Math.min(delay * 2, BACKOFF_MAX_MS); + } + } + + private async connectOnce( + signal: AbortSignal, + onOpen: () => void + ): Promise<"retry" | "fatal"> { + try { + const client = this.client(); + if (!client) { + throw new ApiError(401, "not signed in", null); + } + await this.options.prepare?.(); + if (signal.aborted) { + return "fatal"; + } + await client.stream( + this.path, + (event) => { + if (event.type === "hello") { + onOpen(); + this.state = "open"; + this.detail = null; + return; + } + this.options.onEvent(event); + }, + signal + ); + this.state = "retrying"; + this.detail = "stream closed"; + return "retry"; + } catch (error) { + if (signal.aborted) { + return "fatal"; + } + if (error instanceof ApiError && FATAL.has(error.status)) { + this.state = "failed"; + this.detail = `${error.status}: ${error.message}`; + this.controller = null; + return "fatal"; + } + this.state = "retrying"; + this.detail = describe(error); + return "retry"; + } + } +} diff --git a/ui/src/lib/api/sse.ts b/ui/src/lib/api/sse.ts new file mode 100644 index 0000000..ceb1b07 --- /dev/null +++ b/ui/src/lib/api/sse.ts @@ -0,0 +1,66 @@ +import type { BusEvent } from "./types"; + +const FRAME_BREAK = /\r?\n\r?\n/; +const LINE_BREAK = /\r?\n/; + +function parseFrame(frame: string): BusEvent | null { + const data: string[] = []; + for (const line of frame.split(LINE_BREAK)) { + if (line.startsWith("data:")) { + data.push(line.slice(5).trimStart()); + } + } + if (data.length === 0) { + return null; + } + try { + const parsed: unknown = JSON.parse(data.join("\n")); + return parsed && typeof parsed === "object" ? (parsed as BusEvent) : null; + } catch { + return null; + } +} + +function drain(buffer: string, onEvent: (event: BusEvent) => void): string { + let rest = buffer; + let index = rest.search(FRAME_BREAK); + while (index >= 0) { + const event = parseFrame(rest.slice(0, index)); + rest = rest.slice(index).replace(FRAME_BREAK, ""); + if (event) { + onEvent(event); + } + index = rest.search(FRAME_BREAK); + } + return rest; +} + +export async function readSse( + response: Response, + onEvent: (event: BusEvent) => void, + signal?: AbortSignal +): Promise { + const { body } = response; + if (!body) { + throw new Error("SSE response has no body"); + } + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const abort = () => { + reader.cancel().catch(() => undefined); + }; + signal?.addEventListener("abort", abort, { once: true }); + try { + for (;;) { + // biome-ignore lint/performance/noAwaitInLoops: a stream is read chunk by chunk + const { value, done } = await reader.read(); + if (done) { + break; + } + buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent); + } + } finally { + signal?.removeEventListener("abort", abort); + } +} diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts new file mode 100644 index 0000000..4ab1d2b --- /dev/null +++ b/ui/src/lib/api/types.ts @@ -0,0 +1,278 @@ +export type Kind = "master" | "branch" | "deep" | "job" | "fork"; +export type ConversationStatus = "open" | "merged" | "closed" | "archived"; + +export interface Binding { + external_id: string; + frontend: string; + visible: boolean; +} + +export interface ToolSnapshot { + content: string | null; + ended_at: string | null; + input: unknown; + is_error: boolean | null; + name: string; + parent_tool_use_id: string | null; + started_at: string; + tool_use_id: string; +} + +export interface TurnSnapshot { + id: string; + origin: string | null; + started_at: string | null; + text: string | null; + tools: ToolSnapshot[]; +} + +export interface QuestionOption { + description?: string; + label: string; +} + +export interface Question { + header?: string; + multiSelect?: boolean; + options?: QuestionOption[]; + question: string; +} + +export interface PendingQuestion { + id: string; + questions: Question[]; +} + +export interface QueueItem { + created_at: string; + id: number; + origin: string; + priority: "urgent" | "user" | "normal"; + status: "queued" | "running" | "done" | "failed" | "interrupted"; + text: string; +} + +export interface ConversationSummary { + agent: string; + created_at: string | null; + flags: Record; + id: string; + kind: Kind; + last_activity_at: string | null; + last_user_activity_at: string | null; + origin: string; + parent_row: number | null; + pending_question: boolean; + running_turn: string | null; + session_id: string | null; + status: ConversationStatus; + title: string | null; +} + +export interface ConversationInfo extends ConversationSummary { + bindings: Binding[]; + busy: boolean; + live: boolean; + parent: string | null; + question: PendingQuestion | null; + queue: QueueItem[]; + turn: TurnSnapshot | null; +} + +export interface BusEvent { + conversation_id?: string; + seq: number; + ts: string; + turn_id?: string; + type: string; + [key: string]: unknown; +} + +export interface ContentBlock { + content?: unknown; + id?: string; + input?: unknown; + is_error?: boolean; + name?: string; + text?: string; + thinking?: string; + tool_use_id?: string; + type: string; +} + +export interface HistoryMessage { + content: string | ContentBlock[]; + role: "user" | "assistant"; +} + +export interface EntriesPage { + entries: Record[]; + id: string; + offset: number; + subpath: string; + subpaths: string[]; + total: number; +} + +export interface AgentInfo { + effort: string | null; + kinds: Kind[]; + model: string; + name: string; + type: string; +} + +export interface FrontendInfo { + default_agents: Record; + kinds: Kind[]; + name: string; + port: number | null; + public_base_url: string | null; + type: string; +} + +export interface AgentsResponse { + agents: AgentInfo[]; + frontends: FrontendInfo[]; + mcps: { name: string; kind: string }[]; +} + +export interface SessionRow { + age_seconds: number; + agent: string; + busy: boolean; + dirty: boolean; + idle_seconds: number; + key: string; + kind: string; + pending_question: boolean; + pid: number | null; + pinned: boolean; + rss: number | null; + running_turn: string | null; + session_id: string | null; + turns: number; +} + +export interface SessionsResponse { + rss: number | null; + rss_limit: number | null; + sessions: SessionRow[]; +} + +export interface UsageTotals { + cache_creation: number; + cache_read: number; + cost_usd: number; + input: number; + output: number; + turns: number; + web_searches: number; +} + +export type UsageGroup = "day" | "agent" | "conversation" | "model"; + +export interface UsageRow extends UsageTotals { + agent?: string; + conversation?: string; + day?: string; + key: string; + kind?: Kind; + model?: string; + status?: ConversationStatus; + title?: string | null; +} + +export interface UsageResponse { + group_by: UsageGroup; + rows: UsageRow[]; + since: string; + total: UsageTotals; + until: string; +} + +export type LimitStatus = "allowed" | "allowed_warning" | "rejected"; + +export interface LimitRecord { + agent: string | null; + id: number; + overage_resets_at: string | null; + overage_status: LimitStatus | null; + resets_at: string | null; + session_id: string | null; + status: LimitStatus; + ts: string; + utilization: number | null; + window: string; +} + +export interface LimitWindow extends LimitRecord { + gateway: UsageTotals & { since: string }; +} + +export interface LimitsResponse { + history: LimitRecord[]; + windows: LimitWindow[]; +} + +export interface MemoryNode { + children?: MemoryNode[]; + mtime: string; + name: string; + path: string; + size?: number; + type: "dir" | "file"; +} + +export interface MemoryTree { + root: string; + tree: MemoryNode[]; +} + +export interface MemoryFile { + content: string; + mtime: string; + path: string; + size: number; +} + +export interface TokenRow { + created_at: string; + id: number; + last_used_at: string | null; + name: string; + revoked_at: string | null; + scope: string; +} + +export interface AuditRecord { + actor: string; + agent: string | null; + detail: Record | string; + id: number; + kind: string; + ts: string; +} + +export interface AuditPage { + next_before: number | null; + records: AuditRecord[]; +} + +export interface Schedule { + conversation_row: number; + created_at: string; + delivered_at: string | null; + execute_at: string; + id: number; + text: string; +} + +export interface TurnUsage { + cache_creation?: number; + cache_read?: number; + cost_usd?: number | null; + duration_ms?: number | null; + input?: number; + output?: number; +} diff --git a/ui/src/lib/components/app-sidebar.svelte b/ui/src/lib/components/app-sidebar.svelte new file mode 100644 index 0000000..e909482 --- /dev/null +++ b/ui/src/lib/components/app-sidebar.svelte @@ -0,0 +1,75 @@ + + + diff --git a/ui/src/lib/components/bottom-nav.svelte b/ui/src/lib/components/bottom-nav.svelte new file mode 100644 index 0000000..71f814e --- /dev/null +++ b/ui/src/lib/components/bottom-nav.svelte @@ -0,0 +1,79 @@ + + + + + + + More + {#each secondary as item (item.href)} + { + moreOpen = false; + }} + > + + {item.label} + + {/each} +
+ +
+ + +
+
+
+
diff --git a/ui/src/lib/components/empty-state.svelte b/ui/src/lib/components/empty-state.svelte new file mode 100644 index 0000000..2dd7e45 --- /dev/null +++ b/ui/src/lib/components/empty-state.svelte @@ -0,0 +1,31 @@ + + +
+

{title}

+ {#if hint} +

{hint}

+ {/if} + {#if children} +
{@render children()}
+ {/if} +
diff --git a/ui/src/lib/components/error-note.svelte b/ui/src/lib/components/error-note.svelte new file mode 100644 index 0000000..812d7ef --- /dev/null +++ b/ui/src/lib/components/error-note.svelte @@ -0,0 +1,18 @@ + + + diff --git a/ui/src/lib/components/kind-badge.svelte b/ui/src/lib/components/kind-badge.svelte new file mode 100644 index 0000000..a1c4599 --- /dev/null +++ b/ui/src/lib/components/kind-badge.svelte @@ -0,0 +1,25 @@ + + + + {kind} + diff --git a/ui/src/lib/components/limit-bar.svelte b/ui/src/lib/components/limit-bar.svelte new file mode 100644 index 0000000..1adb29a --- /dev/null +++ b/ui/src/lib/components/limit-bar.svelte @@ -0,0 +1,69 @@ + + +
+ {limitLabel(w.window)} + {#if known} +
+
+
+ + {pct}% + + {:else} + + utilization not reported yet + + + {/if} + {fmtCountdown(w.resets_at, now)} + + via gateway this window: {fmtTokens(spent)} tokens · + {fmtMoney(w.gateway.cost_usd)} + · {w.gateway.turns} turns + {#if w.status !== "allowed"} + {w.status.replace("_", " ")} + {/if} + +
diff --git a/ui/src/lib/components/live-dot.svelte b/ui/src/lib/components/live-dot.svelte new file mode 100644 index 0000000..ecbefa9 --- /dev/null +++ b/ui/src/lib/components/live-dot.svelte @@ -0,0 +1,50 @@ + + + + + {#if label} + {TEXT[state]} + {/if} + diff --git a/ui/src/lib/components/page-header.svelte b/ui/src/lib/components/page-header.svelte new file mode 100644 index 0000000..b7c53d8 --- /dev/null +++ b/ui/src/lib/components/page-header.svelte @@ -0,0 +1,44 @@ + + +
+
+

{title}

+ {#if subtitle} + {subtitle} + {/if} +
+ {#if children} +
+ {@render children()} +
+ {/if} +
+ {#if actions} + {@render actions()} + {/if} + +
+
diff --git a/ui/src/lib/components/stat.svelte b/ui/src/lib/components/stat.svelte new file mode 100644 index 0000000..74f59ff --- /dev/null +++ b/ui/src/lib/components/stat.svelte @@ -0,0 +1,37 @@ + + +
+ {label} + + {value} + + {#if hint} + {hint} + {/if} +
diff --git a/ui/src/lib/components/status-pill.svelte b/ui/src/lib/components/status-pill.svelte new file mode 100644 index 0000000..c7ec405 --- /dev/null +++ b/ui/src/lib/components/status-pill.svelte @@ -0,0 +1,39 @@ + + + + + {status.replace("_", " ")} + diff --git a/ui/src/lib/components/theme-toggle.svelte b/ui/src/lib/components/theme-toggle.svelte new file mode 100644 index 0000000..8e7557a --- /dev/null +++ b/ui/src/lib/components/theme-toggle.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog-close.svelte b/ui/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..8a92fbd --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog-content.svelte b/ui/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..a005349 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,53 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + {#snippet child({ props })} + + {/snippet} + + {/if} + + diff --git a/ui/src/lib/components/ui/dialog/dialog-description.svelte b/ui/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3ed5bd5 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog-footer.svelte b/ui/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..f1d7ec3 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,32 @@ + + +
+ {@render children?.()} + {#if showCloseButton} + + {#snippet child({ props })} + + {/snippet} + + {/if} +
diff --git a/ui/src/lib/components/ui/dialog/dialog-header.svelte b/ui/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..885bad0 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/dialog/dialog-overlay.svelte b/ui/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..edcdd9d --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog-portal.svelte b/ui/src/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..d208462 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog-title.svelte b/ui/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..25d9d77 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog-trigger.svelte b/ui/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..1b49765 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/dialog.svelte b/ui/src/lib/components/ui/dialog/dialog.svelte new file mode 100644 index 0000000..84ecf31 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/dialog.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/dialog/index.ts b/ui/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..9aa2082 --- /dev/null +++ b/ui/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,34 @@ +import Close from "./dialog-close.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Portal from "./dialog-portal.svelte"; +import Title from "./dialog-title.svelte"; +import Trigger from "./dialog-trigger.svelte"; +import Root from "./dialog.svelte"; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 0000000..442ee4f --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..81ad31b --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,44 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else if checked} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..5786225 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,33 @@ + + + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..6300ce4 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..2473b77 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,14 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..da6269c --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..7ed0f5f --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000..f9e1e2a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..d4bbe25 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..4702ad6 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,34 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..8c90ca4 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..32cc98e --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..9720c1a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..f834a4f --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..2ab8a41 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..a52fb99 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,14 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000..40b84e0 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/index.ts b/ui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..be8af59 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import Root from "./dropdown-menu.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/ui/src/lib/components/ui/input/index.ts b/ui/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..ceb4b16 --- /dev/null +++ b/ui/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/ui/src/lib/components/ui/input/input.svelte b/ui/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..af0e68f --- /dev/null +++ b/ui/src/lib/components/ui/input/input.svelte @@ -0,0 +1,54 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/label/index.ts b/ui/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..b0b23ce --- /dev/null +++ b/ui/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/ui/src/lib/components/ui/label/label.svelte b/ui/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..f378b07 --- /dev/null +++ b/ui/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/select/index.ts b/ui/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..5a4d1b6 --- /dev/null +++ b/ui/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Content from "./select-content.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Group from "./select-group.svelte"; +import Item from "./select-item.svelte"; +import Label from "./select-label.svelte"; +import Portal from "./select-portal.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import Separator from "./select-separator.svelte"; +import Trigger from "./select-trigger.svelte"; +import Root from "./select.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/ui/src/lib/components/ui/select/select-content.svelte b/ui/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..303818a --- /dev/null +++ b/ui/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/ui/src/lib/components/ui/select/select-group-heading.svelte b/ui/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..538b75f --- /dev/null +++ b/ui/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/select/select-group.svelte b/ui/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..9394640 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/select/select-item.svelte b/ui/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..1de2b60 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,40 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + + {/snippet} + diff --git a/ui/src/lib/components/ui/select/select-label.svelte b/ui/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..1b8d55b --- /dev/null +++ b/ui/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/select/select-portal.svelte b/ui/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..de6004b --- /dev/null +++ b/ui/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/select/select-scroll-down-button.svelte b/ui/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..152a8d3 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/ui/src/lib/components/ui/select/select-scroll-up-button.svelte b/ui/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..a1d69c0 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/ui/src/lib/components/ui/select/select-separator.svelte b/ui/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..c1cb775 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/ui/src/lib/components/ui/select/select-trigger.svelte b/ui/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..50573c6 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/select/select.svelte b/ui/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..e5fb6b2 --- /dev/null +++ b/ui/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/ui/src/lib/components/ui/separator/index.ts b/ui/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..d66644e --- /dev/null +++ b/ui/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/ui/src/lib/components/ui/separator/separator.svelte b/ui/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..c5ddb3e --- /dev/null +++ b/ui/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,23 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/index.ts b/ui/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..d7b6230 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,34 @@ +import Close from "./sheet-close.svelte"; +import Content from "./sheet-content.svelte"; +import Description from "./sheet-description.svelte"; +import Footer from "./sheet-footer.svelte"; +import Header from "./sheet-header.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Portal from "./sheet-portal.svelte"; +import Title from "./sheet-title.svelte"; +import Trigger from "./sheet-trigger.svelte"; +import Root from "./sheet.svelte"; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/ui/src/lib/components/ui/sheet/sheet-close.svelte b/ui/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..3edf368 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-content.svelte b/ui/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..fa5f044 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,60 @@ + + + + + + + + {@render children?.()} + {#if showCloseButton} + + {#snippet child({ props })} + + {/snippet} + + {/if} + + diff --git a/ui/src/lib/components/ui/sheet/sheet-description.svelte b/ui/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..a1c020d --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-footer.svelte b/ui/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..7b0a2b8 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sheet/sheet-header.svelte b/ui/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..03d1b2e --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sheet/sheet-overlay.svelte b/ui/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..72ba1d8 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-portal.svelte b/ui/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 0000000..27812f0 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-title.svelte b/ui/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9d19949 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-trigger.svelte b/ui/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..417d73f --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet.svelte b/ui/src/lib/components/ui/sheet/sheet.svelte new file mode 100644 index 0000000..40537ae --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/skeleton/index.ts b/ui/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..cb26b2c --- /dev/null +++ b/ui/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/ui/src/lib/components/ui/skeleton/skeleton.svelte b/ui/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..f5f0ebf --- /dev/null +++ b/ui/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/ui/src/lib/components/ui/sonner/index.ts b/ui/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/ui/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/ui/src/lib/components/ui/sonner/sonner.svelte b/ui/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..2d4cee6 --- /dev/null +++ b/ui/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,37 @@ + + + + {#snippet loadingIcon()} + + {/snippet} + {#snippet successIcon()} + + {/snippet} + {#snippet errorIcon()} + + {/snippet} + {#snippet infoIcon()} + + {/snippet} + {#snippet warningIcon()} + + {/snippet} + diff --git a/ui/src/lib/components/ui/switch/index.ts b/ui/src/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..99620eb --- /dev/null +++ b/ui/src/lib/components/ui/switch/index.ts @@ -0,0 +1,7 @@ +import Root from "./switch.svelte"; + +export { + Root, + // + Root as Switch, +}; diff --git a/ui/src/lib/components/ui/switch/switch.svelte b/ui/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..e80cacc --- /dev/null +++ b/ui/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/ui/src/lib/components/ui/table/index.ts b/ui/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..1be68c5 --- /dev/null +++ b/ui/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; +import Root from "./table.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/ui/src/lib/components/ui/table/table-body.svelte b/ui/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..1871929 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-caption.svelte b/ui/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..f5578b6 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-cell.svelte b/ui/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..28d751e --- /dev/null +++ b/ui/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-footer.svelte b/ui/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..78fde31 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-head.svelte b/ui/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..c80e1f5 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-header.svelte b/ui/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..608695b --- /dev/null +++ b/ui/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-row.svelte b/ui/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..4dee5de --- /dev/null +++ b/ui/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table.svelte b/ui/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..37a9da6 --- /dev/null +++ b/ui/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
+ + {@render children?.()} +
+
diff --git a/ui/src/lib/components/ui/tabs/index.ts b/ui/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..bebe6af --- /dev/null +++ b/ui/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,21 @@ +import Content from "./tabs-content.svelte"; +import Trigger from "./tabs-trigger.svelte"; +import Root from "./tabs.svelte"; +import List, { + tabsListVariants, + type TabsListVariant, +} from "./tabs-list.svelte"; + +export { + Root, + Content, + List, + Trigger, + tabsListVariants, + type TabsListVariant, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/ui/src/lib/components/ui/tabs/tabs-content.svelte b/ui/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..957ad6d --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs-list.svelte b/ui/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..fa53336 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,42 @@ + + + + + diff --git a/ui/src/lib/components/ui/tabs/tabs-trigger.svelte b/ui/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..259a0e0 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,23 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs.svelte b/ui/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..930efbf --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/index.ts b/ui/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..a3d6935 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Content from "./tooltip-content.svelte"; +import Portal from "./tooltip-portal.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Root from "./tooltip.svelte"; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/ui/src/lib/components/ui/tooltip/tooltip-content.svelte b/ui/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..1043dde --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
+ {/snippet} +
+
+
diff --git a/ui/src/lib/components/ui/tooltip/tooltip-portal.svelte b/ui/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..bd0b125 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/tooltip-provider.svelte b/ui/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..b9ebab6 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..dc4b3d6 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/tooltip.svelte b/ui/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..0d28bed --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,8 @@ + + + diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 0000000..1dbac20 --- /dev/null +++ b/ui/src/lib/format.ts @@ -0,0 +1,184 @@ +const THOUSAND = 1000; +const MILLION = 1_000_000; +const MINUTE_MS = 60_000; +const HOUR_MS = 3_600_000; +const DAY_MS = 86_400_000; +const KB = 1024; +const WHITESPACE = /\s+/g; + +export function fmtTokens(n: number | null | undefined): string { + if (n === null || n === undefined) { + return "–"; + } + if (n >= MILLION) { + return `${(n / MILLION).toFixed(n >= 10 * MILLION ? 0 : 1)}M`; + } + if (n >= THOUSAND) { + return `${(n / THOUSAND).toFixed(n >= 10 * THOUSAND ? 0 : 1)}k`; + } + return String(n); +} + +export function fmtMoney(usd: number | null | undefined): string { + if (usd === null || usd === undefined) { + return "–"; + } + if (usd === 0) { + return "$0"; + } + if (usd < 0.01) { + return `$${usd.toFixed(4)}`; + } + if (usd < 1) { + return `$${usd.toFixed(3)}`; + } + return `$${usd.toFixed(2)}`; +} + +export function fmtPct(fraction: number | null | undefined): string { + if (fraction === null || fraction === undefined) { + return "–"; + } + return `${Math.round(fraction * 100)}%`; +} + +export function fmtDuration(ms: number | null | undefined): string { + if (ms === null || ms === undefined) { + return "–"; + } + if (ms < THOUSAND) { + return `${Math.round(ms)}ms`; + } + if (ms < MINUTE_MS) { + return `${(ms / THOUSAND).toFixed(1)}s`; + } + if (ms < HOUR_MS) { + const minutes = Math.floor(ms / MINUTE_MS); + const seconds = Math.round((ms % MINUTE_MS) / THOUSAND); + return `${minutes}m${String(seconds).padStart(2, "0")}s`; + } + const hours = Math.floor(ms / HOUR_MS); + const minutes = Math.round((ms % HOUR_MS) / MINUTE_MS); + return `${hours}h${String(minutes).padStart(2, "0")}m`; +} + +export function fmtSeconds(seconds: number | null | undefined): string { + return seconds === null || seconds === undefined + ? "–" + : fmtDuration(seconds * THOUSAND); +} + +export function fmtBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined) { + return "–"; + } + if (bytes >= KB * KB * KB) { + return `${(bytes / (KB * KB * KB)).toFixed(2)} GB`; + } + if (bytes >= KB * KB) { + return `${(bytes / (KB * KB)).toFixed(0)} MB`; + } + if (bytes >= KB) { + return `${(bytes / KB).toFixed(0)} kB`; + } + return `${bytes} B`; +} + +export function parseDate(iso: string | null | undefined): Date | null { + if (!iso) { + return null; + } + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function fmtTime(iso: string | null | undefined): string { + const date = parseDate(iso); + return date + ? date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + : "–"; +} + +export function fmtDateTime(iso: string | null | undefined): string { + const date = parseDate(iso); + return date + ? date.toLocaleString(undefined, { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short", + }) + : "–"; +} + +export function fmtRelative( + iso: string | null | undefined, + now = Date.now() +): string { + const date = parseDate(iso); + if (!date) { + return "–"; + } + const diff = now - date.getTime(); + const abs = Math.abs(diff); + const suffix = diff >= 0 ? "ago" : "from now"; + if (abs < MINUTE_MS) { + return diff >= 0 ? "just now" : "in <1m"; + } + if (abs < HOUR_MS) { + return `${Math.round(abs / MINUTE_MS)}m ${suffix}`; + } + if (abs < DAY_MS) { + return `${Math.round(abs / HOUR_MS)}h ${suffix}`; + } + return `${Math.round(abs / DAY_MS)}d ${suffix}`; +} + +export function fmtCountdown(iso: string | null | undefined, now = Date.now()) { + const date = parseDate(iso); + if (!date) { + return "–"; + } + const left = date.getTime() - now; + if (left <= 0) { + return "resets now"; + } + if (left < HOUR_MS) { + return `resets in ${Math.ceil(left / MINUTE_MS)}m`; + } + if (left < DAY_MS) { + const hours = Math.floor(left / HOUR_MS); + const minutes = Math.round((left % HOUR_MS) / MINUTE_MS); + return `resets in ${hours}h ${String(minutes).padStart(2, "0")}m`; + } + const days = Math.floor(left / DAY_MS); + const hours = Math.round((left % DAY_MS) / HOUR_MS); + return `resets in ${days}d ${hours}h`; +} + +export function elapsedMs(from: string, to: string | null, now = Date.now()) { + const a = parseDate(from)?.getTime(); + const b = to ? parseDate(to)?.getTime() : now; + if (a === undefined || b === undefined) { + return 0; + } + return Math.max(0, b - a); +} + +export function clip(text: string, max: number): string { + const one = text.replace(WHITESPACE, " ").trim(); + return one.length > max ? `${one.slice(0, max - 1)}…` : one; +} + +export function shortId(id: string | null | undefined): string { + return id ? id.slice(0, 8) : "–"; +} + +export function cacheShare(input: number, cacheRead: number): number | null { + const total = input + cacheRead; + return total > 0 ? cacheRead / total : null; +} diff --git a/ui/src/lib/gateway.svelte.ts b/ui/src/lib/gateway.svelte.ts new file mode 100644 index 0000000..3da494f --- /dev/null +++ b/ui/src/lib/gateway.svelte.ts @@ -0,0 +1,134 @@ +import { LiveStream } from "./api/live.svelte"; +import type { + BusEvent, + ConversationSummary, + LimitsResponse, + LimitWindow, +} from "./api/types"; +import { session } from "./session.svelte"; + +const TAPE_SIZE = 120; +const QUIET = new Set(["stream", "hello"]); + +// Gateway-wide live state: the conversation index kept fresh by +// ``/api/events``, the last non-chatty events as a tape, and the quota +// windows updated the moment the SDK reports them. +class Gateway { + conversations = $state([]); + tape = $state([]); + limits = $state(null); + loaded = $state(false); + readonly live = new LiveStream(() => session.client, "/api/events", { + onEvent: (event) => this.apply(event), + prepare: () => this.load(), + }); + + get running(): ConversationSummary[] { + return this.conversations.filter((row) => row.running_turn); + } + + get open(): ConversationSummary[] { + return this.conversations.filter((row) => row.status === "open"); + } + + byId(id: string): ConversationSummary | undefined { + return this.conversations.find((row) => row.id === id); + } + + async load(): Promise { + const { client } = session; + if (!client) { + return; + } + const [list, limits] = await Promise.all([ + client.conversations({ limit: 500 }), + client.limits().catch(() => null), + ]); + this.conversations = list.conversations; + if (limits) { + this.limits = limits; + } + this.loaded = true; + } + + async refreshLimits(): Promise { + const { client } = session; + if (client) { + this.limits = await client.limits(); + } + } + + private upsert(row: ConversationSummary): void { + const index = this.conversations.findIndex((c) => c.id === row.id); + if (index >= 0) { + this.conversations[index] = { ...this.conversations[index], ...row }; + } else { + this.conversations.unshift(row); + } + } + + apply(event: BusEvent): void { + if (!QUIET.has(event.type)) { + this.tape.unshift(event); + if (this.tape.length > TAPE_SIZE) { + this.tape.length = TAPE_SIZE; + } + } + switch (event.type) { + case "conversation.created": + case "conversation.updated": { + if (typeof event.id === "string") { + this.upsert(event as unknown as ConversationSummary); + } + return; + } + case "turn.start": + case "turn.end": { + const row = event.conversation_id + ? this.byId(event.conversation_id) + : undefined; + if (row) { + row.running_turn = + event.type === "turn.start" ? (event.turn_id ?? null) : null; + row.last_activity_at = event.ts; + } + return; + } + case "rate_limit": { + this.applyLimit(event); + return; + } + default: + } + } + + private applyLimit(event: BusEvent): void { + if (!this.limits || typeof event.window !== "string") { + this.refreshLimits().catch(() => undefined); + return; + } + const current = this.limits.windows.find((w) => w.window === event.window); + const patch = { + overage_status: event.overage_status ?? null, + resets_at: event.resets_at, + status: event.status, + ts: event.ts, + utilization: event.utilization, + } as Partial; + if (current) { + Object.assign(current, patch); + } else { + this.refreshLimits().catch(() => undefined); + } + } + + start(): void { + this.live.start(); + } + + stop(): void { + this.live.stop(); + } +} + +export const gateway = new Gateway(); diff --git a/ui/src/lib/limits.ts b/ui/src/lib/limits.ts new file mode 100644 index 0000000..f640ad4 --- /dev/null +++ b/ui/src/lib/limits.ts @@ -0,0 +1,11 @@ +export const LIMIT_LABELS: Record = { + five_hour: "5-hour", + overage: "overage", + seven_day: "7-day", + seven_day_opus: "7-day · Opus", + seven_day_sonnet: "7-day · Sonnet", +}; + +export function limitLabel(window: string): string { + return LIMIT_LABELS[window] ?? window; +} diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts new file mode 100644 index 0000000..7299407 --- /dev/null +++ b/ui/src/lib/nav.ts @@ -0,0 +1,38 @@ +import ActivityIcon from "@lucide/svelte/icons/activity"; +import BrainIcon from "@lucide/svelte/icons/brain"; +import ClockIcon from "@lucide/svelte/icons/clock"; +import GaugeIcon from "@lucide/svelte/icons/gauge"; +import KeyRoundIcon from "@lucide/svelte/icons/key-round"; +import MessagesSquareIcon from "@lucide/svelte/icons/messages-square"; +import ScrollTextIcon from "@lucide/svelte/icons/scroll-text"; +import type { Component } from "svelte"; + +export interface NavItem { + href: string; + icon: Component<{ class?: string }>; + label: string; + mobile: boolean; +} + +export const NAV: NavItem[] = [ + { href: "/", icon: ActivityIcon, label: "Now", mobile: true }, + { + href: "/conversations", + icon: MessagesSquareIcon, + label: "Conversations", + mobile: true, + }, + { href: "/usage", icon: GaugeIcon, label: "Usage", mobile: true }, + { href: "/memory", icon: BrainIcon, label: "Memory", mobile: true }, + { href: "/tokens", icon: KeyRoundIcon, label: "Tokens", mobile: false }, + { href: "/jobs", icon: ClockIcon, label: "Jobs", mobile: false }, + { href: "/audit", icon: ScrollTextIcon, label: "Audit", mobile: false }, +]; + +export function isActive(pathname: string, base: string, href: string) { + const path = pathname.startsWith(base) + ? pathname.slice(base.length) + : pathname; + const current = path || "/"; + return href === "/" ? current === "/" : current.startsWith(href); +} diff --git a/ui/src/lib/panel/activity-feed.svelte b/ui/src/lib/panel/activity-feed.svelte new file mode 100644 index 0000000..e13e9e7 --- /dev/null +++ b/ui/src/lib/panel/activity-feed.svelte @@ -0,0 +1,52 @@ + + +
+ {#if model.question} + + {/if} + {#if model.turns.length === 0} + + {:else} +
+ {#each model.turns as turn (turn.id)} + + {/each} +
+ {/if} +
diff --git a/ui/src/lib/panel/activity.svelte.ts b/ui/src/lib/panel/activity.svelte.ts new file mode 100644 index 0000000..20b4fb7 --- /dev/null +++ b/ui/src/lib/panel/activity.svelte.ts @@ -0,0 +1,396 @@ +import type { + BusEvent, + ConversationInfo, + PendingQuestion, + ToolSnapshot, + TurnUsage, +} from "$lib/api/types"; + +export type ToolStatus = "running" | "done" | "error" | "aborted"; +export type TurnStatus = "running" | "done" | "interrupted" | "error"; + +export interface ToolNode { + children: string[]; + endedAt: string | null; + id: string; + input: unknown; + name: string; + parent: string | null; + result: string; + startedAt: string; + status: ToolStatus; +} + +export interface Turn { + endedAt: string | null; + id: string; + itemOrigin: string | null; + nodes: Record; + origin: string; + resultSubtype: string | null; + roots: string[]; + says: string[]; + startedAt: string; + status: TurnStatus; + text: string; + thinking: number; + usage: TurnUsage | null; + userText: string | null; +} + +interface Cursor { + event: BusEvent; + parent: string | null; + ts: string; + turnId: string | null; +} + +const MAX_TURNS = 30; + +function str(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function stopStatus(turn: Turn, stop: string | null): TurnStatus { + if (stop === "end_turn") { + return turn.status === "error" ? "error" : "done"; + } + return stop === "interrupted" ? "interrupted" : "error"; +} + +// Bus events in, turns → tool calls → subagent tool calls out. Reactive +// through $state so the tree re-renders per node, not per event. +export class ActivityModel { + conversation = $state(null); + turns = $state([]); + question = $state(null); + + private readonly handlers: Record void> = { + "conversation.created": (c) => this.onConversation(c), + "conversation.updated": (c) => this.onConversation(c), + question: (c) => this.onQuestion(c), + "question.answered": () => { + this.question = null; + }, + "question.timeout": () => { + this.question = null; + }, + reply: (c) => this.onReply(c), + result: (c) => this.onResult(c), + say: (c) => this.onSay(c), + stream: (c) => this.onStream(c), + tool: (c) => this.onTool(c), + "tool.result": (c) => this.onToolResult(c), + "turn.end": (c) => this.onTurnEnd(c), + "turn.start": (c) => this.onTurnStart(c), + }; + + get running(): Turn | null { + return this.turns.find((turn) => turn.status === "running") ?? null; + } + + setConversation(info: ConversationInfo): void { + this.conversation = info; + this.question = info.question; + const now = new Date().toISOString(); + for (const turn of this.turns) { + if (turn.status === "running" && turn.id !== info.running_turn) { + this.closeTurn(turn, "interrupted", now); + } + } + if (info.turn) { + const turn = this.ensureTurn( + info.turn.id, + info.turn.started_at ?? now, + info.turn.origin + ); + turn.userText = info.turn.text ?? turn.userText; + for (const tool of info.turn.tools) { + this.applySnapshot(turn, tool); + } + } else if (info.running_turn) { + this.ensureTurn(info.running_turn, now, null); + } + } + + apply(event: BusEvent): void { + const handler = this.handlers[event.type]; + if (!handler) { + return; + } + handler({ + event, + parent: str(event.parent_tool_use_id), + ts: event.ts ?? new Date().toISOString(), + turnId: str(event.turn_id), + }); + } + + private applySnapshot(turn: Turn, tool: ToolSnapshot): void { + const node = this.ensureNode( + turn, + tool.tool_use_id, + tool.name, + tool.parent_tool_use_id, + tool.started_at + ); + node.input = tool.input; + if (tool.ended_at) { + node.status = tool.is_error ? "error" : "done"; + node.endedAt = tool.ended_at; + node.result = tool.content ?? ""; + } + } + + private onTurnStart({ event, ts, turnId }: Cursor): void { + if (!turnId) { + return; + } + const turn = this.ensureTurn(turnId, ts, str(event.origin)); + turn.status = "running"; + turn.startedAt = ts; + turn.itemOrigin = str(event.item_origin); + turn.userText = str(event.text) ?? turn.userText; + } + + private onStream({ event, ts, turnId, parent }: Cursor): void { + const raw = event.event; + if (!(turnId && raw && typeof raw === "object")) { + return; + } + const turn = this.ensureTurn(turnId, ts, null); + const sdk = raw as Record; + if (sdk.type === "content_block_start") { + const block = sdk.content_block as Record | undefined; + if (block?.type === "tool_use" && typeof block.id === "string") { + this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts); + } else if (block?.type === "thinking" && parent === null) { + turn.thinking += 1; + } + return; + } + if (sdk.type === "content_block_delta" && parent === null) { + const delta = sdk.delta as Record | undefined; + if (delta?.type === "text_delta" && typeof delta.text === "string") { + turn.text += delta.text; + } + } + } + + private onTool({ event, ts, turnId, parent }: Cursor): void { + if (!(turnId && typeof event.tool_use_id === "string")) { + return; + } + const turn = this.ensureTurn(turnId, ts, null); + const node = this.ensureNode( + turn, + event.tool_use_id, + str(event.name) ?? "?", + parent, + ts + ); + node.input = event.input ?? null; + } + + private onToolResult({ event, ts, turnId, parent }: Cursor): void { + if (!(turnId && typeof event.tool_use_id === "string")) { + return; + } + const turn = this.ensureTurn(turnId, ts, null); + const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts); + node.status = event.is_error ? "error" : "done"; + node.endedAt = ts; + node.result = str(event.content) ?? ""; + } + + private onResult({ event, ts, turnId }: Cursor): void { + if (!turnId) { + return; + } + const turn = this.ensureTurn(turnId, ts, null); + turn.resultSubtype = str(event.subtype); + if (event.is_error) { + turn.status = "error"; + } + } + + private onTurnEnd({ event, ts, turnId }: Cursor): void { + if (!turnId) { + return; + } + const turn = this.ensureTurn(turnId, ts, str(event.origin)); + this.closeTurn(turn, stopStatus(turn, str(event.stop)), ts); + turn.usage = + event.usage && typeof event.usage === "object" + ? (event.usage as TurnUsage) + : null; + } + + private onReply({ event, ts, turnId }: Cursor): void { + if (!turnId) { + return; + } + const turn = this.ensureTurn(turnId, ts, null); + turn.userText = str(event.user_text); + const text = str(event.text); + if (text) { + turn.text = text; + } + } + + private onSay({ event, ts, turnId }: Cursor): void { + const text = str(event.text); + if (!text) { + return; + } + const turn = turnId ? this.ensureTurn(turnId, ts, null) : this.turns[0]; + turn?.says.push(text); + } + + private onQuestion({ event }: Cursor): void { + if (typeof event.question_id === "string") { + this.question = { + id: event.question_id, + questions: Array.isArray(event.questions) ? event.questions : [], + }; + } + } + + private onConversation({ event }: Cursor): void { + if (!this.conversation || event.id !== this.conversation.id) { + return; + } + this.conversation = { + ...this.conversation, + ...(event as unknown as Partial), + }; + } + + private closeTurn(turn: Turn, status: TurnStatus, ts: string): void { + turn.status = status; + turn.endedAt = ts; + for (const node of Object.values(turn.nodes)) { + if (node.status === "running") { + node.status = "aborted"; + node.endedAt = ts; + } + } + } + + private ensureTurn(id: string, ts: string, origin: string | null): Turn { + const existing = this.turns.find((t) => t.id === id); + if (existing) { + if (origin && existing.origin === "?") { + existing.origin = origin; + } + return existing; + } + const turn: Turn = { + endedAt: null, + id, + itemOrigin: null, + nodes: {}, + origin: origin ?? "?", + resultSubtype: null, + roots: [], + says: [], + startedAt: ts, + status: "running", + text: "", + thinking: 0, + usage: null, + userText: null, + }; + this.turns.unshift(turn); + if (this.turns.length > MAX_TURNS) { + this.turns.length = MAX_TURNS; + } + return this.turns[0]; + } + + private ensureNode( + turn: Turn, + id: string, + name: string, + parent: string | null, + ts: string + ): ToolNode { + const existing = turn.nodes[id]; + if (existing) { + if (existing.name === "?" && name !== "?") { + existing.name = name; + } + return existing; + } + turn.nodes[id] = { + children: [], + endedAt: null, + id, + input: null, + name, + parent, + result: "", + startedAt: ts, + status: "running", + }; + if (parent) { + this.ensureNode(turn, parent, "?", null, ts).children.push(id); + } else { + turn.roots.push(id); + } + return turn.nodes[id]; + } +} + +function pick(input: unknown, ...keys: string[]): string | null { + if (!input || typeof input !== "object") { + return null; + } + const record = input as Record; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + return null; +} + +const WHITESPACE = /\s+/g; +const KEYS_BY_TOOL: Record = { + Agent: ["description", "prompt"], + Bash: ["command", "description"], + Edit: ["file_path"], + Glob: ["pattern"], + Grep: ["pattern"], + MultiEdit: ["file_path"], + NotebookEdit: ["notebook_path"], + Read: ["file_path", "notebook_path"], + Skill: ["skill"], + Task: ["description", "prompt"], + WebFetch: ["url"], + WebSearch: ["query"], + Write: ["file_path"], +}; +export const SUBAGENT_TOOLS = new Set(["Task", "Agent"]); + +export function summarizeInput(name: string, input: unknown): string { + if (input === null || input === undefined) { + return ""; + } + const keys = + KEYS_BY_TOOL[name] ?? + (name.startsWith("mcp__gateway__") ? ["text", "title", "id"] : []); + let summary = keys.length > 0 ? pick(input, ...keys) : null; + if (summary === null && typeof input === "object") { + const first = Object.values(input as Record).find( + (value) => typeof value === "string" && value.trim() + ); + summary = typeof first === "string" ? first : JSON.stringify(input); + } + return (summary ?? String(input)).replace(WHITESPACE, " ").trim(); +} + +export function toolLabel(name: string): string { + return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name; +} diff --git a/ui/src/lib/panel/bindings-list.svelte b/ui/src/lib/panel/bindings-list.svelte new file mode 100644 index 0000000..2242a71 --- /dev/null +++ b/ui/src/lib/panel/bindings-list.svelte @@ -0,0 +1,60 @@ + + +{#if bindings.length === 0} +

+ Not shown in any window. Bind it from a frontend, or it stays admin-only. +

+{:else} +
    + {#each bindings as binding (`${binding.frontend}:${binding.external_id}`)} +
  • + {binding.frontend} + + {binding.external_id} + + toggle(binding, value)} + /> +
  • + {/each} +
+{/if} diff --git a/ui/src/lib/panel/composer.svelte b/ui/src/lib/panel/composer.svelte new file mode 100644 index 0000000..55bd49b --- /dev/null +++ b/ui/src/lib/panel/composer.svelte @@ -0,0 +1,128 @@ + + +
{ + event.preventDefault(); + send(); + }} +> + +
+ { + mode = value as Mode; + }} + type="single" + value={mode} + > + {current.label} + + {#each MODES as option (option.value)} + + + {option.label} + {option.hint} + + + {/each} + + + + +
+
diff --git a/ui/src/lib/panel/conversation-header.svelte b/ui/src/lib/panel/conversation-header.svelte new file mode 100644 index 0000000..adfc6e4 --- /dev/null +++ b/ui/src/lib/panel/conversation-header.svelte @@ -0,0 +1,292 @@ + + +
+
+ +

+ {info.title || `${info.kind} ${shortId(info.id)}`} +

+ + {#if info.pending_question} + question pending + {/if} +
+ + + + + {#snippet child({ props })} + + {/snippet} + + + + + Copy id + + { + newTitle = info.title ?? ""; + renameOpen = true; + }} + > + Rename + + + {#if info.parent && info.status === "open"} + + run(() => client.merge(info.id), "Merged into the parent")} + > + Merge into parent + + {/if} + {#if info.status === "open"} + + run(() => client.update(info.id, { status: "closed" }), "Closed")} + > + Close + + + run( + () => client.update(info.id, { status: "archived" }), + "Archived" + )} + > + Archive + + {:else} + + run(() => client.update(info.id, { status: "open" }), "Reopened")} + > + Reopen + + {/if} + + +
+
+
+ {info.agent} + via {info.origin} + {#if info.parent} + + parent {shortId(info.parent)} + + {/if} + {shortId(info.id)} + + {info.live ? "session live" : "no live session"} + {info.busy ? " · busy" : ""} + + + last activity {fmtRelative(info.last_activity_at)} + +
+
+ + + + + Branch off this conversation + + A branch gets its own window and session and ends with a merge back into + this thread. + + +
+
+ + { + seed = value; + }} + type="single" + value={seed} + > + + {SEEDS.find((s) => s.value === seed)?.label} + + + {#each SEEDS as option (option.value)} + + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + + + + Rename + + + + + + + + diff --git a/ui/src/lib/panel/conversation-view.svelte b/ui/src/lib/panel/conversation-view.svelte new file mode 100644 index 0000000..b1cb6aa --- /dev/null +++ b/ui/src/lib/panel/conversation-view.svelte @@ -0,0 +1,194 @@ + + +
+ {#if feed.error && !info} +
+ {:else if !info} +
+ + +
+ {:else} + +
+
+ + + Activity + History + Raw + Meta + +
+ + + + + + + + + + {@render rail()} +
+
+ +
+ {#if !compact} + + {/if} +
+ {/if} +
+ +{#snippet rail()} + {#if info} +
+
+

+ Queue +

+ +
+
+

+ Windows +

+ +
+
+

+ Flags +

+ {#if Object.keys(info.flags).length === 0} +

No flags set.

+ {:else} +
+ {#each Object.entries(info.flags) as [key, value] (key)} +
{key}
+
{JSON.stringify(value)}
+ {/each} +
+ {/if} +
+
+

+ Session +

+

+ {info.session_id ?? "no session yet"} +

+
+
+ {/if} +{/snippet} diff --git a/ui/src/lib/panel/conversation.svelte.ts b/ui/src/lib/panel/conversation.svelte.ts new file mode 100644 index 0000000..3f735fb --- /dev/null +++ b/ui/src/lib/panel/conversation.svelte.ts @@ -0,0 +1,48 @@ +import type { ApiClient } from "$lib/api/client"; +import { LiveStream } from "$lib/api/live.svelte"; +import { ActivityModel } from "./activity.svelte"; + +// One conversation, live: the snapshot from ``GET /api/conversations/{id}`` +// (bindings, queue, the turn in flight) plus its SSE stream folded into an +// ``ActivityModel``. Shared by the admin thread page and the Obsidian panel. +export class ConversationFeed { + model = new ActivityModel(); + error = $state(null); + loaded = $state(false); + readonly id: string; + readonly live: LiveStream; + private readonly client: () => ApiClient | null; + + constructor(client: () => ApiClient | null, id: string) { + this.client = client; + this.id = id; + this.live = new LiveStream(client, `/api/conversations/${id}/events`, { + onEvent: (event) => this.model.apply(event), + prepare: () => this.refresh(), + }); + } + + async refresh(): Promise { + const client = this.client(); + if (!client) { + return; + } + try { + this.model.setConversation(await client.conversation(this.id)); + this.error = null; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + throw error; + } finally { + this.loaded = true; + } + } + + start(): void { + this.live.start(); + } + + stop(): void { + this.live.stop(); + } +} diff --git a/ui/src/lib/panel/history-view.svelte b/ui/src/lib/panel/history-view.svelte new file mode 100644 index 0000000..bface7f --- /dev/null +++ b/ui/src/lib/panel/history-view.svelte @@ -0,0 +1,130 @@ + + +
+ + + show tool results + + {#if failure} + + {:else if messages === null} +
+ + + +
+ {:else if messages.length === 0} + + {:else} + {#each messages as message, index (index)} + {@const parts = blocks(message)} + {@const isResultOnly = parts.every((b) => b.type === "tool_result")} + {#if !(isResultOnly && !showResults)} +
+ {#each parts as block, blockIndex (blockIndex)} + {#if block.type === "text" && block.text} +

+ {block.text} +

+ {:else if block.type === "tool_use"} +

+ {toolLabel(block.name ?? "?")} + + {clip(summarizeInput(block.name ?? "", block.input), 160)} + +

+ {:else if block.type === "tool_result" && showResults} +
{clip(resultText(block), RESULT_CLIP)}
+ {:else if block.type === "thinking"} +

thinking

+ {/if} + {/each} +
+ {/if} + {/each} + {/if} +
diff --git a/ui/src/lib/panel/question-card.svelte b/ui/src/lib/panel/question-card.svelte new file mode 100644 index 0000000..77e69c5 --- /dev/null +++ b/ui/src/lib/panel/question-card.svelte @@ -0,0 +1,75 @@ + + +
+ + {#each question.questions as q, index (index)} +
+

{q.question}

+ {#if q.options?.length} +
+ {#each q.options as option (option.label)} + + {/each} +
+ {/if} +
+ {/each} +
{ + event.preventDefault(); + answer(free); + }} + > + + +
+
diff --git a/ui/src/lib/panel/queue-list.svelte b/ui/src/lib/panel/queue-list.svelte new file mode 100644 index 0000000..40c8e7b --- /dev/null +++ b/ui/src/lib/panel/queue-list.svelte @@ -0,0 +1,36 @@ + + +{#if items.length === 0} +

Queue is empty.

+{:else} +
    + {#each items as item (item.id)} +
  • +
    + + {item.priority} + + {item.origin} + +
    +

    {clip(item.text, 140)}

    + + {fmtRelative(item.created_at)} + +
  • + {/each} +
+{/if} diff --git a/ui/src/lib/panel/raw-entries.svelte b/ui/src/lib/panel/raw-entries.svelte new file mode 100644 index 0000000..be986a1 --- /dev/null +++ b/ui/src/lib/panel/raw-entries.svelte @@ -0,0 +1,182 @@ + + +
+
+ {#if page && page.subpaths.length > 0} + { + subpath = value === "main" ? "" : value; + offset = null; + }} + type="single" + value={subpath || "main"} + > + + {subpath || "main transcript"} + + + + {#each page.subpaths as sub (sub)} + + {/each} + + + {/if} + {#if page} + + {page.total === 0 ? "0" : `${page.offset + 1}–${page.offset + page.entries.length}`} + of {page.total} + +
+ + +
+ {/if} +
+ {#if failure} + load(subpath, offset)} /> + {:else if page === null} + + {:else if page.entries.length === 0} + + {:else} +
+ {#each page.entries as entry, index (index)} + {@const seq = page.offset + index} + {@const isOpen = open.has(seq)} +
+ + {#if isOpen} +
{JSON.stringify(
+                entry,
+                null,
+                2
+              )}
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/ui/src/lib/panel/tool-node.svelte b/ui/src/lib/panel/tool-node.svelte new file mode 100644 index 0000000..610ab58 --- /dev/null +++ b/ui/src/lib/panel/tool-node.svelte @@ -0,0 +1,100 @@ + + +
0 && "ml-3 border-l pl-2")}> + + {#if expanded} +
+ {#if node.input !== null && node.input !== undefined} +
{JSON.stringify(
+            node.input,
+            null,
+            2
+          )}
+ {/if} + {#if node.result} +
{node.result}
+ {/if} +
+ {/if} + {#if node.children.length > 0} +
+ {#each node.children as childId (childId)} + {#if nodes[childId]} + + {/if} + {/each} +
+ {/if} +
diff --git a/ui/src/lib/panel/turn-card.svelte b/ui/src/lib/panel/turn-card.svelte new file mode 100644 index 0000000..2552b0a --- /dev/null +++ b/ui/src/lib/panel/turn-card.svelte @@ -0,0 +1,96 @@ + + +
+
+ + + {originLabel} + + {fmtTime(turn.startedAt)} + {fmtDuration(duration)} + {#if toolCount > 0} + + {toolCount} + tool {toolCount === 1 ? "call" : "calls"} + + {/if} + {#if turn.thinking > 0 && turn.status === "running"} + thinking… + {/if} + {#if turn.usage} + + in {fmtTokens(turn.usage.input)} · out {fmtTokens(turn.usage.output)} · + cache {fmtTokens(turn.usage.cache_read)}/{fmtTokens( + turn.usage.cache_creation + )} + {#if typeof turn.usage.cost_usd === "number"} + · {fmtMoney(turn.usage.cost_usd)} + {/if} + + {/if} +
+ {#if turn.userText} +

+ {turn.userText} +

+ {/if} + {#if turn.roots.length > 0} +
+ {#each turn.roots as rootId (rootId)} + {#if turn.nodes[rootId]} + + {/if} + {/each} +
+ {/if} + {#each turn.says as text, index (index)} +

+ + {text} +

+ {/each} + {#if turn.text} +

{turn.text}

+ {:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0} +

+ Waiting for the model… +

+ {/if} +
diff --git a/ui/src/lib/session.svelte.ts b/ui/src/lib/session.svelte.ts new file mode 100644 index 0000000..d02b171 --- /dev/null +++ b/ui/src/lib/session.svelte.ts @@ -0,0 +1,92 @@ +import { base } from "$app/paths"; +import { ApiClient } from "./api/client"; + +interface SessionPayload { + api_base: string | null; + token: string; + user: string; +} + +// Admin sign-in state. The cookie lives on the admin origin; the bearer +// the SPA uses against ``/api`` arrives with ``/auth/session`` and is +// refetched when the gateway restarts and rotates it. +class Session { + user = $state(null); + ready = $state(false); + error = $state(null); + client = $state(null); + apiBase = $state(""); + + private async fetchSession(): Promise { + const response = await fetch(`${base}/auth/session`, { + credentials: "same-origin", + }); + if (response.status === 401) { + return null; + } + if (!response.ok) { + throw new Error(`session check failed: HTTP ${response.status}`); + } + return (await response.json()) as SessionPayload; + } + + private apply(payload: SessionPayload | null): void { + if (!payload) { + this.user = null; + this.client = null; + return; + } + this.user = payload.user; + this.apiBase = payload.api_base ?? window.location.origin; + this.client = new ApiClient(this.apiBase, payload.token, { + onUnauthorized: () => this.refreshToken(), + }); + } + + async check(): Promise { + try { + this.apply(await this.fetchSession()); + this.error = null; + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.ready = true; + } + } + + private async refreshToken(): Promise { + const payload = await this.fetchSession(); + if (!payload) { + this.user = null; + this.client = null; + return null; + } + return payload.token; + } + + async login(username: string, password: string): Promise { + const response = await fetch(`${base}/auth/login`, { + body: JSON.stringify({ password, username }), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(body.error ?? `HTTP ${response.status}`); + } + this.apply(await this.fetchSession()); + } + + async logout(): Promise { + await fetch(`${base}/auth/logout`, { + credentials: "same-origin", + method: "POST", + }); + this.apply(null); + } +} + +export const session = new Session(); diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index ab928bf..ada71d8 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -1,9 +1,64 @@ -{@render children()} + + + + + + {#if isLogin} + {@render children()} + {:else if session.ready && session.user} +
+ +
+ {@render children()} +
+ +
+ {:else if session.ready && session.error} +
+

{session.error}

+
+ {/if} +
diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 3d70440..e7c7e28 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -1,5 +1,428 @@ -

Welcome to SvelteKit

-

- Visit svelte.dev/docs/kit to read - the documentation -

+ + +Now · Beaver + + + +
+
+ {#if failure} + + {/if} + +
+

+ Running +

+ {#if !gateway.loaded} + + {:else if running.length === 0} + + {:else} + + {/if} +
+ +
+
+

+ Subscription quota +

+ {#if windows.length === 0} + + {:else} +
+ {#each windows as w (w.window)} + + {/each} +
+ {/if} +
+
+

+ Spend via gateway +

+ {#if usage5h && usageWeek} + + + + + + + + + + + + + {#each [["last 5 h", usage5h.total], ["last 7 d", usageWeek.total]] as [label, total] (label)} + {@const t = total as UsageResponse["total"]} + {@const share = cacheShare(t.input, t.cache_read)} + + + + + + + + + {/each} + +
rangecostturnsin / outcache sharewritten
{label} + {fmtMoney(t.cost_usd)} + {t.turns} + {fmtTokens(t.input)} + / {fmtTokens(t.output)} + 0 && "text-warn")} + > + {fmtPct(share)} + + {fmtTokens(t.cache_creation)} +
+ + Full breakdown → + + {:else} + + {/if} +
+
+ +
+
+

+ Live sessions +

+ {#if sessions} + + {sessions.sessions.length} + processes · {fmtBytes(sessions.rss)} + {#if sessions.rss_limit} + of {fmtBytes(sessions.rss_limit)} + {/if} + + {/if} +
+ {#if !sessions} + + {:else if sessions.sessions.length === 0} + + {:else} +
+ + + + + + + + + + + + + + + {#each sessions.sessions as s (s.key)} + + + + + + + + + + + {/each} + +
agentkindstaterssidleageturnsconversation
{s.agent} + + {s.busy ? "busy" : "idle"} + + {#if s.pinned} + pinned + {/if} + {#if s.dirty} + dirty + {/if} + {#if s.pending_question} + question + {/if} + + {fmtBytes(s.rss)} + + {fmtSeconds(s.idle_seconds)} + + {fmtSeconds(s.age_seconds)} + {s.turns} + + {shortId(s.key)} + +
+
+ {/if} +
+ +
+
+

+ Agents +

+ {#if agents} +
    + {#each agents.agents as a (a.name)} +
  • + + {a.name} + + {a.model}{a.effort ? ` · ${a.effort}` : ""} + + + + {#each a.kinds as k (k)} + + {/each} + +
  • + {/each} +
+ {:else} + + {/if} +
+
+

+ Frontends +

+ {#if agents} +
    + {#each agents.frontends as fe (fe.type + fe.name)} +
  • + + {fe.name} + + {frontendUrl(fe.port, fe.public_base_url) || fe.type} + {#if Object.keys(fe.default_agents).length > 0} + · + {Object.entries(fe.default_agents) + .map(([k, v]) => `${k}→${v}`) + .join(", ")} + {/if} + + + + {#each fe.kinds as k (k)} + + {/each} + +
  • + {/each} +
+ {:else} + + {/if} +
+
+ +
+

+ Event tape +

+ {#if tape.length === 0} +

+ Quiet. Bus events (turns, tools, injects, questions) scroll here as + they happen. +

+ {:else} +
    + {#each tape as item (item.seq)} +
  • + {fmtTime(item.ts)} + {item.type} + + {#if item.conversation_id} + + {shortId(item.conversation_id)} + + {/if} + {#if typeof item.name === "string"} + {item.name} + {/if} + {#if typeof item.text === "string"} + {item.text.slice(0, 80)} + {/if} + {#if typeof item.stop === "string"} + {item.stop} + {/if} + +
  • + {/each} +
+ {/if} +
+
+
diff --git a/ui/src/routes/audit/+page.svelte b/ui/src/routes/audit/+page.svelte new file mode 100644 index 0000000..4ceca8e --- /dev/null +++ b/ui/src/routes/audit/+page.svelte @@ -0,0 +1,105 @@ + + +Audit · Beaver + + + +
+
+ {#if failure} + load()} /> + {:else if records === null} + + {:else if records.length === 0} + + {:else} +
    + {#each records as record (record.id)} +
  • + {fmtDateTime(record.ts)} + {record.kind} + {record.actor} + + {#if record.agent} + {record.agent} + · + {/if} + {detail(record)} + +
  • + {/each} +
+ {#if nextBefore !== null} + + {/if} + {/if} +
+
diff --git a/ui/src/routes/conversations/+page.svelte b/ui/src/routes/conversations/+page.svelte new file mode 100644 index 0000000..c317a71 --- /dev/null +++ b/ui/src/routes/conversations/+page.svelte @@ -0,0 +1,287 @@ + + +Conversations · Beaver + + + { + kind = value; + }} + type="single" + value={kind} + > + + {kind === "all" ? "any kind" : kind} + + + {#each KINDS as option (option)} + + {/each} + + + { + statusFilter = value; + }} + type="single" + value={statusFilter} + > + + {statusFilter === "all" ? "any status" : statusFilter} + + + {#each STATUSES as option (option)} + + {/each} + + + + {#snippet actions()} + + {/snippet} + + +
+ {#if gateway.live.state === "failed"} +
+ gateway.start()} + /> +
+ {:else if !gateway.loaded} +
+ + + +
+ {:else if rows.length === 0} +
+ + + +
+ {:else} + + {/if} +
+ + + + + New conversation + + It opens in the home window of its kind (a vault file, a Telegram topic) + and stays silent until someone speaks. + + +
+
+ + { + form.kind = value; + form.agent = ""; + }} + type="single" + value={form.kind} + > + {form.kind} + + {#each ["deep", "branch", "master", "job"] as option (option)} + + {/each} + + +
+
+ + { + form.agent = value === "default" ? "" : value; + }} + type="single" + value={form.agent || "default"} + > + {form.agent || "frontend default"} + + + {#each agentsForKind as agent (agent.name)} + + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + +
+
diff --git a/ui/src/routes/conversations/[id]/+page.svelte b/ui/src/routes/conversations/[id]/+page.svelte new file mode 100644 index 0000000..6630ee2 --- /dev/null +++ b/ui/src/routes/conversations/[id]/+page.svelte @@ -0,0 +1,23 @@ + + +Conversation · Beaver + +{#if session.client && id} + {#key id} + goto(href(target))} + /> + {/key} +{/if} diff --git a/ui/src/routes/jobs/+page.svelte b/ui/src/routes/jobs/+page.svelte new file mode 100644 index 0000000..aeb7f8d --- /dev/null +++ b/ui/src/routes/jobs/+page.svelte @@ -0,0 +1,81 @@ + + +Jobs · Beaver + + + +
+
+ + {#if failure} + + {:else if schedules === null} + + {:else if schedules.length === 0} +

No deferred injects.

+ {:else} +
+

+ Deferred injects +

+
    + {#each [...pending, ...delivered] as s (s.id)} +
  • + + {s.delivered_at ? "delivered" : fmtRelative(s.execute_at)} + + {clip(s.text, 160)} + + {fmtDateTime(s.execute_at)} + +
  • + {/each} +
+
+ {/if} +
+
diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css index 658cb38..603cbdc 100644 --- a/ui/src/routes/layout.css +++ b/ui/src/routes/layout.css @@ -31,6 +31,8 @@ --icon: oklch(0.55 0.03 340); --signal: oklch(0.48 0.21 342); --note: oklch(0.52 0.13 78); + --warn: oklch(0.55 0.13 75); + --link: var(--primary); --status-new: oklch(0.55 0.22 342); --status-done: oklch(0.55 0.14 155); @@ -74,6 +76,8 @@ --icon: #877384; --signal: #ff82f3; --note: oklch(0.84 0.14 88); + --warn: oklch(0.8 0.13 75); + --link: #ff82f3; --status-new: #ff82f3; --status-done: oklch(0.74 0.14 155); @@ -200,3 +204,96 @@ display: none; } } + +@theme inline { + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --color-kind-master: var(--signal); + --color-kind-branch: var(--status-reply); + --color-kind-deep: var(--status-meeting); + --color-kind-job: var(--status-work); + --color-kind-fork: var(--status-skip); + --color-ok: var(--status-done); + --color-warn: var(--warn); + --color-link: var(--link); + --animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes pulse-dot { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); + } +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: none; + } +} + +@layer base { + * { + scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent) + transparent; + scrollbar-width: thin; + } + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + ::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--foreground) 22%, transparent); + border-radius: 999px; + } + :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } + input, + textarea { + caret-color: var(--primary); + } + a { + text-underline-offset: 3px; + } + code, + pre, + kbd { + font-family: var(--font-mono); + font-size: 0.8125rem; + } + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } + } +} + +@utility row-hover { + transition: background-color 150ms ease-out; + &:hover { + background: color-mix(in oklab, var(--foreground) 4%, transparent); + } +} + +@utility ledger-grid { + display: grid; + column-gap: 0.75rem; + align-items: center; +} + +@utility hairline { + border-color: color-mix(in oklab, var(--border) 100%, transparent); +} diff --git a/ui/src/routes/login/+page.svelte b/ui/src/routes/login/+page.svelte new file mode 100644 index 0000000..f12df37 --- /dev/null +++ b/ui/src/routes/login/+page.svelte @@ -0,0 +1,70 @@ + + +Sign in · Beaver + +
+
+
+ + Beaver +
+

+ Operator console for beaver-gateway. Sign in with the admin credentials + from the gateway environment. +

+
+ + +
+
+ + +
+ {#if failure} + + {/if} + +
+
diff --git a/ui/src/routes/memory/+page.svelte b/ui/src/routes/memory/+page.svelte new file mode 100644 index 0000000..03653da --- /dev/null +++ b/ui/src/routes/memory/+page.svelte @@ -0,0 +1,193 @@ + + +Memory · Beaver + + + +
+
+ {#if failure} + + {:else if notConfigured} + + {:else if !tree} +
+ + + +
+ {:else if tree.tree.length === 0} + + {:else} + {@render nodes(tree.tree, 0)} + {/if} +
+
+ {#if !selected} +
+ Pick a file to read it. This is the agent's own zone: state, handouts, + prompts, skills - everything it can write. +
+ {:else if fileError} +
+ open(selected ?? "")} /> +
+ {:else if !file} +
+ + +
+ {:else} +
+ {file.path} + {fmtBytes(file.size)} + + modified {fmtRelative(file.mtime)} + +
+
{file.content}
+ {/if} +
+
+ +{#snippet nodes(list: MemoryNode[], depth: number)} +
    + {#each list as node (node.path)} +
  • + {#if node.type === "dir"} + + {#if !collapsed.has(node.path) && node.children} + {@render nodes(node.children, depth + 1)} + {/if} + {:else} + + {/if} +
  • + {/each} +
+{/snippet} diff --git a/ui/src/routes/tokens/+page.svelte b/ui/src/routes/tokens/+page.svelte new file mode 100644 index 0000000..b66f050 --- /dev/null +++ b/ui/src/routes/tokens/+page.svelte @@ -0,0 +1,287 @@ + + +Tokens · Beaver + + + + + show revoked + + {#snippet actions()} + + {/snippet} + + +
+
+ {#if created} +
+

+ Token {created.name} created. This is + the only time the plaintext is shown - copy it into the client now. +

+
+ + {created.plaintext} + + + +
+
+ {/if} + {#if failure} + load()} /> + {:else if tokens === null} + + {:else if tokens.length === 0} + + + + {:else} + + + + + + + + + + + + {#each tokens as token (token.id)} + + + + + + + + {/each} + +
namescopecreatedlast used
+ {token.name} + {#if token.revoked_at} + revoked {fmtRelative(token.revoked_at)} + {/if} + + {token.scope} + + {fmtDateTime(token.created_at)} + + {token.last_used_at ? fmtRelative(token.last_used_at) : "never"} + + {#if !token.revoked_at} + + {/if} +
+ {/if} +
+
+ + + + + New bearer token + + The value is generated by the gateway and shown once. + + +
{ + event.preventDefault(); + create(); + }} + > +
+ + +
+
+ + { + scope = value; + }} + type="single" + value={scope} + > + {scope} + + {#each SCOPES as option (option.value)} + + + {option.value} + {option.hint} + + + {/each} + + +
+ + + + +
+
+
diff --git a/ui/src/routes/usage/+page.svelte b/ui/src/routes/usage/+page.svelte new file mode 100644 index 0000000..ebcce79 --- /dev/null +++ b/ui/src/routes/usage/+page.svelte @@ -0,0 +1,299 @@ + + +Usage · Beaver + + + { + range = value; + }} + value={range} + > + + {#each RANGES as r (r.value)} + {r.label} + {/each} + + + + +
+
+ {#if failure} + load(range, group)} /> + {/if} + +
+

+ Subscription quota +

+ {#if windows.length === 0} + + {:else} +
+ {#each windows as w (w.window)} + + {/each} +
+ {/if} +
+ +
+
+

+ Tokens and API-price equivalent +

+ { + group = value as UsageGroup; + }} + value={group} + > + + {#each GROUPS as g (g.value)} + {g.label} + {/each} + + +
+ {#if !(data && total)} + + {:else} +
+ + + + + 0 ? "warn" : "default"} + value={fmtPct(share)} + /> + +
+ {#if data.rows.length === 0} + + {:else} +
+ + + + + + + + + + + + + + + {#each data.rows as row (row.key)} + {@const rowShare = cacheShare(row.input, row.cache_read)} + + + + + + + + + + + {/each} + +
{group}costturnsinoutcache read + cache write + cache share
+ + {#if group === "conversation"} + {#if row.kind} + + {/if} + + {label(row)} + + {#if row.status && row.status !== "open"} + + {/if} + {:else} + {label(row)} + {#if group === "day"} + {row.agent} + {/if} + {/if} + + + + + + + {fmtMoney(row.cost_usd)} + + {row.turns} + {fmtTokens(row.input)} + + {fmtTokens(row.output)} + + {fmtTokens(row.cache_read)} + + {fmtTokens(row.cache_creation)} + + {fmtPct(rowShare)} +
+
+ {/if} + {/if} +
+ + {#if history.length > 0} +
+

+ Rate-limit history +

+
    + {#each history as h (h.id)} +
  • + {fmtDateTime(h.ts)} + {limitLabel(h.window)} + {fmtPct(h.utilization)} + +
  • + {/each} +
+
+ {/if} +
+
diff --git a/ui/vite.config.ts b/ui/vite.config.ts index bd694f9..7e22963 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,6 +3,9 @@ import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; +const adminOrigin = process.env.ADMIN_ORIGIN ?? "http://127.0.0.1:62992"; +const apiOrigin = process.env.API_ORIGIN ?? "http://127.0.0.1:62994"; + export default defineConfig({ plugins: [ tailwindcss(), @@ -14,6 +17,13 @@ export default defineConfig({ // biome-ignore lint/performance/useTopLevelRegex: svelte default filename.split(/[/\\]/).includes("node_modules") ? undefined : true, }, + paths: { base: "/admin" }, }), ], + server: { + proxy: { + "/admin/auth": adminOrigin, + "/api": apiOrigin, + }, + }, }); diff --git a/uv.lock b/uv.lock index e60d895..8414421 100644 --- a/uv.lock +++ b/uv.lock @@ -299,7 +299,6 @@ dependencies = [ { name = "fastmcp" }, { name = "greenlet" }, { name = "itsdangerous" }, - { name = "jinja2" }, { name = "psutil" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, @@ -341,7 +340,6 @@ requires-dist = [ { name = "fastmcp", specifier = ">=3.3.1" }, { name = "greenlet", specifier = ">=3.5.0" }, { name = "itsdangerous", specifier = ">=2.2.0" }, - { name = "jinja2", specifier = ">=3.1.6" }, { name = "psutil", specifier = ">=7.2.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "pydantic", specifier = ">=2.13.4" }, @@ -945,18 +943,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - [[package]] name = "jiter" version = "0.14.0" @@ -1111,58 +1097,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - [[package]] name = "mcp" version = "1.27.1"