feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot

This commit is contained in:
hh
2026-08-28 22:14:15 +02:00
parent 61562e947d
commit 98796a82c6
158 changed files with 8459 additions and 2531 deletions
+3
View File
@@ -19,3 +19,6 @@ t/
# Local env
.env
db.db
# Impeccable review screenshots
.impeccable/review/
-1
View File
@@ -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",
@@ -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),
)
+1
View File
@@ -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:
+5
View File
@@ -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
+121 -12
View File
@@ -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:
+2
View File
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -1,206 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}beaver-gateway · admin{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<style>
:root {
--bg: #fbfbfd;
--fg: #1d1d1f;
--muted: #6e6e73;
--line: #e5e5ea;
--accent: #0071e3;
--danger: #d70015;
--surface: #ffffff;
--code-bg: #f5f5f7;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body {
font-family: -apple-system, "SF Pro Display", "SF Pro Text",
BlinkMacSystemFont, system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
letter-spacing: -0.005em;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, kbd, samp {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.92em;
}
pre {
background: var(--code-bg);
padding: 1rem 1.25rem;
border-radius: 12px;
overflow-x: auto;
}
header.top {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
header.top .inner {
max-width: 1080px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1.25rem;
}
header.top h1 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
nav.tabs { display: flex; gap: 1.1rem; flex: 1; }
nav.tabs a {
color: var(--fg);
padding: 0.25rem 0.1rem;
border-bottom: 2px solid transparent;
}
nav.tabs a.active {
border-bottom-color: var(--fg);
}
header.top .actor {
color: var(--muted);
font-size: 0.9em;
}
main {
max-width: 1080px;
margin: 0 auto;
padding: 2.25rem 1.5rem 4rem;
}
h2 {
font-weight: 600;
letter-spacing: -0.015em;
margin: 2rem 0 0.75rem;
}
h2:first-of-type { margin-top: 0; }
.muted { color: var(--muted); }
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-weight: 500;
color: var(--muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
tr:last-child td { border-bottom: none; }
.revoked td { color: var(--muted); }
button, .btn {
font-family: inherit;
font-size: 0.92em;
padding: 0.45rem 0.95rem;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--fg);
cursor: pointer;
transition: background 120ms ease;
}
button:hover, .btn:hover { background: var(--code-bg); }
button.primary, .btn.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.primary:hover, .btn.primary:hover {
background: #005bb5;
}
button.danger { color: var(--danger); border-color: #f0c5c9; }
button.danger:hover { background: #fdeff1; }
form.inline { display: inline; margin: 0; }
.form-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) 160px auto;
gap: 0.75rem;
align-items: end;
}
.form-grid label {
display: block;
font-size: 0.8em;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
input[type="text"], input[type="password"], select {
width: 100%;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: 8px;
font-family: inherit;
font-size: 0.95em;
background: var(--surface);
color: var(--fg);
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.banner {
padding: 1rem 1.25rem;
border-radius: 12px;
background: #f0f9ff;
border: 1px solid #cfe8ff;
margin: 0 0 1.25rem;
}
.banner.error {
background: #fff0f0;
border-color: #f5c2c5;
color: var(--danger);
}
.banner.warn {
background: #fff7e6;
border-color: #fadf9c;
}
.pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
background: var(--code-bg);
font-size: 0.78em;
color: var(--muted);
}
.pill.scope-wild { background: #ecfdf3; color: #027a48; }
.pill.scope-admin { background: #fff4e6; color: #b54708; }
</style>
</head>
<body>
{% block header %}
<header class="top">
<div class="inner">
<h1>beaver-gateway</h1>
<nav class="tabs">
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
<a href="{{ p }}/pty" class="{% if active == 'pty' %}active{% endif %}">PTY</a>
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
</nav>
<span class="actor">Signed in as <strong>{{ user }}</strong></span>
<form class="inline" method="post" action="{{ p }}/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button type="submit">Log out</button>
</form>
</div>
</header>
{% endblock %}
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -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. #}
<div class="banner warn">
<strong>Token created: {{ token.name }}</strong>
<p class="muted" style="margin:0.25rem 0 0.5rem;">Copy it now — this is the only time it will be shown.</p>
<pre style="margin:0;">{{ plaintext }}</pre>
</div>
<tbody id="tokens-rows" hx-swap-oob="afterbegin">
{% include "_token_row.html" %}
</tbody>
<tr id="tokens-empty" hx-swap-oob="delete"></tr>
@@ -1 +0,0 @@
<div class="banner error">{{ message }}</div>
@@ -1,25 +0,0 @@
<tr id="token-row-{{ token.id }}" {% if token.revoked_at %}class="revoked"{% endif %}>
<td><strong>{{ token.name }}</strong></td>
<td>
<span class="pill {% if token.scope == '*' %}scope-wild{% elif token.scope == 'admin' %}scope-admin{% endif %}">{{ token.scope }}</span>
</td>
<td><code>{{ token.created_at | fmt_dt }}</code></td>
<td><code>{{ token.last_used_at | fmt_dt }}</code></td>
<td><code>{{ token.revoked_at | fmt_dt }}</code></td>
<td style="text-align:right;">
{% if not token.revoked_at %}
<form
class="inline"
hx-post="{{ p }}/tokens/{{ token.id }}/revoke"
hx-target="#token-row-{{ token.id }}"
hx-swap="outerHTML"
hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="danger" type="submit">Revoke</button>
</form>
{% else %}
<span class="muted">revoked</span>
{% endif %}
</td>
</tr>
@@ -1,37 +0,0 @@
{% extends "_layout.html" %}
{% set active = "audit" %}
{% block title %}beaver-gateway · audit{% endblock %}
{% block content %}
<h2>Audit log</h2>
<div class="card">
{% if audit %}
<table>
<thead>
<tr>
<th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th>
</tr>
</thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:1rem;">
{% if next_before %}
<a href="{{ p }}/audit?before={{ next_before }}">Older entries →</a>
{% else %}
End of log.
{% endif %}
</p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
{% endblock %}
@@ -1,355 +0,0 @@
{% extends "_layout.html" %}
{% set active = "chat" %}
{% block title %}beaver-gateway · chat{% endblock %}
{% block content %}
<div class="chat-wrap">
<div class="chat-toolbar">
<label>
<span>Agent</span>
<select id="agent-select">
{% for a in agents %}
<option value="{{ a.name }}">{{ a.name }} · {{ a.model }}</option>
{% else %}
<option disabled>no agents with a backend</option>
{% endfor %}
</select>
</label>
<div class="spacer"></div>
<button id="new-chat-btn" type="button">New chat</button>
</div>
<div id="messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-input">
<textarea id="chat-text" rows="3" placeholder="Message… (⌘/Ctrl+Enter to send)" required></textarea>
<button type="submit" class="primary" id="send-btn">Send</button>
</form>
</div>
<style>
.chat-wrap {
display: flex; flex-direction: column;
height: calc(100vh - 200px); min-height: 480px;
}
.chat-toolbar {
display: flex; gap: 0.85rem; align-items: end;
margin-bottom: 1rem;
}
.chat-toolbar .spacer { flex: 1; }
.chat-toolbar label {
display: flex; flex-direction: column; gap: 0.3rem;
min-width: 260px;
}
.chat-toolbar label > span {
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
}
.chat-messages {
flex: 1; overflow-y: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 1.25rem;
display: flex; flex-direction: column; gap: 1rem;
}
.chat-empty { color: var(--muted); margin: auto; }
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble {
max-width: 78%; padding: 0.7rem 0.95rem; border-radius: 14px;
background: var(--accent); color: white;
white-space: pre-wrap; word-wrap: break-word;
}
.msg.assistant .blocks {
display: flex; flex-direction: column; gap: 0.5rem;
max-width: 88%;
}
.block-text {
background: var(--code-bg); padding: 0.7rem 0.95rem;
border-radius: 14px;
white-space: pre-wrap; word-wrap: break-word;
}
details.tool-call, details.thinking-block {
border: 1px solid var(--line); border-radius: 10px;
background: var(--surface); padding: 0 0.85rem;
font-size: 0.9em;
}
details.tool-call > summary,
details.thinking-block > summary {
cursor: pointer; padding: 0.55rem 0;
list-style: none;
display: flex; align-items: center; gap: 0.55rem;
}
details > summary::-webkit-details-marker { display: none; }
details.tool-call > summary::before,
details.thinking-block > summary::before {
content: "▸"; color: var(--muted); font-size: 0.78em;
}
details.tool-call[open] > summary::before,
details.thinking-block[open] > summary::before { content: "▾"; }
.tool-name {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-weight: 500;
}
.tool-id, .tool-label {
color: var(--muted); font-size: 0.78em;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
details.tool-call pre, details.thinking-block pre {
margin: 0 0 0.6rem; padding: 0.7rem 0.85rem;
background: var(--code-bg); border-radius: 8px;
font-size: 0.85em; max-height: 360px; overflow: auto;
}
.tool-label { display: block; margin: 0.15rem 0 0.25rem; }
.chat-input { display: flex; gap: 0.75rem; margin-top: 1rem; }
.chat-input textarea {
flex: 1; padding: 0.7rem 0.9rem;
border: 1px solid var(--line); border-radius: 12px;
font-family: inherit; font-size: 0.95em;
background: var(--surface); color: var(--fg); resize: vertical;
}
.chat-input textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-input button { align-self: stretch; padding-left: 1.4rem; padding-right: 1.4rem; }
.chat-error {
color: var(--danger); font-size: 0.85em;
padding: 0.55rem 0.75rem;
background: #fff0f0; border-radius: 10px;
border: 1px solid #f5c2c5;
}
.chat-input button:disabled { opacity: 0.55; cursor: progress; }
</style>
<script>
(() => {
const CSRF = {{ csrf | tojson }};
const URL_PREFIX = {{ p | tojson }};
const agentSelect = document.getElementById("agent-select");
const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form");
const textEl = document.getElementById("chat-text");
const sendBtn = document.getElementById("send-btn");
const newBtn = document.getElementById("new-chat-btn");
// Anthropic-style history sent to the backend. We keep assistant
// content text-only — tool_use blocks can't round-trip without
// matching tool_result, and backends (claude-code) run tools
// internally anyway.
let apiMessages = [];
function renderEmpty() {
messagesEl.innerHTML = '<div class="chat-empty">No messages yet — say something.</div>';
}
renderEmpty();
newBtn.addEventListener("click", () => {
apiMessages = [];
renderEmpty();
});
function clearEmpty() {
const e = messagesEl.querySelector(".chat-empty");
if (e) e.remove();
}
function scrollDown() { messagesEl.scrollTop = messagesEl.scrollHeight; }
function appendUser(text) {
clearEmpty();
const row = document.createElement("div");
row.className = "msg user";
const bub = document.createElement("div");
bub.className = "bubble";
bub.textContent = text;
row.appendChild(bub);
messagesEl.appendChild(row);
scrollDown();
}
function appendAssistant() {
clearEmpty();
const row = document.createElement("div");
row.className = "msg assistant";
const blocks = document.createElement("div");
blocks.className = "blocks";
row.appendChild(blocks);
messagesEl.appendChild(row);
scrollDown();
return blocks;
}
function appendError(text) {
clearEmpty();
const e = document.createElement("div");
e.className = "chat-error";
e.textContent = text;
messagesEl.appendChild(e);
scrollDown();
}
function ensureBlock(state, index, type, extra) {
if (state.blocks[index]) return state.blocks[index];
const block = { type };
if (type === "text") {
const el = document.createElement("div");
el.className = "block-text";
state.container.appendChild(el);
block.el = el;
} else if (type === "thinking") {
const det = document.createElement("details");
det.className = "thinking-block";
const sum = document.createElement("summary");
sum.textContent = "Thinking";
det.appendChild(sum);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
} else if (type === "tool_use") {
const det = document.createElement("details");
det.className = "tool-call";
const sum = document.createElement("summary");
const name = document.createElement("span");
name.className = "tool-name";
name.textContent = "🔧 " + (extra.name || "tool");
sum.appendChild(name);
if (extra.id) {
const idEl = document.createElement("span");
idEl.className = "tool-id";
idEl.textContent = extra.id;
sum.appendChild(idEl);
}
det.appendChild(sum);
const label = document.createElement("span");
label.className = "tool-label";
label.textContent = "input";
det.appendChild(label);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
block.jsonBuf = "";
block.seedInput = extra.input;
}
state.blocks[index] = block;
return block;
}
function applyEvent(state, ev) {
const t = ev.type;
if (t === "content_block_start") {
const cb = ev.content_block || {};
if (cb.type === "text") {
ensureBlock(state, ev.index, "text", {});
} else if (cb.type === "thinking") {
ensureBlock(state, ev.index, "thinking", {});
} else if (cb.type === "tool_use") {
ensureBlock(state, ev.index, "tool_use",
{ name: cb.name, id: cb.id, input: cb.input });
}
} else if (t === "content_block_delta") {
const d = ev.delta || {};
const b = state.blocks[ev.index];
if (!b) return;
if (d.type === "text_delta") {
b.el.textContent += d.text || "";
state.assistantText += d.text || "";
scrollDown();
} else if (d.type === "thinking_delta") {
b.el.textContent += d.thinking || "";
scrollDown();
} else if (d.type === "input_json_delta") {
b.jsonBuf += d.partial_json || "";
}
} else if (t === "content_block_stop") {
const b = state.blocks[ev.index];
if (!b) return;
if (b.type === "tool_use") {
let input = null;
if (b.jsonBuf && b.jsonBuf.trim()) {
try { input = JSON.parse(b.jsonBuf); }
catch { input = b.jsonBuf; }
} else if (b.seedInput !== undefined && b.seedInput !== null) {
input = b.seedInput;
}
b.el.textContent = input === null
? "(no input)"
: (typeof input === "string"
? input
: JSON.stringify(input, null, 2));
}
} else if (t === "error") {
const msg = (ev.error && ev.error.message) || "stream error";
appendError(msg);
}
}
async function send() {
const text = textEl.value.trim();
if (!text) return;
const model = agentSelect.value;
if (!model) { appendError("no agent selected"); return; }
apiMessages.push({ role: "user", content: text });
appendUser(text);
textEl.value = "";
sendBtn.disabled = true;
const container = appendAssistant();
const state = { container, blocks: {}, assistantText: "" };
let resp;
try {
resp = await fetch(URL_PREFIX + "/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF,
},
body: JSON.stringify({ model, messages: apiMessages }),
});
} catch (e) {
appendError("network error: " + e.message);
sendBtn.disabled = false;
return;
}
if (!resp.ok || !resp.body) {
let msg = resp.status + " " + resp.statusText;
try { const body = await resp.text(); if (body) msg = body; } catch {}
appendError(msg);
sendBtn.disabled = false;
return;
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const raw = buf.slice(0, i);
buf = buf.slice(i + 2);
if (!raw) continue;
let dataLine = "";
for (const line of raw.split("\n")) {
if (line.startsWith("data:")) dataLine += line.slice(5).trimStart();
}
if (!dataLine) continue;
let payload;
try { payload = JSON.parse(dataLine); } catch { continue; }
applyEvent(state, payload);
}
}
if (state.assistantText) {
apiMessages.push({ role: "assistant", content: state.assistantText });
}
sendBtn.disabled = false;
textEl.focus();
}
form.addEventListener("submit", (e) => { e.preventDefault(); send(); });
textEl.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
send();
}
});
})();
</script>
{% endblock %}
@@ -1,490 +0,0 @@
{% extends "_layout.html" %}
{% set active = "dashboard" %}
{% block title %}beaver-gateway · dashboard{% endblock %}
{% block content %}
<h2>Agents</h2>
<div class="card">
{% if agents %}
<table>
<thead><tr><th>Name</th><th>Type</th><th>Model</th><th>Exposed MCPs</th></tr></thead>
<tbody>
{% for a in agents %}
<tr>
<td><code>{{ a.name }}</code></td>
<td><span class="pill">{{ a.__class__.__name__ }}</span></td>
<td><code>{{ a.model }}</code></td>
<td>
{% for em in a.expose_mcps %}<code>{{ em.name }}</code>{% if not loop.last %}, {% endif %}{% else %}<span class="muted"></span>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No agents configured.</p>
{% endif %}
</div>
<h2>MCP namespaces</h2>
<div class="card">
{% if mcps %}
<table>
<thead><tr><th>Name</th><th>Kind</th></tr></thead>
<tbody>
{% for m in mcps %}
<tr>
<td><code>{{ m.name }}</code></td>
<td><span class="pill">{{ m.kind }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No MCP servers configured.</p>
{% endif %}
</div>
<h2>Endpoints</h2>
<div class="card endpoints">
{% if endpoints.agents or endpoints.mcps %}
<div class="ep-controls">
<label class="ep-token">
<span>Token (hint)</span>
<select id="ep-token-select">
<option value="" data-scope="">— select a known token —</option>
{% for t in tokens %}
<option value="{{ t.name }}" data-scope="{{ t.scope }}">{{ t.name }} · scope {{ t.scope }}</option>
{% endfor %}
</select>
</label>
<label class="ep-secret">
<span>Bearer secret</span>
<input type="password" id="ep-token-secret" autocomplete="off" spellcheck="false"
placeholder="paste plaintext (we only store the Argon2 hash)">
</label>
<label class="ep-show">
<input type="checkbox" id="ep-show-secret"> <span>show</span>
</label>
</div>
<p class="muted ep-note">
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, <a href="{{ p }}/tokens">mint a new one</a>.
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
into URL / curl, then click Copy.
</p>
{% if endpoints.agents %}
<h3 class="ep-h3">Agents — <code>POST /v1/messages</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Agent</th><th>Model</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.agents %}
<tr class="ep-row" data-kind="messages" data-agent="{{ ep.agent }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.agent }}</code>
<span class="pill">{{ ep.agent_type }}</span>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><code>{{ ep.model }}</code></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.anthropic_base is none and agents %}
<p class="muted">
Agents are declared but no <code>AnthropicMessagesFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.mcps %}
<h3 class="ep-h3">MCP — streamable HTTP</h3>
<table class="ep-table" data-required-scope="mcp">
<thead><tr><th>Namespace</th><th>Kind</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.mcps %}
<tr class="ep-row" data-kind="mcp" data-namespace="{{ ep.namespace }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.namespace }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><span class="pill">{{ ep.kind }}</span></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-url-token">Copy URL+token</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.mcp_base is none and mcps %}
<p class="muted">
MCP servers are declared but no <code>McpServerFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.markdown %}
<h3 class="ep-h3">Markdown — <code>POST /chat</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Vault</th><th>Default agent</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
<tr class="ep-row"
data-kind="markdown"
data-url="{{ endpoints.markdown.url }}"
data-base="{{ endpoints.markdown.base }}"
data-sample-agent="{{ endpoints.markdown.sample_agent }}">
<td>
<code>{{ endpoints.markdown.vault_path }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
{% if endpoints.markdown.log_all_chats %}
<span class="pill">log_all_chats · {{ endpoints.markdown.logged_subdir }}/</span>
{% endif %}
</td>
<td>
{% if endpoints.markdown.default_agent %}
<code>{{ endpoints.markdown.default_agent }}</code>
{% else %}
<span class="muted">— (request must set <code>agent</code> or frontmatter)</span>
{% endif %}
</td>
<td>
<code class="ep-url">{{ endpoints.markdown.url }}</code>
<div class="muted" style="font-size:0.85em; margin-top:0.25rem;">
plugin base: <code>{{ endpoints.markdown.base }}</code>
</div>
</td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-base">Copy plugin base</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
</tbody>
</table>
{% endif %}
{% else %}
<p class="muted">
Nothing exposed yet — declare agents / MCPs and the matching frontends
(<code>AnthropicMessagesFrontend</code>, <code>McpServerFrontend</code>) in your config.
</p>
{% endif %}
</div>
<h2>Recent activity</h2>
<div class="card">
{% if audit %}
<table>
<thead><tr><th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th></tr></thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:0.85rem;"><a href="{{ p }}/audit">Full log →</a></p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
<style>
.endpoints .ep-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 2fr auto;
gap: 0.85rem;
align-items: end;
margin-bottom: 0.5rem;
}
.endpoints .ep-controls label > span {
display: block;
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
.endpoints .ep-show {
display: flex; align-items: center; gap: 0.4rem;
padding-bottom: 0.6rem; color: var(--muted); font-size: 0.9em;
}
.endpoints .ep-note {
font-size: 0.85em; margin: 0.25rem 0 1.2rem;
}
.endpoints .ep-h3 {
font-size: 0.95rem; font-weight: 600;
margin: 1.5rem 0 0.55rem;
}
.endpoints .ep-table { table-layout: auto; }
.endpoints .ep-table th.ep-actions-th { width: 1%; }
.endpoints .ep-url {
word-break: break-all;
}
.endpoints .ep-actions {
white-space: nowrap;
text-align: right;
}
.endpoints .ep-actions button {
padding: 0.32rem 0.7rem;
font-size: 0.85em;
margin-left: 0.3rem;
}
.endpoints .ep-scope-warn {
display: inline-block;
margin-left: 0.5rem;
padding: 0.05rem 0.45rem;
background: #fff7e6;
border: 1px solid #fadf9c;
color: #8a5a00;
border-radius: 999px;
font-size: 0.72em;
}
.endpoints .ep-row.scope-mismatch td:not(.ep-actions) { opacity: 0.55; }
.endpoints .ep-row.scope-mismatch .ep-actions button { opacity: 0.7; }
.endpoints .ep-curl-row td { padding-top: 0; padding-bottom: 0; }
.endpoints .ep-curl {
margin: 0.4rem 0 1rem;
font-size: 0.82em;
max-height: 280px;
}
.endpoints button[data-copied="1"] {
background: #ecfdf3 !important;
border-color: #b6e6cb !important;
color: #027a48;
}
@media (max-width: 760px) {
.endpoints .ep-controls { grid-template-columns: 1fr; }
.endpoints .ep-actions { text-align: left; padding-top: 0.4rem; }
}
</style>
<script>
(() => {
const card = document.querySelector(".endpoints");
if (!card) return;
const sel = card.querySelector("#ep-token-select");
const secret = card.querySelector("#ep-token-secret");
const showCb = card.querySelector("#ep-show-secret");
if (!sel || !secret || !showCb) return;
const PLACEHOLDER = "<YOUR_TOKEN>";
function currentToken() {
const v = secret.value;
return v && v.length > 0 ? v : null;
}
function selectedScope() {
const o = sel.options[sel.selectedIndex];
return o ? (o.getAttribute("data-scope") || "") : "";
}
function scopeCovers(have, need) {
if (!have) return true; // no token selected — don't grey anything out
if (have === "*") return true;
return have === need;
}
function escSh(s) {
// single-quote shell escape: close, escape, reopen.
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
function buildCurl(row) {
const kind = row.getAttribute("data-kind");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (kind === "messages") {
const agent = row.getAttribute("data-agent");
const body = JSON.stringify({
model: agent,
messages: [{ role: "user", content: "hello" }],
stream: true,
});
return [
"curl -N \\",
" -H " + escSh("x-api-key: " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "markdown") {
const agent = row.getAttribute("data-sample-agent") || "";
const body = JSON.stringify({
filename: "example.md",
content: "### User:\n\nhello\n",
agent: agent,
});
return [
"curl \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "mcp") {
// Streamable-HTTP MCP wants `initialize` first — the response
// carries the `Mcp-Session-Id` header you must echo back on
// every subsequent call (tools/list, tools/call, ...). We use
// `-i` so the session-id is visible in the response, and ship
// the proper handshake body so a fresh paste actually works.
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "beaver-admin-curl", version: "0" },
},
});
return [
"# 1) initialize — grab Mcp-Session-Id from the response headers",
"curl -N -i \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -H 'accept: application/json, text/event-stream' \\",
" -d " + escSh(body) + " \\",
" " + url,
"",
"# 2) reuse that id on follow-up calls, e.g. tools/list:",
"# curl -N \\",
"# -H " + escSh("Authorization: Bearer " + tok) + " \\",
"# -H 'Mcp-Session-Id: <paste-from-step-1>' \\",
"# -H 'content-type: application/json' \\",
"# -H 'accept: application/json, text/event-stream' \\",
"# -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' \\",
"# " + url,
].join("\n");
}
return "";
}
function refreshScopeMarks() {
const have = selectedScope();
card.querySelectorAll(".ep-table").forEach((tbl) => {
const need = tbl.getAttribute("data-required-scope") || "";
const ok = scopeCovers(have, need);
tbl.querySelectorAll("tr.ep-row").forEach((row) => {
const warn = row.querySelector(".ep-scope-warn");
if (!ok) {
row.classList.add("scope-mismatch");
if (warn) warn.hidden = false;
} else {
row.classList.remove("scope-mismatch");
if (warn) warn.hidden = true;
}
});
});
}
function refreshOpenCurls() {
card.querySelectorAll("tr.ep-curl-row").forEach((cr) => {
if (cr.hidden) return;
const row = cr.previousElementSibling;
const pre = cr.querySelector(".ep-curl");
if (row && pre) pre.textContent = buildCurl(row);
});
}
function copyText(btn, text) {
const done = () => {
btn.setAttribute("data-copied", "1");
const prev = btn.textContent;
btn.textContent = "✓ copied";
setTimeout(() => {
btn.removeAttribute("data-copied");
btn.textContent = prev;
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => {
// Fallback for non-secure contexts.
fallbackCopy(text);
done();
});
} else {
fallbackCopy(text);
done();
}
}
function fallbackCopy(text) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch {}
document.body.removeChild(ta);
}
sel.addEventListener("change", () => {
const name = sel.value;
secret.placeholder = name
? "paste plaintext for '" + name + "' (we only store the hash)"
: "paste plaintext (we only store the Argon2 hash)";
refreshScopeMarks();
});
secret.addEventListener("input", refreshOpenCurls);
showCb.addEventListener("change", () => {
secret.type = showCb.checked ? "text" : "password";
});
card.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-action]");
if (!btn) return;
const row = btn.closest("tr.ep-row");
if (!row) return;
const action = btn.getAttribute("data-action");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (action === "copy-url") {
copyText(btn, url);
} else if (action === "copy-base") {
const base = row.getAttribute("data-base");
if (base) copyText(btn, base);
} else if (action === "copy-url-token") {
const sep = url.indexOf("?") >= 0 ? "&" : "?";
copyText(btn, url + sep + "token=" + encodeURIComponent(tok));
} else if (action === "copy-curl") {
copyText(btn, buildCurl(row));
} else if (action === "toggle-curl") {
const curlRow = row.nextElementSibling;
if (!curlRow || !curlRow.classList.contains("ep-curl-row")) return;
const pre = curlRow.querySelector(".ep-curl");
if (curlRow.hidden) {
if (pre) pre.textContent = buildCurl(row);
curlRow.hidden = false;
btn.textContent = "▾ curl";
btn.setAttribute("aria-expanded", "true");
} else {
curlRow.hidden = true;
btn.textContent = "▸ curl";
btn.setAttribute("aria-expanded", "false");
}
}
});
refreshScopeMarks();
})();
</script>
{% endblock %}
@@ -1,83 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>beaver-gateway · log in</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #fbfbfd; color: #1d1d1f; }
body {
font-family: -apple-system, "SF Pro Display", system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 16px;
padding: 2rem 2.25rem;
width: min(360px, 92vw);
box-shadow: 0 10px 24px rgba(0,0,0,0.04);
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
p.muted { color: #6e6e73; margin: 0 0 1.4rem; font-size: 0.92em; }
label { display: block; margin: 0.85rem 0 0.3rem; font-size: 0.82em; color: #6e6e73; }
input {
width: 100%;
padding: 0.6rem 0.75rem;
font-family: inherit;
font-size: 0.95em;
border: 1px solid #e5e5ea;
border-radius: 8px;
background: #fff;
box-sizing: border-box;
}
input:focus { outline: 2px solid #0071e3; outline-offset: 1px; }
button {
width: 100%;
margin-top: 1.25rem;
padding: 0.65rem;
font-family: inherit;
font-size: 0.95em;
background: #0071e3;
border: 1px solid #0071e3;
color: white;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #005bb5; }
.error {
background: #fff0f0;
border: 1px solid #f5c2c5;
color: #d70015;
padding: 0.65rem 0.85rem;
border-radius: 8px;
font-size: 0.9em;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="card">
<h1>beaver-gateway</h1>
<p class="muted">Sign in to manage tokens and view audit logs.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post" action="{{ p }}/login">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>
@@ -1,146 +0,0 @@
{% extends "_layout.html" %}
{% set active = "pty" %}
{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %}
{% block content %}
<style>
/* xterm.js styles inlined from the CDN bundle — keep them tight and
constrained to the terminal block so they don't leak into the rest
of the admin UI. */
.term-wrap {
background: #000;
border-radius: 12px;
padding: 12px;
border: 1px solid var(--line);
}
.term-host {
height: 70vh;
}
.term-host .xterm {
height: 100%;
}
.term-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.term-toolbar .status {
margin-left: auto;
font-size: 0.85em;
color: var(--muted);
}
.term-toolbar .status.connected { color: #027a48; }
.term-toolbar .status.dropped { color: var(--danger); }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<h2>PTY <code>{{ session_id }}</code></h2>
<p class="muted">
Live view of the claude subprocess TUI. Keystrokes you type here go
straight into its stdin — Enter, arrows, Ctrl-C, paste, all work.
<a href="{{ p }}/pty">← back to list</a>
</p>
<div class="term-toolbar">
<button id="reconnect-btn" type="button">Reconnect</button>
<button id="enter-btn" type="button" title="Send a single \r — handy if a paste is stuck in the input box">Send Enter</button>
<button id="ctrl-c-btn" type="button">Send Ctrl-C</button>
<span id="status" class="status">connecting…</span>
</div>
<div class="term-wrap">
<div id="term" class="term-host"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script>
(function () {
const sessionId = {{ session_id|tojson }};
const wsBase =
(location.protocol === "https:" ? "wss://" : "ws://") +
location.host +
{{ p|tojson }} +
"/pty/" + encodeURIComponent(sessionId) + "/ws";
const term = new Terminal({
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
theme: {
background: "#000000",
foreground: "#d0d0d0",
},
convertEol: false,
cursorBlink: true,
scrollback: 5000,
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("term"));
// Defer fit until after layout settles; xterm's measurement reads
// computed CSS that isn't stable until the page paints once.
requestAnimationFrame(() => fit.fit());
window.addEventListener("resize", () => fit.fit());
const statusEl = document.getElementById("status");
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = "status" + (cls ? " " + cls : "");
}
let ws = null;
let manuallyClosed = false;
function connect() {
manuallyClosed = false;
setStatus("connecting…", "");
ws = new WebSocket(wsBase);
ws.binaryType = "arraybuffer";
ws.onopen = () => setStatus("connected", "connected");
ws.onclose = () => {
if (!manuallyClosed) setStatus("disconnected", "dropped");
};
ws.onerror = () => setStatus("error", "dropped");
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
// ArrayBuffer — feed raw bytes to xterm. It expects either
// string or Uint8Array; the latter preserves byte boundaries
// exactly, which matters for ANSI sequences split across frames.
term.write(new Uint8Array(ev.data));
}
};
}
// Keystrokes -> server. xterm.js gives us the exact byte sequence
// the terminal would emit (e.g. Enter -> "\r", arrows -> "\x1b[A"
// etc.), so we just pass it through.
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
document.getElementById("reconnect-btn").addEventListener("click", () => {
if (ws) {
manuallyClosed = true;
ws.close();
}
connect();
});
document.getElementById("enter-btn").addEventListener("click", () => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\r");
term.focus();
});
document.getElementById("ctrl-c-btn").addEventListener("click", () => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x03");
term.focus();
});
connect();
})();
</script>
{% endblock %}
@@ -1,46 +0,0 @@
{% extends "_layout.html" %}
{% set active = "pty" %}
{% block title %}PTY · beaver-gateway · admin{% endblock %}
{% block content %}
<h2>Live PTY sessions</h2>
<p class="muted">
Each row is a running <code>claude</code> 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.
</p>
{% if sessions %}
<div class="card" style="padding: 0;">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Agent</th>
<th>Idle</th>
<th>Age</th>
<th>PID</th>
<th>Buffer</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><code>{{ s.session_id }}</code></td>
<td>{{ s.agent }}</td>
<td>{{ s.idle }}</td>
<td class="muted">{{ s.age }}</td>
<td><span class="pill">{{ s.pid or "?" }}</span></td>
<td>{{ s.buffer_size }} bytes</td>
<td>
<a class="btn primary" href="{{ p }}/pty/{{ s.session_id }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="banner">No live PTY sessions right now. Start a turn and refresh.</div>
{% endif %}
{% endblock %}
@@ -1,64 +0,0 @@
{% extends "_layout.html" %}
{% set active = "tokens" %}
{% block title %}beaver-gateway · tokens{% endblock %}
{% block content %}
<h2>Create token</h2>
<div class="card">
<p class="muted">Plaintext is shown <strong>once</strong>, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.</p>
<div id="token-create-result"></div>
<form
hx-post="{{ p }}/tokens"
hx-target="#token-create-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){this.reset();}"
>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="form-grid">
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" placeholder="cursor / claude-desktop / mobile …" required>
</div>
<div>
<label for="scope">Scope</label>
<select id="scope" name="scope">
{% for s in scopes %}
<option value="{{ s }}" {% if s == "*" %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div>
<button class="primary" type="submit">Create</button>
</div>
</div>
</form>
</div>
<h2>
Tokens
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
{% if include_revoked %}
<a href="{{ p }}/tokens">Hide revoked</a>
{% else %}
<a href="{{ p }}/tokens?include_revoked=1">Show revoked</a>
{% endif %}
</span>
</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Name</th><th>Scope</th><th>Created</th><th>Last used</th><th>Revoked</th><th></th>
</tr>
</thead>
{# Render the tbody unconditionally so the HTMX OOB swap on
create has a target even when the table starts empty. #}
<tbody id="tokens-rows">
{% for token in tokens %}
{% include "_token_row.html" %}
{% else %}
<tr id="tokens-empty"><td colspan="6" class="muted">No tokens yet. Create one above.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+459 -61
View File
@@ -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
+2
View File
@@ -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",
+34
View File
@@ -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",
+387
View File
@@ -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("<html>ui</html>", 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 == "<html>ui</html>"
assert (await http.get("/")).status_code == 307
escape = await http.get("/admin/%2e%2e/pyproject.toml")
assert escape.status_code == 200 and escape.text == "<html>ui</html>"
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
+7 -2
View File
@@ -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
+7 -2
View File
@@ -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=="],
+2 -1
View File
@@ -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",
+19 -1
View File
@@ -2,11 +2,29 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<meta content="width=device-width, initial-scale=1, viewport-fit=cover" name="viewport" />
<meta content="light dark" name="color-scheme" />
<meta content="scale" name="text-scale" />
<title>Beaver</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<!--
impeccable direction contract (seed 20df1617, mode operate, brief-pinned world)
THESIS: activity first. The console opens on what the agents are doing right now, as a live
ledger of turns and tool calls, and refuses the hero-metric dashboard grid of cards.
OWN-WORLD: msos mauve/pink tokens on Inter; two neutral layers (cooler sidebar, content surface);
one accent for selection and the live state; kind hues from the msos status palette; hairline
borders, tabular numerals, rows and rails instead of cards; no cards inside cards.
STORY: the operator lands, sees what runs and how much quota is left, opens a thread, watches
tools and subagents stream, answers a question, checks spend, manages tokens and memory.
FIRST VIEWPORT: sidebar left with the gateway connection dot; main opens with the "now" ledger
(running turns as live rows), then quota bars and the 5 h / 7 d spend row, then live sessions.
Primary action: open a running conversation.
FORM: dense operator console, brief-pinned (roll assigned index 7, superseded by the pin).
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+323
View File
@@ -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<string, unknown>;
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<unknown> {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text;
}
}
export type Params = Record<string, string | number | boolean | undefined>;
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<string | null>;
}
// 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<string | null>;
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<string, string> = {}): Record<string, string> {
return { Authorization: `Bearer ${this.token}`, ...extra };
}
private async request<T>(
method: string,
path: string,
body?: unknown,
params?: Params,
retried = false
): Promise<T> {
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<T>(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<T>(path: string, params?: Params): Promise<T> {
return this.request<T>("GET", path, undefined, params);
}
post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, body ?? {});
}
patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, body ?? {});
}
async stream(
path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal
): Promise<void> {
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<AgentsResponse> {
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<ConversationInfo> {
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<EntriesPage> {
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<ConversationInfo> {
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<ConversationInfo> {
return this.post(`/api/conversations/${id}/bind`, {
external_id: externalId,
frontend,
visible,
});
}
setFlags(
id: string,
flags: Record<string, unknown>
): Promise<ConversationSummary> {
return this.patch(`/api/conversations/${id}/flags`, flags);
}
update(
id: string,
body: { status?: string; title?: string }
): Promise<ConversationSummary> {
return this.patch(`/api/conversations/${id}`, body);
}
spawn(body: {
kind: string;
agent?: string;
seed?: string;
text?: string;
title?: string;
}): Promise<ConversationInfo> {
return this.post("/api/conversations", body);
}
sessions(): Promise<SessionsResponse> {
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<UsageResponse> {
return this.get("/api/usage", params);
}
limits(): Promise<LimitsResponse> {
return this.get("/api/limits");
}
memory(): Promise<MemoryTree> {
return this.get("/api/memory");
}
memoryFile(path: string): Promise<MemoryFile> {
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<AuditPage> {
return this.get("/api/audit", params);
}
}
+140
View File
@@ -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<void> {
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<void>;
}
// SSE subscription with reconnect and backoff. ``state`` is reactive so a
// header can show "open" / "retrying …" without polling.
export class LiveStream {
state = $state<LiveState>("off");
detail = $state<string | null>(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<void> {
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";
}
}
}
+66
View File
@@ -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<void> {
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);
}
}
+278
View File
@@ -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<string, unknown>;
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<string, unknown>[];
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<string, string>;
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, unknown> | 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;
}
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const running = $derived(gateway.running.length);
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<aside
class="hidden w-52 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground sm:flex"
>
<a
class="flex h-12 items-center gap-2 border-b px-4 font-semibold tracking-tight"
href="{base}/"
>
<span class="size-2.5 rounded-sm bg-primary"></span>
Beaver
</a>
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
{#each NAV as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn(
"flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors",
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
href="{base}{item.href}"
>
<item.icon class="size-4 shrink-0 text-icon" />
<span class="flex-1">{item.label}</span>
{#if item.href === "/" && running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{/if}
</a>
{/each}
</nav>
<div class="flex flex-col gap-2 border-t p-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-muted-foreground text-xs">{session.user}</span>
<div class="flex items-center">
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
</div>
</div>
</div>
</aside>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import * as Sheet from "$lib/components/ui/sheet";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
let moreOpen = $state(false);
const primary = NAV.filter((item) => item.mobile);
const secondary = NAV.filter((item) => !item.mobile);
async function signOut() {
moreOpen = false;
await session.logout();
await goto(`${base}/login`);
}
</script>
<nav
aria-label="Sections"
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden"
>
{#each primary as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn(
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]",
active ? "text-link" : "text-muted-foreground"
)}
href="{base}{item.href}"
>
<item.icon class="size-5" />
{item.label}
</a>
{/each}
<button
class="flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px] text-muted-foreground"
onclick={() => {
moreOpen = true;
}}
type="button"
>
<MoreHorizontalIcon class="size-5" />
More
</button>
</nav>
<Sheet.Root bind:open={moreOpen}>
<Sheet.Content class="flex flex-col gap-1 pt-10" side="bottom">
<Sheet.Title class="px-2 pb-2">More</Sheet.Title>
{#each secondary as item (item.href)}
<a
class="flex h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-muted"
href="{base}{item.href}"
onclick={() => {
moreOpen = false;
}}
>
<item.icon class="size-4 text-icon" />
{item.label}
</a>
{/each}
<div class="mt-2 flex items-center justify-between border-t px-3 pt-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center gap-1">
<ThemeToggle />
<Button onclick={signOut} size="sm" variant="ghost">Sign out</Button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
title,
hint = "",
class: className = "",
children,
}: {
title: string;
hint?: string;
class?: string;
children?: Snippet;
} = $props();
</script>
<div
class={cn(
"flex flex-col items-start gap-1 rounded-lg border border-dashed px-4 py-5 text-sm",
className
)}
>
<p class="font-medium">{title}</p>
{#if hint}
<p class="text-muted-foreground">{hint}</p>
{/if}
{#if children}
<div class="pt-2">{@render children()}</div>
{/if}
</div>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
let {
message,
retry,
}: { message: string; retry?: () => void | Promise<void> } = $props();
</script>
<div
class="flex flex-wrap items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"
role="alert"
>
<span class="text-destructive">{message}</span>
{#if retry}
<Button onclick={() => retry()} size="sm" variant="outline">Retry</Button>
{/if}
</div>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Kind } from "$lib/api/types";
import { cn } from "$lib/utils";
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
$props();
const STYLE: Record<string, string> = {
branch: "bg-kind-branch/12 text-kind-branch",
deep: "bg-kind-deep/12 text-kind-deep",
fork: "bg-kind-fork/12 text-kind-fork",
job: "bg-kind-job/12 text-kind-job",
master: "bg-kind-master/12 text-kind-master",
};
</script>
<span
class={cn(
"inline-flex h-5 items-center rounded-md px-1.5 font-medium text-xs leading-none",
STYLE[kind] ?? "bg-muted text-muted-foreground",
className
)}
>
{kind}
</span>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import type { LimitWindow } from "$lib/api/types";
import { fmtCountdown, fmtMoney, fmtTokens } from "$lib/format";
import { limitLabel } from "$lib/limits";
import { cn } from "$lib/utils";
let { window: w, now }: { window: LimitWindow; now: number } = $props();
const BAR: Record<string, string> = {
allowed: "bg-primary",
allowed_warning: "bg-warn",
rejected: "bg-destructive",
};
const TEXT: Record<string, string> = {
allowed: "text-foreground",
allowed_warning: "text-warn",
rejected: "text-destructive",
};
const known = $derived(w.utilization !== null && w.utilization !== undefined);
const pct = $derived(Math.min(100, Math.round((w.utilization ?? 0) * 100)));
const spent = $derived(
w.gateway.input + w.gateway.output + w.gateway.cache_creation
);
</script>
<div class="ledger-grid grid-cols-[7rem_minmax(0,1fr)_3.5rem] gap-y-1 py-2">
<span class="font-medium text-sm">{limitLabel(w.window)}</span>
{#if known}
<div
aria-label="{limitLabel(w.window)} utilization"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={pct}
class="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<div
class={cn(
"h-full rounded-full transition-[width] duration-300",
BAR[w.status]
)}
style="width: {pct}%"
></div>
</div>
<span
class={cn("tabular text-right font-semibold text-sm", TEXT[w.status])}
>
{pct}%
</span>
{:else}
<span class="text-muted-foreground text-sm">
utilization not reported yet
</span>
<span></span>
{/if}
<span class="tabular text-muted-foreground text-xs"
>{fmtCountdown(w.resets_at, now)}</span
>
<span class="tabular col-span-2 text-muted-foreground text-xs">
via gateway this window: {fmtTokens(spent)} tokens ·
{fmtMoney(w.gateway.cost_usd)}
· {w.gateway.turns} turns
{#if w.status !== "allowed"}
<span class={cn("ml-2 font-medium", TEXT[w.status])}
>{w.status.replace("_", " ")}</span
>
{/if}
</span>
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { LiveState } from "$lib/api/live.svelte";
import { cn } from "$lib/utils";
let {
state,
detail = null,
class: className = "",
label = true,
}: {
state: LiveState;
detail?: string | null;
class?: string;
label?: boolean;
} = $props();
const COLORS: Record<LiveState, string> = {
connecting: "bg-warn",
failed: "bg-destructive",
off: "bg-muted-foreground/40",
open: "bg-ok",
retrying: "bg-warn",
};
const TEXT: Record<LiveState, string> = {
connecting: "connecting",
failed: "failed",
off: "offline",
open: "live",
retrying: "reconnecting",
};
</script>
<span
class={cn(
"inline-flex items-center gap-1.5 text-muted-foreground text-xs",
className
)}
title={detail ?? TEXT[state]}
>
<span
class={cn(
"size-2 rounded-full",
COLORS[state],
state === "open" && "animate-pulse-dot"
)}
></span>
{#if label}
<span>{TEXT[state]}</span>
{/if}
</span>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from "svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { gateway } from "$lib/gateway.svelte";
let {
title,
subtitle = "",
children,
actions,
}: {
title: string;
subtitle?: string;
children?: Snippet;
actions?: Snippet;
} = $props();
</script>
<header
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2 sm:px-6"
>
<div class="flex min-w-0 items-baseline gap-2">
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
{#if subtitle}
<span class="truncate text-muted-foreground text-sm">{subtitle}</span>
{/if}
</div>
{#if children}
<div class="flex min-w-0 flex-wrap items-center gap-2">
{@render children()}
</div>
{/if}
<div class="ml-auto flex items-center gap-2">
{#if actions}
{@render actions()}
{/if}
<LiveDot
class="sm:hidden"
detail={gateway.live.detail}
label={false}
state={gateway.live.state}
/>
</div>
</header>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { cn } from "$lib/utils";
let {
label,
value,
hint = "",
tone = "default",
class: className = "",
}: {
label: string;
value: string;
hint?: string;
tone?: "default" | "ok" | "warn" | "danger" | "muted";
class?: string;
} = $props();
const TONE: Record<string, string> = {
danger: "text-destructive",
default: "text-foreground",
muted: "text-muted-foreground",
ok: "text-ok",
warn: "text-warn",
};
</script>
<div class={cn("flex min-w-0 flex-col gap-0.5", className)}>
<span class="truncate text-muted-foreground text-xs">{label}</span>
<span
class={cn("tabular truncate font-semibold text-base leading-tight", TONE[tone])}
>
{value}
</span>
{#if hint}
<span class="truncate text-muted-foreground text-xs">{hint}</span>
{/if}
</div>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { cn } from "$lib/utils";
let { status, class: className = "" }: { status: string; class?: string } =
$props();
const STYLE: Record<string, string> = {
aborted: "text-muted-foreground",
allowed: "text-ok",
allowed_warning: "text-warn",
archived: "text-muted-foreground",
closed: "text-muted-foreground",
done: "text-ok",
error: "text-destructive",
failed: "text-destructive",
interrupted: "text-warn",
merged: "text-kind-branch",
open: "text-foreground",
queued: "text-muted-foreground",
rejected: "text-destructive",
running: "text-signal",
};
</script>
<span
class={cn(
"inline-flex items-center gap-1.5 font-medium text-xs",
STYLE[status] ?? "text-muted-foreground",
className
)}
>
<span
class={cn(
"size-1.5 rounded-full bg-current",
status === "running" && "animate-pulse-dot"
)}
></span>
{status.replace("_", " ")}
</span>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { mode, toggleMode } from "mode-watcher";
import { Button } from "$lib/components/ui/button";
</script>
<Button
aria-label="Toggle theme"
onclick={toggleMode}
size="icon-sm"
title="Toggle theme"
variant="ghost"
>
{#if mode.current === "dark"}
<SunIcon class="size-4" />
{:else}
<MoonIcon class="size-4" />
{/if}
</Button>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close
bind:ref
data-slot="dialog-close"
{type}
{...restProps}
/>
@@ -0,0 +1,53 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import { Button } from "$lib/components/ui/button/index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import * as Dialog from "./index.js";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button
variant="ghost"
class="absolute top-4 right-4 bg-secondary"
size="icon-sm"
{...props}
>
<XIcon />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-1.5 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn("bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("font-heading text-base leading-none font-medium", className)}
{...restProps}
/>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger
bind:ref
data-slot="dialog-trigger"
{type}
{...restProps}
/>
@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps =
$props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
+34
View File
@@ -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,
};
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import MinusIcon from "@lucide/svelte/icons/minus";
import CheckIcon from "@lucide/svelte/icons/check";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,33 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
align = "start",
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<
ComponentProps<typeof DropdownMenuPortal>
>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
"min-w-48 rounded-3xl p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>
@@ -0,0 +1,14 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
...restProps
}: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group
bind:ref
data-slot="dropdown-menu-group"
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5 data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,34 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn("min-w-36 rounded-3xl p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 w-auto animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"gap-2 rounded-2xl px-3 py-2 text-sm font-medium focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,10 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
open = $bindable(false),
...restProps
}: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,14 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
...restProps
}: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger
bind:ref
data-slot="dropdown-menu-trigger"
{...restProps}
/>
@@ -0,0 +1,10 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
open = $bindable(false),
...restProps
}: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
@@ -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,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
@@ -0,0 +1,54 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type {
HTMLInputAttributes,
HTMLInputTypeAttribute,
} from "svelte/elements";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
(
| { type: "file"; files?: FileList }
| { type?: InputType; files?: undefined }
)
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"h-9 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
type="file"
bind:files
bind:value
{...restProps}
>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"h-9 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{type}
bind:value
{...restProps}
>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...restProps}
/>
+37
View File
@@ -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,
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
import SelectPortal from "./select-portal.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"min-w-36 rounded-3xl text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 isolate z-50 overflow-x-hidden overflow-y-auto animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group
bind:ref
data-slot="select-group"
class={cn("scroll-my-1.5 p-1.5", className)}
{...restProps}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="cn-select-item-indicator-icon" />
{/if}
</span>
<span class="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
</span>
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
{...restProps}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
{...restProps}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { Separator as SeparatorPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("-mx-1.5 my-1.5 h-px bg-border pointer-events-none", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"gap-1.5 rounded-3xl border border-transparent bg-input/50 px-3 py-2 text-sm transition-[color,box-shadow,background-color] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 text-muted-foreground pointer-events-none" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
// this is different in shadcn/ui but self-stretch breaks things for us
"data-[orientation=vertical]:h-full",
className
)}
{...restProps}
/>
+34
View File
@@ -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,
};
@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps =
$props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
@@ -0,0 +1,60 @@
<script lang="ts" module>
export type Side = "top" | "right" | "bottom" | "left";
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import { Button } from "$lib/components/ui/button/index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import SheetOverlay from "./sheet-overlay.svelte";
import SheetPortal from "./sheet-portal.svelte";
import type { Snippet } from "svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
side = "right",
showCloseButton = true,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
showCloseButton?: boolean;
children: Snippet;
} = $props();
</script>
<SheetPortal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
data-side={side}
class={cn(
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<SheetPrimitive.Close data-slot="sheet-close">
{#snippet child({ props })}
<Button
variant="ghost"
class="absolute top-4 right-4 bg-secondary"
size="icon-sm"
{...props}
>
<XIcon />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</SheetPrimitive.Close>
{/if}
</SheetPrimitive.Content>
</SheetPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-sm text-muted-foreground", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("gap-2 p-6 mt-auto flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("gap-1.5 p-6 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn("bg-black/30 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 z-50", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ...restProps }: SheetPrimitive.PortalProps = $props();
</script>
<SheetPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("font-heading text-base font-medium text-foreground", className)}
{...restProps}
/>
@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps =
$props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

Some files were not shown because too many files have changed in this diff Show More