feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
@@ -19,3 +19,6 @@ t/
|
|||||||
# Local env
|
# Local env
|
||||||
.env
|
.env
|
||||||
db.db
|
db.db
|
||||||
|
|
||||||
|
# Impeccable review screenshots
|
||||||
|
.impeccable/review/
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ dependencies = [
|
|||||||
"fastmcp>=3.3.1",
|
"fastmcp>=3.3.1",
|
||||||
"greenlet>=3.5.0",
|
"greenlet>=3.5.0",
|
||||||
"itsdangerous>=2.2.0",
|
"itsdangerous>=2.2.0",
|
||||||
"jinja2>=3.1.6",
|
|
||||||
"psutil>=7.2.2",
|
"psutil>=7.2.2",
|
||||||
"psycopg[binary]>=3.3.4",
|
"psycopg[binary]>=3.3.4",
|
||||||
"pydantic>=2.13.4",
|
"pydantic>=2.13.4",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@ def _usage_of(result: ResultMessage | None) -> TurnUsage:
|
|||||||
cost_usd=result.total_cost_usd,
|
cost_usd=result.total_cost_usd,
|
||||||
duration_ms=result.duration_ms,
|
duration_ms=result.duration_ms,
|
||||||
num_turns=result.num_turns,
|
num_turns=result.num_turns,
|
||||||
|
model_usage=cast("dict[str, Any] | None", result.model_usage),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ async def _build_backends(
|
|||||||
cost_usd=event.usage.cost_usd,
|
cost_usd=event.usage.cost_usd,
|
||||||
duration_ms=event.usage.duration_ms,
|
duration_ms=event.usage.duration_ms,
|
||||||
num_turns=event.usage.num_turns,
|
num_turns=event.usage.num_turns,
|
||||||
|
model_usage=event.usage.model_usage,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with db.session() as session:
|
async with db.session() as session:
|
||||||
|
|||||||
@@ -236,6 +236,11 @@ class TokenStore:
|
|||||||
self._flusher_task = None
|
self._flusher_task = None
|
||||||
await self._flush_now()
|
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:
|
async def invalidate(self) -> None:
|
||||||
"""Force the next verify to re-read from DB (Phase 4.3 admin hook)."""
|
"""Force the next verify to re-read from DB (Phase 4.3 admin hook)."""
|
||||||
self._loaded_at = 0.0
|
self._loaded_at = 0.0
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any, cast
|
|||||||
|
|
||||||
from claude_agent_sdk import (
|
from claude_agent_sdk import (
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
|
RateLimitEvent,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
StreamEvent,
|
StreamEvent,
|
||||||
ToolResultBlock,
|
ToolResultBlock,
|
||||||
@@ -43,6 +44,7 @@ from beaver_gateway.core.transcript import (
|
|||||||
messages_from_entries,
|
messages_from_entries,
|
||||||
render_messages,
|
render_messages,
|
||||||
strip_tool_entries,
|
strip_tool_entries,
|
||||||
|
text_of,
|
||||||
window_entries,
|
window_entries,
|
||||||
)
|
)
|
||||||
from beaver_gateway.core.turn_capture import TurnCapture
|
from beaver_gateway.core.turn_capture import TurnCapture
|
||||||
@@ -51,6 +53,7 @@ from beaver_gateway.storage.models import (
|
|||||||
Conversation,
|
Conversation,
|
||||||
ConversationBinding,
|
ConversationBinding,
|
||||||
InjectQueueItem,
|
InjectQueueItem,
|
||||||
|
RateLimit,
|
||||||
Schedule,
|
Schedule,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,6 +136,23 @@ class _Runner:
|
|||||||
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
task: asyncio.Task[None] | None = None
|
task: asyncio.Task[None] | None = None
|
||||||
turn_id: str | 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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -442,8 +462,21 @@ class Conversations:
|
|||||||
live = self._pool.get(conv.external_id)
|
live = self._pool.get(conv.external_id)
|
||||||
out["live"] = live is not None
|
out["live"] = live is not None
|
||||||
out["busy"] = live.busy if live is not None else False
|
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
|
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 -------------------------------------------------------
|
# ---- routing -------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -593,14 +626,21 @@ class Conversations:
|
|||||||
return ForkResult(conversation=child, text=text, capture=capture)
|
return ForkResult(conversation=child, text=text, capture=capture)
|
||||||
|
|
||||||
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
|
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:
|
if conv.session_id is None:
|
||||||
return ""
|
return []
|
||||||
entries = await self._store.load(cast("Any", self._store_key(conv)))
|
key = {**self._store_key(conv), "subpath": subpath}
|
||||||
if not entries:
|
return list(await self._store.load(cast("Any", key)) or [])
|
||||||
return ""
|
|
||||||
return render_messages(
|
async def subpaths(self, conv: Conversation) -> list[str]:
|
||||||
messages_from_entries(cast("Any", entries)), window=window
|
if conv.session_id is None:
|
||||||
)
|
return []
|
||||||
|
return list(await self._store.list_subkeys(cast("Any", self._store_key(conv))))
|
||||||
|
|
||||||
async def inject(
|
async def inject(
|
||||||
self,
|
self,
|
||||||
@@ -816,6 +856,10 @@ class Conversations:
|
|||||||
resume = session_id if session_id is not None else conv.session_id
|
resume = session_id if session_id is not None else conv.session_id
|
||||||
async with runner.lock:
|
async with runner.lock:
|
||||||
runner.turn_id = turn_id
|
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)
|
await self._mark_running(conv, turn_id)
|
||||||
self._bus.publish(
|
self._bus.publish(
|
||||||
"turn.start",
|
"turn.start",
|
||||||
@@ -823,6 +867,7 @@ class Conversations:
|
|||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
item_origin=item_origin,
|
item_origin=item_origin,
|
||||||
|
text=runner.text,
|
||||||
)
|
)
|
||||||
stop = "error"
|
stop = "error"
|
||||||
cut = False
|
cut = False
|
||||||
@@ -836,7 +881,7 @@ class Conversations:
|
|||||||
kind=conv.kind,
|
kind=conv.kind,
|
||||||
pinned=conv.kind == "master",
|
pinned=conv.kind == "master",
|
||||||
tools=tools,
|
tools=tools,
|
||||||
observer=self._observer(conv.external_id, turn_id, origin),
|
observer=self._observer(conv, runner, turn_id, origin),
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
)
|
)
|
||||||
async for event in events:
|
async for event in events:
|
||||||
@@ -1117,11 +1162,15 @@ class Conversations:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _observer(
|
def _observer(
|
||||||
self, conversation_id: str, turn_id: str, origin: str
|
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str
|
||||||
) -> Callable[[Any], None]:
|
) -> Callable[[Any], None]:
|
||||||
|
conversation_id = conv.external_id
|
||||||
|
|
||||||
def observe(message: Any) -> None:
|
def observe(message: Any) -> None:
|
||||||
parent = getattr(message, "parent_tool_use_id", 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(
|
self._bus.publish(
|
||||||
"stream",
|
"stream",
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
@@ -1133,7 +1182,7 @@ class Conversations:
|
|||||||
elif isinstance(message, AssistantMessage):
|
elif isinstance(message, AssistantMessage):
|
||||||
for block in message.content:
|
for block in message.content:
|
||||||
if isinstance(block, ToolUseBlock):
|
if isinstance(block, ToolUseBlock):
|
||||||
self._bus.publish(
|
event = self._bus.publish(
|
||||||
"tool",
|
"tool",
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
@@ -1143,11 +1192,21 @@ class Conversations:
|
|||||||
name=block.name,
|
name=block.name,
|
||||||
input=block.input,
|
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):
|
elif isinstance(message, UserMessage):
|
||||||
blocks = message.content if isinstance(message.content, list) else ()
|
blocks = message.content if isinstance(message.content, list) else ()
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
if isinstance(block, ToolResultBlock):
|
if isinstance(block, ToolResultBlock):
|
||||||
self._bus.publish(
|
event = self._bus.publish(
|
||||||
"tool.result",
|
"tool.result",
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
@@ -1157,6 +1216,11 @@ class Conversations:
|
|||||||
is_error=bool(block.is_error),
|
is_error=bool(block.is_error),
|
||||||
content=_result_preview(block.content),
|
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:
|
elif isinstance(message, ResultMessage) and parent is None:
|
||||||
self._bus.publish(
|
self._bus.publish(
|
||||||
"result",
|
"result",
|
||||||
@@ -1170,6 +1234,40 @@ class Conversations:
|
|||||||
|
|
||||||
return observe
|
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 _mark_running(self, conv: Conversation, turn_id: str) -> None:
|
||||||
async def apply(row: Conversation) -> None:
|
async def apply(row: Conversation) -> None:
|
||||||
row.running_turn = turn_id
|
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
|
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(
|
def _result_preview(
|
||||||
content: str | list[dict[str, Any]] | None, limit: int = 400
|
content: str | list[dict[str, Any]] | None, limit: int = 400
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class TurnUsage:
|
|||||||
cost_usd: float | None = None
|
cost_usd: float | None = None
|
||||||
duration_ms: int | None = None
|
duration_ms: int | None = None
|
||||||
num_turns: 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
|
@dataclass
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
"""Admin UI (Phase 4.3).
|
"""Admin console: the ``ui/`` SPA served from the admin port plus its login."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from beaver_gateway.frontends.admin.frontend import AdminFrontend
|
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 %}
|
|
||||||
@@ -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
|
Bearer scope ``api``; token and audit management need ``admin``. Every
|
||||||
frontend only shapes JSON. ``/api/events`` and
|
write goes through ``core/conversations``; the frontend only shapes JSON.
|
||||||
``/api/conversations/{id}/events`` replay the gateway bus as SSE with the
|
``/api/events`` and ``/api/conversations/{id}/events`` replay the gateway
|
||||||
same keepalive the markdown frontend uses, so a proxy never sees a
|
bus as SSE with the same keepalive the markdown frontend uses, so a proxy
|
||||||
silent socket.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
from collections import Counter
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, status
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from sqlalchemy import func
|
from sqlmodel import col, select
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
from sqlmodel import col
|
|
||||||
|
|
||||||
from beaver_gateway.core import audit
|
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.conversations import SEEDS
|
||||||
from beaver_gateway.core.kinds import Kind, as_kind
|
from beaver_gateway.core.kinds import Kind, as_kind
|
||||||
from beaver_gateway.frontends._auth import require_token
|
from beaver_gateway.frontends._auth import require_token
|
||||||
@@ -32,20 +40,37 @@ from beaver_gateway.frontends._sse import (
|
|||||||
sse_pack,
|
sse_pack,
|
||||||
)
|
)
|
||||||
from beaver_gateway.frontends.base import Frontend
|
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:
|
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.core.conversations import Conversations
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
from beaver_gateway.storage.models import Conversation
|
|
||||||
|
|
||||||
_log = logging.getLogger("beaver_gateway.frontends.api")
|
_log = logging.getLogger("beaver_gateway.frontends.api")
|
||||||
|
|
||||||
__all__ = ["ApiFrontend"]
|
__all__ = ["ApiFrontend"]
|
||||||
|
|
||||||
SCOPE = "api"
|
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):
|
class ApiFrontend(Frontend):
|
||||||
@@ -62,6 +87,7 @@ class ApiFrontend(Frontend):
|
|||||||
branch_agent: str | None = None,
|
branch_agent: str | None = None,
|
||||||
deep_agent: str | None = None,
|
deep_agent: str | None = None,
|
||||||
job_agent: str | None = None,
|
job_agent: str | None = None,
|
||||||
|
memory_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -70,6 +96,7 @@ class ApiFrontend(Frontend):
|
|||||||
self.branch_agent = branch_agent
|
self.branch_agent = branch_agent
|
||||||
self.deep_agent = deep_agent
|
self.deep_agent = deep_agent
|
||||||
self.job_agent = job_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
|
self._app: FastAPI | None = None
|
||||||
|
|
||||||
def agent_for(self, kind: Kind) -> str | 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:
|
if runtime.conversations is None or runtime.bus is None:
|
||||||
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self._app = _build_app(runtime)
|
self._app = build_app(runtime, memory_root=self.memory_root)
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -98,7 +125,7 @@ class ApiFrontend(Frontend):
|
|||||||
await server.serve()
|
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 = FastAPI(title="beaver-gateway / API")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -146,6 +173,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
return value
|
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")
|
@app.get("/healthz")
|
||||||
async def healthz() -> dict[str, str]:
|
async def healthz() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -158,20 +189,26 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
{
|
{
|
||||||
"name": a.name,
|
"name": a.name,
|
||||||
"model": a.model,
|
"model": a.model,
|
||||||
|
"type": a.__class__.__name__,
|
||||||
"kinds": list(getattr(a, "kinds", ())),
|
"kinds": list(getattr(a, "kinds", ())),
|
||||||
|
"effort": getattr(getattr(a, "options", None), "effort", None),
|
||||||
}
|
}
|
||||||
for a in runtime.agents
|
for a in runtime.agents
|
||||||
],
|
],
|
||||||
"frontends": [
|
"frontends": [
|
||||||
{
|
{
|
||||||
"name": fe.name,
|
"name": fe.name or fe.__class__.__name__,
|
||||||
|
"type": fe.__class__.__name__,
|
||||||
"kinds": list(fe.kinds),
|
"kinds": list(fe.kinds),
|
||||||
"default_agents": {
|
"default_agents": {
|
||||||
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
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")
|
@app.get("/api/conversations")
|
||||||
@@ -179,7 +216,9 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
await require_token(request, runtime, scope=SCOPE)
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
q = request.query_params
|
q = request.query_params
|
||||||
rows = await conversations.find(
|
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]}
|
return {"conversations": [conversations.public(r) for r in rows]}
|
||||||
|
|
||||||
@@ -238,7 +277,7 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
"priority": i.priority,
|
"priority": i.priority,
|
||||||
"origin": i.origin,
|
"origin": i.origin,
|
||||||
"status": i.status,
|
"status": i.status,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": _iso(i.created_at),
|
||||||
"text": i.text[:200],
|
"text": i.text[:200],
|
||||||
}
|
}
|
||||||
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
|
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),
|
"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(
|
@app.post(
|
||||||
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
|
"/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)
|
conv = await conv_of(public_id)
|
||||||
return await conversations.say(conv, text_of(await body_of(request)))
|
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(
|
@app.post(
|
||||||
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
|
"/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,
|
"id": s.id,
|
||||||
"conversation_row": s.conversation_id,
|
"conversation_row": s.conversation_id,
|
||||||
"execute_at": s.execute_at.isoformat(),
|
"execute_at": _iso(s.execute_at),
|
||||||
"text": s.text,
|
"text": s.text,
|
||||||
"delivered_at": s.delivered_at.isoformat()
|
"created_at": _iso(s.created_at),
|
||||||
if s.delivered_at
|
"delivered_at": _iso(s.delivered_at),
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
for s in await conversations.schedules(conv)
|
for s in await conversations.schedules(conv)
|
||||||
]
|
]
|
||||||
@@ -473,35 +546,156 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
@app.get("/api/usage")
|
@app.get("/api/usage")
|
||||||
async def usage(request: Request) -> dict[str, Any]:
|
async def usage(request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=SCOPE)
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
hours = float(request.query_params.get("hours", "24"))
|
q = request.query_params
|
||||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
group_by = q.get("group_by", "agent")
|
||||||
columns = (
|
if group_by not in GROUPS:
|
||||||
func.count(),
|
raise HTTPException(
|
||||||
func.coalesce(func.sum(Usage.input_tokens), 0),
|
status.HTTP_400_BAD_REQUEST, f"group_by must be one of {GROUPS}"
|
||||||
func.coalesce(func.sum(Usage.output_tokens), 0),
|
)
|
||||||
func.coalesce(func.sum(Usage.cache_read_tokens), 0),
|
until = _parse_time(q.get("until")) or datetime.now(UTC)
|
||||||
func.coalesce(func.sum(Usage.cache_creation_tokens), 0),
|
since = _parse_time(q.get("since")) or until - timedelta(
|
||||||
func.coalesce(func.sum(Usage.cost_usd), 0.0),
|
hours=float(q.get("hours", "24"))
|
||||||
)
|
)
|
||||||
async with runtime.db.session() as session:
|
rows = await _usage_rows(runtime, since, until)
|
||||||
by_agent = (
|
groups = _group_usage(rows, group_by)
|
||||||
await session.execute( # ty: ignore[deprecated]
|
if group_by == "conversation":
|
||||||
sa_select(col(Usage.agent_name), *columns)
|
titles = await _conversation_titles(runtime, [g["key"] for g in groups])
|
||||||
.where(col(Usage.ts) >= since.replace(tzinfo=None))
|
for g in groups:
|
||||||
.group_by(col(Usage.agent_name))
|
g.update(titles.get(g["key"], {}))
|
||||||
)
|
|
||||||
).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()
|
|
||||||
return {
|
return {
|
||||||
"since": since.isoformat(timespec="seconds"),
|
"since": since.isoformat(timespec="seconds"),
|
||||||
"by_agent": [_usage_row("agent", r) for r in by_agent],
|
"until": until.isoformat(timespec="seconds"),
|
||||||
"by_conversation": [_usage_row("conversation", r) for r in by_conversation],
|
"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)
|
@app.exception_handler(HTTPException)
|
||||||
@@ -515,19 +709,6 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
return app
|
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:
|
def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingResponse:
|
||||||
async def gen() -> AsyncIterator[bytes]:
|
async def gen() -> AsyncIterator[bytes]:
|
||||||
stream = runtime.bus.stream(conversation_id=conversation_id)
|
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)
|
yield sse_pack(str(event["type"]), event)
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=SSE_HEADERS)
|
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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from beaver_gateway.storage.models import (
|
|||||||
ConversationBinding,
|
ConversationBinding,
|
||||||
Delivery,
|
Delivery,
|
||||||
InjectQueueItem,
|
InjectQueueItem,
|
||||||
|
RateLimit,
|
||||||
Schedule,
|
Schedule,
|
||||||
TelegramUpdate,
|
TelegramUpdate,
|
||||||
Token,
|
Token,
|
||||||
@@ -38,6 +39,7 @@ __all__ = [
|
|||||||
"Delivery",
|
"Delivery",
|
||||||
"InjectQueueItem",
|
"InjectQueueItem",
|
||||||
"PostgresSessionStore",
|
"PostgresSessionStore",
|
||||||
|
"RateLimit",
|
||||||
"Schedule",
|
"Schedule",
|
||||||
"TelegramUpdate",
|
"TelegramUpdate",
|
||||||
"Token",
|
"Token",
|
||||||
|
|||||||
@@ -319,6 +319,39 @@ class Usage(SQLModel, table=True):
|
|||||||
cost_usd: float | None = Field(default=None)
|
cost_usd: float | None = Field(default=None)
|
||||||
duration_ms: int | None = Field(default=None)
|
duration_ms: int | None = Field(default=None)
|
||||||
num_turns: 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__ = [
|
__all__ = [
|
||||||
@@ -328,6 +361,7 @@ __all__ = [
|
|||||||
"ConversationMessage",
|
"ConversationMessage",
|
||||||
"Delivery",
|
"Delivery",
|
||||||
"InjectQueueItem",
|
"InjectQueueItem",
|
||||||
|
"RateLimit",
|
||||||
"Schedule",
|
"Schedule",
|
||||||
"TelegramUpdate",
|
"TelegramUpdate",
|
||||||
"Token",
|
"Token",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
async def test_observer_publishes_tool_results_for_the_panel(world: World) -> None:
|
||||||
seen: list[dict[str, Any]] = []
|
seen: list[dict[str, Any]] = []
|
||||||
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
|
||||||
async def collect() -> None:
|
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)
|
seen.append(event)
|
||||||
|
|
||||||
task = asyncio.create_task(collect())
|
task = asyncio.create_task(collect())
|
||||||
await asyncio.sleep(0)
|
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(
|
observe(
|
||||||
AssistantMessage(
|
AssistantMessage(
|
||||||
content=[ToolUseBlock(id="toolu_1", name="Bash", input={"command": "ls"})],
|
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"]
|
assert [e["type"] for e in seen] == ["tool", "tool.result"]
|
||||||
tool, result = seen
|
tool, result = seen
|
||||||
assert tool["tool_use_id"] == "toolu_1" and tool["parent_tool_use_id"] == "toolu_0"
|
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["tool_use_id"] == "toolu_1"
|
||||||
assert result["parent_tool_use_id"] == "toolu_0"
|
assert result["parent_tool_use_id"] == "toolu_0"
|
||||||
assert result["turn_id"] == "t1" and result["is_error"] is False
|
assert result["turn_id"] == "t1" and result["is_error"] is False
|
||||||
|
|||||||
+7
-2
@@ -8,13 +8,14 @@
|
|||||||
"@biomejs/biome": "2.5.9",
|
"@biomejs/biome": "2.5.9",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@internationalized/date": "^3.12.3",
|
"@internationalized/date": "^3.12.3",
|
||||||
"@lucide/svelte": "^1.34.0",
|
"@lucide/svelte": "^1.35.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^26.4.0",
|
||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"mode-watcher": "^1.1.0",
|
"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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|||||||
+2
-1
@@ -17,13 +17,14 @@
|
|||||||
"@biomejs/biome": "2.5.9",
|
"@biomejs/biome": "2.5.9",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@internationalized/date": "^3.12.3",
|
"@internationalized/date": "^3.12.3",
|
||||||
"@lucide/svelte": "^1.34.0",
|
"@lucide/svelte": "^1.35.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^26.4.0",
|
||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
|
|||||||
+19
-1
@@ -2,11 +2,29 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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" />
|
<meta content="scale" name="text-scale" />
|
||||||
|
<title>Beaver</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<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>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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} />
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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
Reference in New Issue
Block a user