feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars

This commit is contained in:
hh
2026-08-29 00:49:06 +02:00
parent 540327efa1
commit 16b6bbddda
32 changed files with 691 additions and 441 deletions
+15 -30
View File
@@ -144,41 +144,27 @@ gateway = Gateway(
# Anthropic-compatible Messages endpoint. Auth comes from # Anthropic-compatible Messages endpoint. Auth comes from
# `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`). # `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`).
# #
# Behind a reverse proxy (Caddy / nginx / Cloudflare) pass # Every HTTP frontend is mounted under its own path on the one
# `public_base_url=` so the admin dashboard advertises the # gateway port (`Gateway.port`, 8000 here): `/anthropic/v1/messages`.
# outside URL instead of `host:port`. Caddy strips its own # Behind a reverse proxy set `Gateway(public_url="https://domain.com")`
# prefix and the frontend's internal paths (`/v1/messages`, # so advertised endpoints use the outside origin; the proxy just
# `/v1/models`) get appended: # forwards everything to the gateway, no prefix stripping.
# Caddy: handle_path /ai/* { reverse_proxy localhost:8000 } AnthropicMessagesFrontend(),
# Config: AnthropicMessagesFrontend(
# port=8000,
# public_base_url="https://domain.com/ai")
# Result: https://domain.com/ai/v1/messages
AnthropicMessagesFrontend(host="0.0.0.0", port=8000),
# Phase 3 — re-exposes every declared `McpServer` outside the # Phase 3 — re-exposes every declared `McpServer` outside the
# gateway with bearer auth + audit log. Each namespace lives # gateway with bearer auth + audit log. Each namespace lives at
# at `/<name>/` on this port (the port itself disambiguates # `/mcp/<name>/`; a flat bundle is published at `/mcp/all/`.
# MCP traffic — no extra `/mcp` segment in the route); a flat # Discovery page (HTML, auth-gated) at `/mcp/` with copy-pastable
# bundle is published at `/all/`. Discovery page (HTML, # Cursor / Claude Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
# auth-gated) at `/` with copy-pastable Cursor / Claude McpServerFrontend(),
# Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
#
# Same `public_base_url=` knob as above. Caddy strips its
# prefix; the frontend's `/<name>/` segment gets appended:
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
# Config: McpServerFrontend(
# port=8001,
# public_base_url="https://domain.com/mcp")
# Result: https://domain.com/mcp/<name>/ (and /mcp/all/)
McpServerFrontend(host="0.0.0.0", port=8001),
# Phase 4.3 — browser admin UI. Creds come from # Phase 4.3 — browser admin UI. Creds come from
# `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with # `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with
# `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in # `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in
# the DB), revoke them, and watch the audit log. Scope is # the DB), revoke them, and watch the audit log. Scope is
# enforced on the bearer frontends: tokens minted with scope # enforced on the bearer frontends: tokens minted with scope
# `messages` only work on `/v1/messages`; `mcp` only on # `messages` only work on `/v1/messages`; `mcp` only on
# `/mcp/<name>`; `*` works everywhere. # `/mcp/<name>`; `*` works everywhere. Served at `/admin/`; `/`
AdminFrontend(host="0.0.0.0", port=8002), # redirects there.
AdminFrontend(),
# Obsidian-vault chat frontend. Each `.md` is one conversation # Obsidian-vault chat frontend. Each `.md` is one conversation
# (User/Assistant turn pairs). The Obsidian companion plugin # (User/Assistant turn pairs). The Obsidian companion plugin
# POSTs `{filename, content?}` to `/chat` — the frontend reads # POSTs `{filename, content?}` to `/chat` — the frontend reads
@@ -191,9 +177,8 @@ gateway = Gateway(
# example boots cleanly; in real deployments mount the # example boots cleanly; in real deployments mount the
# Obsidian-sync container's vault volume to a stable path and # Obsidian-sync container's vault volume to a stable path and
# pass that instead. # pass that instead.
# Mounted at `/md` (`/md/chat`, `/md/chat/stream`).
MarkdownFrontend( MarkdownFrontend(
host="0.0.0.0",
port=8003,
# Point at the dedicated chats subdir of your real Obsidian # Point at the dedicated chats subdir of your real Obsidian
# vault — the gateway has no idea (and no need) about other # vault — the gateway has no idea (and no need) about other
# notes outside it. Path resolution / vault-escape checks # notes outside it. Path resolution / vault-escape checks
+2 -5
View File
@@ -24,11 +24,8 @@ services:
# config.py declares one, so set these (or remove the agent) # config.py declares one, so set these (or remove the agent)
# before exposing port 8000. # before exposing port 8000.
ports: ports:
# /v1/messages frontend # The one gateway port: /anthropic, /mcp, /admin, /md under it
# (change ADMIN_USER/ADMIN_PASS/SESSION_SECRET before exposing).
- "8000:8000" - "8000:8000"
# MCP server frontend
- "8001:8001"
# Admin UI (Phase 4.3) — change ADMIN_USER/ADMIN_PASS/SESSION_SECRET
- "8002:8002"
volumes: volumes:
- ./config.py:/config/config.py:ro - ./config.py:/config/config.py:ro
+17 -1
View File
@@ -47,9 +47,10 @@ from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.bus import EventBus from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.conversations import Conversations from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.gateway_tools import build_tool_server from beaver_gateway.core.gateway_tools import build_tool_server
from beaver_gateway.core.registry import AgentRegistry, McpRegistry from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry
from beaver_gateway.core.sessions import SessionPool from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.mcp.internal_app import build_internal_app from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.settings import Settings from beaver_gateway.settings import Settings
from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage
@@ -188,6 +189,7 @@ async def _async_main() -> None:
conversations=conversations, conversations=conversations,
bus=bus, bus=bus,
pool=pool, pool=pool,
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
) )
for fe in gateway.frontends: for fe in gateway.frontends:
@@ -219,10 +221,24 @@ async def _async_main() -> None:
tg.create_task(pool.reap_loop()) tg.create_task(pool.reap_loop())
if internal_app is not None: if internal_app is not None:
tg.create_task(_serve_internal_mcp(internal_app, settings=settings)) tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
tg.create_task(_serve_root(gateway))
for fe in gateway.frontends: for fe in gateway.frontends:
tg.create_task(fe.serve()) tg.create_task(fe.serve())
async def _serve_root(gateway: Gateway) -> None:
app = build_root_app(gateway.frontends)
config = uvicorn.Config(app, host=gateway.host, port=gateway.port, log_level="info")
_log.info(
"gateway on http://%s:%d - %s",
gateway.host,
gateway.port,
", ".join(fe.path for fe in gateway.frontends if fe.path)
or "no http frontends",
)
await uvicorn.Server(config).serve()
def _build_internal_mcp( def _build_internal_mcp(
mcps: list[McpServerT], *, settings: Settings mcps: list[McpServerT], *, settings: Settings
) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]: ) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]:
+8
View File
@@ -84,3 +84,11 @@ class Gateway:
frontends: list[Frontend] = field(default_factory=list) frontends: list[Frontend] = field(default_factory=list)
texts: ConversationTexts | None = None texts: ConversationTexts | None = None
"""Merge prompt and seed bodies for ``core/conversations`` (§8.2-8.3).""" """Merge prompt and seed bodies for ``core/conversations`` (§8.2-8.3)."""
host: str = "0.0.0.0" # noqa: S104
port: int = 8000
"""The one listener; every HTTP frontend is mounted under its ``path``."""
public_url: str | None = None
"""Origin the reverse proxy shows the world (``https://b.example.com``).
Advertised endpoints and MCP discovery are built on it; ``None``
derives the origin from each request."""
+30
View File
@@ -0,0 +1,30 @@
"""Where a frontend is reachable from outside, as seen by one request."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from starlette.requests import Request
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
def request_origin(request: Request, runtime: GatewayRuntime) -> str:
"""``Gateway.public_url`` if configured, else the request's own origin."""
if runtime.public_url:
return runtime.public_url
host = request.headers.get("host")
if not host:
return f"http://{request.url.hostname}:{request.url.port}"
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{scheme}://{host}"
def external_base(request: Request, runtime: GatewayRuntime) -> str:
"""Origin plus the mount path of the app handling ``request``."""
return request_origin(request, runtime) + str(request.scope.get("root_path", ""))
def frontend_url(request: Request, runtime: GatewayRuntime, fe: Frontend) -> str | None:
return request_origin(request, runtime) + fe.path if fe.path else None
+16 -52
View File
@@ -1,11 +1,11 @@
"""Admin console: serves the ``ui/`` SPA and signs the operator in. """Admin console: serves the ``ui/`` SPA and signs the operator in.
Everything the console shows comes from ``/api/*`` (``ApiFrontend``) Everything the console shows comes from ``/api/*`` (``ApiFrontend``)
with a bearer. The admin port only owns three JSON routes under with a bearer. This app, mounted at ``/admin``, owns three JSON routes
``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env, under ``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
session cookie signed with ``SESSION_SECRET``, 8 h), logout, and session cookie signed with ``SESSION_SECRET``, 8 h), logout, and
``session``, which hands a signed-in browser the process-lifetime admin ``session``, which hands a signed-in browser the process-lifetime admin
bearer plus the API origin - and the static build under ``/admin/``. bearer - and the static build under ``/admin/``.
The bearer is minted at startup, registered in the token store with The bearer is minted at startup, registered in the token store with
scope ``*`` and never persisted; a gateway restart rotates it, and the scope ``*`` and never persisted; a gateway restart rotates it, and the
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any
import itsdangerous import itsdangerous
from fastapi import FastAPI, HTTPException, Request, status from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import FileResponse, JSONResponse, Response
from beaver_gateway.core import audit from beaver_gateway.core import audit
from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.base import Frontend
@@ -49,17 +49,10 @@ __all__ = ["AdminFrontend"]
class AdminFrontend(Frontend): class AdminFrontend(Frontend):
def __init__( path = BASE
self, landing = True
*,
host: str = "0.0.0.0", # noqa: S104 def __init__(self, *, ui_dir: Path | None = None) -> None:
port: int = 8002,
public_base_url: str | None = None,
ui_dir: Path | None = None,
) -> None:
self.host = host
self.port = port
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve() self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve()
self._app: FastAPI | None = None self._app: FastAPI | None = None
@@ -78,16 +71,8 @@ class AdminFrontend(Frontend):
"admin UI build missing at %s (run `make ui-build`)", self.ui_dir "admin UI build missing at %s (run `make ui-build`)", self.ui_dir
) )
async def serve(self) -> None: def app(self) -> FastAPI | None:
import uvicorn return self._app
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
await uvicorn.Server(config).serve()
def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI: def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
@@ -100,13 +85,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
) )
index = ui_dir / "index.html" index = ui_dir / "index.html"
@app.get("/") @app.post("/auth/login")
async def root() -> Response:
return RedirectResponse(
f"{BASE}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
@app.post(f"{BASE}/auth/login")
async def login(request: Request) -> Response: async def login(request: Request) -> Response:
try: try:
data = await request.json() data = await request.json()
@@ -157,7 +136,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip) await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
return response return response
@app.post(f"{BASE}/auth/logout", status_code=status.HTTP_204_NO_CONTENT) @app.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request) -> Response: async def logout(request: Request) -> Response:
user = _current_user(request, signer) user = _current_user(request, signer)
response = Response(status_code=status.HTTP_204_NO_CONTENT) response = Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -166,15 +145,15 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
await audit.log(runtime, actor=f"admin:{user}", kind="logout") await audit.log(runtime, actor=f"admin:{user}", kind="logout")
return response return response
@app.get(f"{BASE}/auth/session") @app.get("/auth/session")
async def session(request: Request) -> dict[str, Any]: async def session(request: Request) -> dict[str, Any]:
user = _current_user(request, signer) user = _current_user(request, signer)
if user is None: if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in")
return {"user": user, "token": token, "api_base": _api_base(request, runtime)} return {"user": user, "token": token}
@app.get(BASE) @app.get("/")
@app.get(f"{BASE}/{{path:path}}") @app.get("/{path:path}")
async def spa(path: str = "") -> Response: async def spa(path: str = "") -> Response:
if path: if path:
target = (ui_dir / path).resolve() target = (ui_dir / path).resolve()
@@ -204,21 +183,6 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
return app return app
def _api_base(request: Request, runtime: GatewayRuntime) -> str | None:
for fe in runtime.frontends:
if fe.name != "api":
continue
public = getattr(fe, "public_base_url", None)
if public:
return public.removesuffix("/api")
port = getattr(fe, "port", None)
if port is None:
return None
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{scheme}://{request.url.hostname}:{port}"
return None
def _current_user( def _current_user(
request: Request, signer: itsdangerous.URLSafeTimedSerializer request: Request, signer: itsdangerous.URLSafeTimedSerializer
) -> str | None: ) -> str | None:
+4 -22
View File
@@ -58,17 +58,9 @@ class AnthropicMessagesFrontend(Frontend):
name = FRONTEND name = FRONTEND
kinds = ("deep",) kinds = ("deep",)
path = "/anthropic"
def __init__( def __init__(self) -> None:
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8000,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None self._app: FastAPI | None = None
@@ -76,18 +68,8 @@ class AnthropicMessagesFrontend(Frontend):
self._runtime = runtime self._runtime = runtime
self._app = self._build_app(runtime) self._app = self._build_app(runtime)
async def serve(self) -> None: def app(self) -> FastAPI | None:
import uvicorn return self._app
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
server = uvicorn.Server(config)
await server.serve()
def _build_app(self, runtime: GatewayRuntime) -> FastAPI: def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
app = FastAPI(title="beaver-gateway / Anthropic Messages") app = FastAPI(title="beaver-gateway / Anthropic Messages")
+35 -51
View File
@@ -39,6 +39,7 @@ from beaver_gateway.frontends._sse import (
events_with_heartbeat, events_with_heartbeat,
sse_pack, sse_pack,
) )
from beaver_gateway.frontends._urls import frontend_url
from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage import ( from beaver_gateway.storage import (
create_token, create_token,
@@ -82,22 +83,17 @@ MEMORY_MAX_ENTRIES = 5000
class ApiFrontend(Frontend): class ApiFrontend(Frontend):
name = "api" name = "api"
kinds = ("master", "branch", "deep", "job") kinds = ("master", "branch", "deep", "job")
path = "/api"
def __init__( def __init__(
self, self,
*, *,
host: str = "0.0.0.0", # noqa: S104
port: int = 8004,
public_base_url: str | None = None,
master_agent: str | None = None, master_agent: str | None = None,
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, memory_root: Path | None = None,
) -> None: ) -> None:
self.host = host
self.port = port
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self.master_agent = master_agent self.master_agent = master_agent
self.branch_agent = branch_agent self.branch_agent = branch_agent
self.deep_agent = deep_agent self.deep_agent = deep_agent
@@ -119,16 +115,8 @@ class ApiFrontend(Frontend):
raise RuntimeError(msg) raise RuntimeError(msg)
self._app = build_app(runtime, memory_root=self.memory_root) self._app = build_app(runtime, memory_root=self.memory_root)
async def serve(self) -> None: def app(self) -> FastAPI | None:
import uvicorn return self._app
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
server = uvicorn.Server(
uvicorn.Config(self._app, host=self.host, port=self.port, log_level="info")
)
await server.serve()
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915 def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
@@ -187,7 +175,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/agents") @app.get("/agents")
async def list_agents(request: Request) -> dict[str, Any]: async def list_agents(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
return { return {
@@ -209,15 +197,15 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"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), "path": fe.path,
"public_base_url": getattr(fe, "public_base_url", None), "url": frontend_url(request, runtime, fe),
} }
for fe in runtime.frontends for fe in runtime.frontends
], ],
"mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps], "mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps],
} }
@app.get("/api/conversations") @app.get("/conversations")
async def list_conversations(request: Request) -> dict[str, Any]: async def list_conversations(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
q = request.query_params q = request.query_params
@@ -237,7 +225,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
] ]
} }
@app.post("/api/conversations", status_code=status.HTTP_201_CREATED) @app.post("/conversations", status_code=status.HTTP_201_CREATED)
async def create_conversation(request: Request) -> dict[str, Any]: async def create_conversation(request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
data = await body_of(request) data = await body_of(request)
@@ -281,7 +269,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return await conversations.describe(conv) return await conversations.describe(conv)
@app.get("/api/conversations/{public_id}") @app.get("/conversations/{public_id}")
async def get_conversation(public_id: str, request: Request) -> dict[str, Any]: async def get_conversation(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -292,7 +280,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
] ]
return out return out
@app.get("/api/conversations/{public_id}/messages") @app.get("/conversations/{public_id}/messages")
async def get_messages(public_id: str, request: Request) -> dict[str, Any]: async def get_messages(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -303,13 +291,13 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"text": await conversations.read(conv, window=window), "text": await conversations.read(conv, window=window),
} }
@app.get("/api/conversations/{public_id}/history") @app.get("/conversations/{public_id}/history")
async def get_history(public_id: str, request: Request) -> dict[str, Any]: async def get_history(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
return {"id": conv.external_id, "messages": await conversations.history(conv)} return {"id": conv.external_id, "messages": await conversations.history(conv)}
@app.get("/api/conversations/{public_id}/entries") @app.get("/conversations/{public_id}/entries")
async def get_entries(public_id: str, request: Request) -> dict[str, Any]: async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -327,7 +315,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
} }
@app.post( @app.post(
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED "/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
) )
async def post_message(public_id: str, request: Request) -> dict[str, Any]: async def post_message(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
@@ -345,9 +333,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return {"id": conv.external_id, "item": item.id, "status": item.status} return {"id": conv.external_id, "item": item.id, "status": item.status}
@app.post( @app.post("/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED)
"/api/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED
)
async def post_inject(public_id: str, request: Request) -> dict[str, Any]: async def post_inject(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -373,13 +359,13 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return {"id": conv.external_id, "item": item.id, "priority": item.priority} return {"id": conv.external_id, "item": item.id, "priority": item.priority}
@app.post("/api/conversations/{public_id}/say") @app.post("/conversations/{public_id}/say")
async def post_say(public_id: str, request: Request) -> dict[str, Any]: async def post_say(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
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") @app.post("/conversations/{public_id}/answer")
async def post_answer(public_id: str, request: Request) -> dict[str, Any]: async def post_answer(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -391,9 +377,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return {"id": conv.external_id, "question_id": question_id} return {"id": conv.external_id, "question_id": question_id}
@app.post( @app.post("/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED)
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
)
async def post_branch(public_id: str, request: Request) -> dict[str, Any]: async def post_branch(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
parent = await conv_of(public_id) parent = await conv_of(public_id)
@@ -430,7 +414,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return await conversations.describe(child) return await conversations.describe(child)
@app.post("/api/conversations/{public_id}/merge") @app.post("/conversations/{public_id}/merge")
async def post_merge(public_id: str, request: Request) -> dict[str, Any]: async def post_merge(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE) token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -452,7 +436,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"text": result.text, "text": result.text,
} }
@app.post("/api/conversations/{public_id}/fork") @app.post("/conversations/{public_id}/fork")
async def post_fork(public_id: str, request: Request) -> dict[str, Any]: async def post_fork(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -472,7 +456,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"text": result.text, "text": result.text,
} }
@app.post("/api/conversations/{public_id}/bind") @app.post("/conversations/{public_id}/bind")
async def post_bind(public_id: str, request: Request) -> dict[str, Any]: async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -490,14 +474,14 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return await conversations.describe(conv) return await conversations.describe(conv)
@app.patch("/api/conversations/{public_id}/flags") @app.patch("/conversations/{public_id}/flags")
async def patch_flags(public_id: str, request: Request) -> dict[str, Any]: async def patch_flags(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
data = await body_of(request) data = await body_of(request)
return conversations.public(await conversations.set_flags(conv, data)) return conversations.public(await conversations.set_flags(conv, data))
@app.patch("/api/conversations/{public_id}") @app.patch("/conversations/{public_id}")
async def patch_conversation(public_id: str, request: Request) -> dict[str, Any]: async def patch_conversation(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
@@ -511,18 +495,18 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return conversations.public(conv) return conversations.public(conv)
@app.get("/api/conversations/{public_id}/events") @app.get("/conversations/{public_id}/events")
async def conversation_events(public_id: str, request: Request) -> Any: async def conversation_events(public_id: str, request: Request) -> Any:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id) conv = await conv_of(public_id)
return _sse(runtime, conversation_id=conv.external_id) return _sse(runtime, conversation_id=conv.external_id)
@app.get("/api/events") @app.get("/events")
async def all_events(request: Request) -> Any: async def all_events(request: Request) -> Any:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
return _sse(runtime, conversation_id=None) return _sse(runtime, conversation_id=None)
@app.get("/api/sessions") @app.get("/sessions")
async def sessions(request: Request) -> dict[str, Any]: async def sessions(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
pool = runtime.pool pool = runtime.pool
@@ -532,7 +516,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"sessions": pool.snapshot() if pool is not None else [], "sessions": pool.snapshot() if pool is not None else [],
} }
@app.get("/api/schedules") @app.get("/schedules")
async def schedules(request: Request) -> dict[str, Any]: async def schedules(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
raw = request.query_params.get("conversation") raw = request.query_params.get("conversation")
@@ -551,7 +535,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
] ]
} }
@app.get("/api/usage") @app.get("/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)
q = request.query_params q = request.query_params
@@ -578,7 +562,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"rows": groups, "rows": groups,
} }
@app.get("/api/limits") @app.get("/limits")
async def limits(request: Request) -> dict[str, Any]: async def limits(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
rows = await conversations.rate_limits(limit=200) rows = await conversations.rate_limits(limit=200)
@@ -602,13 +586,13 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]} return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
@app.get("/api/memory") @app.get("/memory")
async def memory_tree(request: Request) -> dict[str, Any]: async def memory_tree(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
root = _memory_root(memory_root) root = _memory_root(memory_root)
return {"root": str(root), "tree": _tree(root, root, depth=0)} return {"root": str(root), "tree": _tree(root, root, depth=0)}
@app.get("/api/memory/file") @app.get("/memory/file")
async def memory_file(request: Request) -> dict[str, Any]: async def memory_file(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
root = _memory_root(memory_root) root = _memory_root(memory_root)
@@ -631,7 +615,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
"content": content, "content": content,
} }
@app.get("/api/tokens") @app.get("/tokens")
async def tokens(request: Request) -> dict[str, Any]: async def tokens(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE) await require_token(request, runtime, scope=ADMIN_SCOPE)
include_revoked = request.query_params.get("include_revoked") == "1" include_revoked = request.query_params.get("include_revoked") == "1"
@@ -639,7 +623,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
rows = await list_tokens(session, include_revoked=include_revoked) rows = await list_tokens(session, include_revoked=include_revoked)
return {"tokens": [_token_public(t) for t in rows]} return {"tokens": [_token_public(t) for t in rows]}
@app.post("/api/tokens", status_code=status.HTTP_201_CREATED) @app.post("/tokens", status_code=status.HTTP_201_CREATED)
async def token_create(request: Request) -> dict[str, Any]: async def token_create(request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE) actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
data = await body_of(request) data = await body_of(request)
@@ -668,7 +652,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return {"token": _token_public(row), "plaintext": plaintext} return {"token": _token_public(row), "plaintext": plaintext}
@app.post("/api/tokens/{token_id}/revoke") @app.post("/tokens/{token_id}/revoke")
async def token_revoke(token_id: int, request: Request) -> dict[str, Any]: async def token_revoke(token_id: int, request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE) actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
async with runtime.db.session() as session: async with runtime.db.session() as session:
@@ -683,7 +667,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
) )
return {"id": token_id, "revoked": True} return {"id": token_id, "revoked": True}
@app.get("/api/audit") @app.get("/audit")
async def audit_list(request: Request) -> dict[str, Any]: async def audit_list(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE) await require_token(request, runtime, scope=ADMIN_SCOPE)
before_raw = request.query_params.get("before") before_raw = request.query_params.get("before")
+28 -9
View File
@@ -1,11 +1,13 @@
"""Frontend ABC + the runtime context handed to ``configure``. """Frontend ABC + the runtime context handed to ``configure``.
A frontend is anything that listens on a port and routes inbound traffic A frontend is anything that routes inbound traffic into the gateway: an
into the gateway. ``GatewayRuntime`` carries everything a frontend may HTTP surface mounted under its ``path`` on the single gateway port
need that isn't user-config: built registries, per-agent backends, and (``frontends/root.py``), a poller (Telegram), or both. ``GatewayRuntime``
the in-memory token store. The user's ``/config/config.py`` defines a carries everything a frontend may need that isn't user-config: built
``Gateway`` (lists); ``cli.main`` turns that into a ``GatewayRuntime`` registries, per-agent backends, and the in-memory token store. The
and hands it to each frontend's ``configure``. user's ``/config/config.py`` defines a ``Gateway`` (lists); ``cli.main``
turns that into a ``GatewayRuntime`` and hands it to each frontend's
``configure``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,6 +19,8 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Awaitable, Callable, Mapping, Sequence
from starlette.types import ASGIApp
from beaver_gateway.backends.base import Backend from beaver_gateway.backends.base import Backend
from beaver_gateway.core.auth import TokenStore from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.kinds import Kind from beaver_gateway.core.kinds import Kind
@@ -85,10 +89,20 @@ class GatewayRuntime:
conversations: Any = None conversations: Any = None
bus: Any = None bus: Any = None
pool: Any = None pool: Any = None
# External origin the reverse proxy puts in front of the gateway
# (``Gateway.public_url``); ``None`` means "derive from the request".
public_url: str | None = None
class Frontend(ABC): class Frontend(ABC):
"""Listens on a port, dispatches into the gateway. """Routes inbound traffic into the gateway.
HTTP frontends set ``path`` and return their ASGI app from ``app()``;
``cli`` mounts every such app under that path on the one gateway
port, so ``/anthropic/v1/messages`` reaches the Anthropic frontend's
``/v1/messages``. ``serve()`` is for work outside HTTP - polling,
vault mirrors - and defaults to nothing. ``landing`` marks the app
that ``/`` redirects to (the admin console).
A frontend that shows conversations declares ``name`` (the binding A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows); key) and ``kinds`` (which conversation kinds it shows);
@@ -103,12 +117,17 @@ class Frontend(ABC):
name: str = "" name: str = ""
kinds: tuple[Kind, ...] = () kinds: tuple[Kind, ...] = ()
path: str | None = None
landing: bool = False
@abstractmethod @abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ... def configure(self, runtime: GatewayRuntime) -> None: ...
@abstractmethod def app(self) -> ASGIApp | None:
async def serve(self) -> None: ... return None
async def serve(self) -> None:
return
def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002 def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002
return None return None
@@ -33,8 +33,6 @@ shape.
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib
import json import json
import logging import logging
import time import time
@@ -107,31 +105,22 @@ class MarkdownFrontend(Frontend):
name = FRONTEND name = FRONTEND
kinds = ("deep",) kinds = ("deep",)
path = "/md"
def __init__( def __init__(
self, self,
*, *,
vault_path: Path | str, vault_path: Path | str,
host: str = "0.0.0.0", # noqa: S104
port: int = 8003,
default_agent: str | None = None, default_agent: str | None = None,
log_all_chats: bool = False, log_all_chats: bool = False,
logged_subdir: str = "_logs", logged_subdir: str = "_logs",
chat_path: Callable[[str, str, Path], Path] | None = None, chat_path: Callable[[str, str, Path], Path] | None = None,
public_base_url: str | None = None,
) -> None: ) -> None:
self.vault_path = Path(vault_path).expanduser().resolve() self.vault_path = Path(vault_path).expanduser().resolve()
self.host = host
self.port = port
self.default_agent = default_agent self.default_agent = default_agent
self.log_all_chats = log_all_chats self.log_all_chats = log_all_chats
self.logged_subdir = logged_subdir self.logged_subdir = logged_subdir
self.chat_path = chat_path self.chat_path = chat_path
# External URL prefix when behind a reverse proxy — same role as
# on the other bearer frontends. Trailing slash trimmed for
# idempotent concatenation; ``None`` means "no proxy / advertise
# raw host:port".
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None self._app: FastAPI | None = None
# Files currently being processed by an in-flight ``POST /chat``. # Files currently being processed by an in-flight ``POST /chat``.
@@ -180,23 +169,11 @@ class MarkdownFrontend(Frontend):
async def materialize(self, conv: Conversation) -> ConversationBinding | None: async def materialize(self, conv: Conversation) -> ConversationBinding | None:
return await self.mirror.materialize(conv) return await self.mirror.materialize(conv)
async def serve(self) -> None: def app(self) -> FastAPI | None:
import uvicorn return self._app
if self._app is None: async def serve(self) -> None:
msg = "configure() must be called before serve()" await self.mirror.run()
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
server = uvicorn.Server(config)
mirror = asyncio.create_task(self.mirror.run())
try:
await server.serve()
finally:
mirror.cancel()
with contextlib.suppress(asyncio.CancelledError):
await mirror
# ---- app builder --------------------------------------------------- # ---- app builder ---------------------------------------------------
+16 -76
View File
@@ -37,7 +37,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -47,6 +46,7 @@ from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
from starlette.routing import Route from starlette.routing import Route
from beaver_gateway.core import audit from beaver_gateway.core import audit
from beaver_gateway.frontends._urls import external_base
from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
@@ -94,76 +94,31 @@ __all__ = ["McpServerFrontend"]
class McpServerFrontend(Frontend): class McpServerFrontend(Frontend):
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator.""" """Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
def __init__( path = "/mcp"
self,
*, def __init__(self) -> None:
host: str = "0.0.0.0", # noqa: S104
port: int = 8001,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy uses to reach this
# frontend. Internal routes mount namespaces at the port root
# (``/<ns>/`` and ``/all/``) — Caddy / nginx / Cloudflare in
# front decides what external prefix they sit under. Typical
# symmetric setup:
#
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
# config: public_base_url -> https://api.example.com/mcp
# dashboard advertises: https://api.example.com/mcp/<ns>/
#
# The frontend's ``/<ns>/`` segment gets appended verbatim. Set
# it to whatever matches your proxy. ``None`` means "advertise
# raw ``host:port`` derived from the inbound request" (dev /
# no proxy).
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None self._runtime: GatewayRuntime | None = None
self._app: Starlette | None = None self._app: Starlette | None = None
# Single shared aiohttp session, opened once when uvicorn starts
# the Starlette lifespan. Reused across every proxied request —
# MCP clients (esp. claude.ai) reconnect frequently, and a fresh
# ClientSession per call would cost a TCP handshake every time.
self._http: aiohttp.ClientSession | None = None self._http: aiohttp.ClientSession | None = None
def configure(self, runtime: GatewayRuntime) -> None: def configure(self, runtime: GatewayRuntime) -> None:
self._runtime = runtime self._runtime = runtime
self._app = self._build_app(runtime) self._app = self._build_app(runtime)
def app(self) -> Starlette | None:
return self._app
async def serve(self) -> None: async def serve(self) -> None:
import uvicorn self._http = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
) )
server = uvicorn.Server(config) try:
await server.serve() await asyncio.Event().wait()
finally:
await self._http.close()
self._http = None
def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002 def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002
# The Starlette lifespan owns the shared aiohttp session: opened
# at startup, closed at shutdown so we don't leak sockets when
# uvicorn is restarted under the same process. Starlette wants a
# plain context manager, not an async-generator function — we
# decorate to match.
@asynccontextmanager
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
self._http = aiohttp.ClientSession(
# Long sock_read because MCP tool calls can take a while
# (a claude tool over HTTP can easily stretch beyond 30s
# on a real tool).
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
)
try:
yield
finally:
if self._http is not None:
await self._http.close()
self._http = None
routes = [ routes = [
Route("/", self._discovery, methods=["GET"]), Route("/", self._discovery, methods=["GET"]),
Route("/healthz", self._healthz, methods=["GET"]), Route("/healthz", self._healthz, methods=["GET"]),
@@ -186,7 +141,7 @@ class McpServerFrontend(Frontend):
methods=["GET", "POST", "DELETE", "OPTIONS"], methods=["GET", "POST", "DELETE", "OPTIONS"],
), ),
] ]
return Starlette(routes=routes, lifespan=lifespan) return Starlette(routes=routes)
async def _healthz(self, _request: Request) -> JSONResponse: async def _healthz(self, _request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@@ -197,13 +152,7 @@ class McpServerFrontend(Frontend):
if err is not None: if err is not None:
return err return err
assert token_name is not None # noqa: S101 — narrow for ty assert token_name is not None # noqa: S101 — narrow for ty
# ``public_base_url`` wins if configured — it's the operator's base = external_base(request, runtime)
# explicit statement of "this is the URL my reverse proxy puts
# in front of me". Otherwise: use the request's own scheme+host
# so snippets work behind generic reverse proxies / tunnels;
# and fall back to the configured host:port if the client
# didn't send Host (curl --raw).
base = self.public_base_url or _external_base_url(request, self.host, self.port)
html = _render_discovery_page( html = _render_discovery_page(
base_url=base, namespaces=list(runtime.mcps), actor=token_name base_url=base, namespaces=list(runtime.mcps), actor=token_name
) )
@@ -240,7 +189,6 @@ class McpServerFrontend(Frontend):
) )
if self._http is None: if self._http is None:
# Lifespan hasn't run yet (shouldn't happen with uvicorn).
return JSONResponse({"error": "frontend not ready"}, status_code=503) return JSONResponse({"error": "frontend not ready"}, status_code=503)
return await _reverse_proxy( return await _reverse_proxy(
@@ -337,14 +285,6 @@ def _join_subpath(base_url: str, subpath: str) -> str:
return base_url return base_url
def _external_base_url(request: Request, fallback_host: str, fallback_port: int) -> str:
host = request.headers.get("host")
if host:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
return f"{scheme}://{host}"
return f"http://{fallback_host}:{fallback_port}"
async def _reverse_proxy( async def _reverse_proxy(
*, *,
client: aiohttp.ClientSession, client: aiohttp.ClientSession,
+49
View File
@@ -0,0 +1,49 @@
"""The one ASGI app the gateway listens with.
Every HTTP frontend is mounted under its ``path`` (``/anthropic``,
``/mcp``, ``/md``, ``/api``, ``/admin``); ``/healthz`` answers for the
whole process and ``/`` redirects to the landing frontend (the admin
console) when one is configured.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from starlette.applications import Starlette
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Mount, Route
if TYPE_CHECKING:
from collections.abc import Iterable
from starlette.requests import Request
from beaver_gateway.frontends.base import Frontend
__all__ = ["build_root_app"]
def build_root_app(frontends: Iterable[Frontend]) -> Starlette:
mounted = [fe for fe in frontends if fe.path and fe.app() is not None]
landing = next((fe for fe in mounted if fe.landing), None)
paths = [fe.path for fe in mounted]
async def healthz(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "frontends": paths})
async def index(_request: Request) -> JSONResponse | RedirectResponse:
if landing is not None:
return RedirectResponse(f"{landing.path}/", status_code=307)
return JSONResponse({"frontends": paths})
routes: list[Route | Mount] = [
Route("/healthz", healthz, methods=["GET"]),
Route("/", index, methods=["GET"]),
]
for fe in mounted:
app = fe.app()
assert app is not None # noqa: S101 - filtered above; narrows for ty
assert fe.path is not None # noqa: S101
routes.append(Mount(fe.path, app=app, name=fe.name or fe.path.strip("/")))
return Starlette(routes=routes)
+44 -32
View File
@@ -21,9 +21,12 @@ from test_conversations import ScriptedClient, World
from beaver_gateway.core.auth import TokenStore from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import McpRegistry from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.transcript import build_entries from beaver_gateway.core.transcript import build_entries
from beaver_gateway.frontends.admin import AdminFrontend
from beaver_gateway.frontends.admin.frontend import build_app as build_admin from beaver_gateway.frontends.admin.frontend import build_app as build_admin
from beaver_gateway.frontends.api import ApiFrontend
from beaver_gateway.frontends.api.frontend import build_app as build_api from beaver_gateway.frontends.api.frontend import build_app as build_api
from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.base import Frontend, GatewayRuntime
from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.storage.models import RateLimit, Usage from beaver_gateway.storage.models import RateLimit, Usage
TOKEN = "tok" TOKEN = "tok"
@@ -114,6 +117,11 @@ class Api:
transport=ASGITransport(app=self.app), base_url="http://api" transport=ASGITransport(app=self.app), base_url="http://api"
) )
def root(self, *frontends: Frontend) -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=build_root_app(frontends)), base_url="http://gw"
)
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
res = await self.http.get(path, params=params, headers=HEADERS) res = await self.http.get(path, params=params, headers=HEADERS)
assert res.status_code == 200, res.text assert res.status_code == 200, res.text
@@ -175,7 +183,7 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
}, },
], ],
) )
by_agent = await api.get("/api/usage", {"group_by": "agent"}) by_agent = await api.get("/usage", {"group_by": "agent"})
assert [r["agent"] for r in by_agent["rows"]] == ["a"] assert [r["agent"] for r in by_agent["rows"]] == ["a"]
assert by_agent["total"] == { assert by_agent["total"] == {
"turns": 1, "turns": 1,
@@ -187,16 +195,16 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
"web_searches": 2, "web_searches": 2,
} }
since = (datetime.now(UTC) - timedelta(days=3)).isoformat() since = (datetime.now(UTC) - timedelta(days=3)).isoformat()
by_day = await api.get("/api/usage", {"group_by": "day", "since": since}) by_day = await api.get("/usage", {"group_by": "day", "since": since})
assert len(by_day["rows"]) == 2 assert len(by_day["rows"]) == 2
assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"] assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"]
by_model = await api.get("/api/usage", {"group_by": "model", "since": since}) by_model = await api.get("/usage", {"group_by": "model", "since": since})
models = {r["model"]: r for r in by_model["rows"]} models = {r["model"]: r for r in by_model["rows"]}
assert models["claude-opus-5"]["web_searches"] == 2 assert models["claude-opus-5"]["web_searches"] == 2
assert models["claude-sonnet-5"]["output"] == 2 assert models["claude-sonnet-5"]["output"] == 2
by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since}) by_conv = await api.get("/usage", {"group_by": "conversation", "since": since})
assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"} assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"}
bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS) bad = await api.http.get("/usage", params={"group_by": "x"}, headers=HEADERS)
assert bad.status_code == 400 assert bad.status_code == 400
@@ -234,7 +242,7 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N
} }
], ],
) )
out = await api.get("/api/limits") out = await api.get("/limits")
windows = {w["window"]: w for w in out["windows"]} windows = {w["window"]: w for w in out["windows"]}
assert windows["five_hour"]["utilization"] == 0.9 assert windows["five_hour"]["utilization"] == 0.9
assert windows["five_hour"]["status"] == "allowed_warning" assert windows["five_hour"]["status"] == "allowed_warning"
@@ -252,18 +260,18 @@ async def test_memory_tree_and_file(world: World) -> None:
(root / "состояние.md").write_text("# state", encoding="utf-8") (root / "состояние.md").write_text("# state", encoding="utf-8")
(root / ".hidden").write_text("x", encoding="utf-8") (root / ".hidden").write_text("x", encoding="utf-8")
api = Api(world, memory_root=root) api = Api(world, memory_root=root)
tree = await api.get("/api/memory") tree = await api.get("/memory")
names = [n["name"] for n in tree["tree"]] names = [n["name"] for n in tree["tree"]]
assert names == ["дни", "состояние.md"] assert names == ["дни", "состояние.md"]
assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md" assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md"
file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"}) file = await api.get("/memory/file", {"path": "дни/2026-08-27.md"})
assert file["content"] == "# day" assert file["content"] == "# day"
escape = await api.http.get( escape = await api.http.get(
"/api/memory/file", params={"path": "../w.db"}, headers=HEADERS "/memory/file", params={"path": "../w.db"}, headers=HEADERS
) )
assert escape.status_code == 404 assert escape.status_code == 404
unset = Api(world) unset = Api(world)
res = await unset.http.get("/api/memory", headers=HEADERS) res = await unset.http.get("/memory", headers=HEADERS)
assert res.status_code == 404 assert res.status_code == 404
@@ -291,7 +299,7 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
ScriptedClient.hold = asyncio.Event() ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "go") await world.conversations.post(conv, "go")
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
described = await api.get(f"/api/conversations/{conv.external_id}") described = await api.get(f"/conversations/{conv.external_id}")
turn = described["turn"] turn = described["turn"]
assert turn is not None and turn["id"] == described["running_turn"] assert turn is not None and turn["id"] == described["running_turn"]
tools = {t["tool_use_id"]: t for t in turn["tools"]} tools = {t["tool_use_id"]: t for t in turn["tools"]}
@@ -299,12 +307,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
assert tools["tu_2"]["parent_tool_use_id"] == "tu_1" assert tools["tu_2"]["parent_tool_use_id"] == "tu_1"
ScriptedClient.hold.set() ScriptedClient.hold.set()
await world.settle(conv, 1) await world.settle(conv, 1)
described = await api.get(f"/api/conversations/{conv.external_id}") described = await api.get(f"/conversations/{conv.external_id}")
assert described["turn"] is None assert described["turn"] is None
limits = await api.get("/api/limits") limits = await api.get("/limits")
assert limits["windows"][0]["utilization"] == 0.8 assert limits["windows"][0]["utilization"] == 0.8
assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5 assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5
usage = await api.get("/api/usage", {"group_by": "model"}) usage = await api.get("/usage", {"group_by": "model"})
assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"} assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"}
assert usage["total"]["web_searches"] == 1 assert usage["total"]["web_searches"] == 1
conv = await world.conversations.get(conv.external_id) conv = await world.conversations.get(conv.external_id)
@@ -321,12 +329,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
model="m", model="m",
), ),
) )
history = await api.get(f"/api/conversations/{conv.external_id}/history") history = await api.get(f"/conversations/{conv.external_id}/history")
assert [m["role"] for m in history["messages"]] == ["user", "assistant"] assert [m["role"] for m in history["messages"]] == ["user", "assistant"]
entries = await api.get(f"/api/conversations/{conv.external_id}/entries") entries = await api.get(f"/conversations/{conv.external_id}/entries")
assert entries["total"] == len(entries["entries"]) == 2 assert entries["total"] == len(entries["entries"]) == 2
page = await api.get( page = await api.get(
f"/api/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0} f"/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0}
) )
assert page["offset"] == 0 and len(page["entries"]) == 1 assert page["offset"] == 0 and len(page["entries"]) == 1
@@ -335,22 +343,22 @@ async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
api = Api(world) api = Api(world)
api.store.grant("api-only", "weak", scope="api") api.store.grant("api-only", "weak", scope="api")
weak = {"Authorization": "Bearer weak"} weak = {"Authorization": "Bearer weak"}
assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403 assert (await api.http.get("/tokens", headers=weak)).status_code == 403
created = await api.http.post( created = await api.http.post(
"/api/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS "/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS
) )
assert created.status_code == 201 assert created.status_code == 201
plaintext = created.json()["plaintext"] plaintext = created.json()["plaintext"]
await api.store.invalidate() await api.store.invalidate()
identity = await api.store.verify(plaintext) identity = await api.store.verify(plaintext)
assert identity is not None and identity.scope == "mcp" assert identity is not None and identity.scope == "mcp"
listed = await api.get("/api/tokens") listed = await api.get("/tokens")
assert [t["name"] for t in listed["tokens"]] == ["cursor"] assert [t["name"] for t in listed["tokens"]] == ["cursor"]
token_id = listed["tokens"][0]["id"] token_id = listed["tokens"][0]["id"]
revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS) revoked = await api.http.post(f"/tokens/{token_id}/revoke", headers=HEADERS)
assert revoked.status_code == 200 assert revoked.status_code == 200
assert (await api.get("/api/tokens"))["tokens"] == [] assert (await api.get("/tokens"))["tokens"] == []
audit = await api.get("/api/audit") audit = await api.get("/audit")
assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"] assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"]
@@ -359,9 +367,12 @@ async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None:
ui_dir = world.root / "build" ui_dir = world.root / "build"
ui_dir.mkdir() ui_dir.mkdir()
(ui_dir / "index.html").write_text("<html>ui</html>", encoding="utf-8") (ui_dir / "index.html").write_text("<html>ui</html>", encoding="utf-8")
admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) admin = AdminFrontend(ui_dir=ui_dir)
admin._app = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) # noqa: SLF001
api.store.grant("admin-ui", "ui-bearer") api.store.grant("admin-ui", "ui-bearer")
http = AsyncClient(transport=ASGITransport(app=admin), base_url="http://admin") api_fe = ApiFrontend()
api_fe._app = api.app # noqa: SLF001
http = api.root(admin, api_fe)
assert (await http.get("/admin/auth/session")).status_code == 401 assert (await http.get("/admin/auth/session")).status_code == 401
bad = await http.post( bad = await http.post(
"/admin/auth/login", json={"username": "admin", "password": "nope"} "/admin/auth/login", json={"username": "admin", "password": "nope"}
@@ -372,16 +383,17 @@ async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None:
) )
assert ok.status_code == 200 assert ok.status_code == 200
session = (await http.get("/admin/auth/session")).json() session = (await http.get("/admin/auth/session")).json()
assert session["user"] == "admin" and session["token"] == "ui-bearer" assert session == {"user": "admin", "token": "ui-bearer"}
assert session["api_base"] is None
spa = await http.get("/admin/conversations/abc") spa = await http.get("/admin/conversations/abc")
assert spa.status_code == 200 and spa.text == "<html>ui</html>" assert spa.status_code == 200 and spa.text == "<html>ui</html>"
assert (await http.get("/")).status_code == 307 root = await http.get("/")
assert root.status_code == 307 and root.headers["location"] == "/admin/"
assert (await http.get("/admin")).status_code == 307
health = await http.get("/healthz")
assert health.json()["frontends"] == ["/admin", "/api"]
escape = await http.get("/admin/%2e%2e/pyproject.toml") escape = await http.get("/admin/%2e%2e/pyproject.toml")
assert escape.status_code == 200 and escape.text == "<html>ui</html>" assert escape.status_code == 200 and escape.text == "<html>ui</html>"
me = await api.http.get( me = await api.http.get("/agents", headers={"Authorization": "Bearer ui-bearer"})
"/api/agents", headers={"Authorization": "Bearer ui-bearer"}
)
assert me.status_code == 200 assert me.status_code == 200
assert (await http.post("/admin/auth/logout")).status_code == 204 assert (await http.post("/admin/auth/logout")).status_code == 204
assert (await http.get("/admin/auth/session")).status_code == 401 assert (await http.get("/admin/auth/session")).status_code == 401
+9 -7
View File
@@ -14,6 +14,7 @@ from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.api import ApiFrontend from beaver_gateway.frontends.api import ApiFrontend
from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.markdown import MarkdownFrontend from beaver_gateway.frontends.markdown import MarkdownFrontend
from beaver_gateway.frontends.root import build_root_app
from test_conversations import ScriptedClient, World from test_conversations import ScriptedClient, World
AUTH = {"Authorization": "Bearer tok"} AUTH = {"Authorization": "Bearer tok"}
@@ -42,10 +43,11 @@ class Stack:
for fe in frontends: for fe in frontends:
fe.configure(self.runtime) fe.configure(self.runtime)
self.mirror = asyncio.create_task(self.markdown.mirror.run()) self.mirror = asyncio.create_task(self.markdown.mirror.run())
self.root = build_root_app(frontends)
def client(self, fe) -> httpx.AsyncClient: def client(self, fe) -> httpx.AsyncClient:
return httpx.AsyncClient( return httpx.AsyncClient(
transport=httpx.ASGITransport(app=fe._app), base_url="http://t" transport=httpx.ASGITransport(app=self.root), base_url=f"http://t{fe.path}"
) )
async def close(self) -> None: async def close(self) -> None:
@@ -83,14 +85,14 @@ async def stack() -> Stack:
async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None: async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None:
async with stack.client(stack.api) as c: async with stack.client(stack.api) as c:
r = await c.post( r = await c.post(
"/api/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH "/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH
) )
assert r.status_code == 400 assert r.status_code == 400
assert "does not serve kind 'deep'" in r.json()["error"] assert "does not serve kind 'deep'" in r.json()["error"]
r = await c.post("/api/conversations", json={"kind": "job"}, headers=AUTH) r = await c.post("/conversations", json={"kind": "job"}, headers=AUTH)
assert r.status_code == 400 assert r.status_code == 400
assert "no default agent" in r.json()["error"] assert "no default agent" in r.json()["error"]
r = await c.get("/api/agents", headers=AUTH) r = await c.get("/agents", headers=AUTH)
agents = {a["name"]: a["kinds"] for a in r.json()["agents"]} agents = {a["name"]: a["kinds"] for a in r.json()["agents"]}
assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]} assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]}
homes = {f["name"]: f["default_agents"] for f in r.json()["frontends"]} homes = {f["name"]: f["default_agents"] for f in r.json()["frontends"]}
@@ -103,11 +105,11 @@ async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None:
async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None: async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
async with stack.client(stack.api) as c: async with stack.client(stack.api) as c:
r = await c.post("/api/conversations", json={"kind": "master"}, headers=AUTH) r = await c.post("/conversations", json={"kind": "master"}, headers=AUTH)
assert r.status_code == 201 and r.json()["agent"] == "a" assert r.status_code == 201 and r.json()["agent"] == "a"
master = r.json()["id"] master = r.json()["id"]
r = await c.post( r = await c.post(
"/api/conversations", "/conversations",
json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"}, json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"},
headers=AUTH, headers=AUTH,
) )
@@ -118,7 +120,7 @@ async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
rel = body["bindings"][0]["external_id"] rel = body["bindings"][0]["external_id"]
assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/") assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/")
r = await c.post( r = await c.post(
f"/api/conversations/{master}/bind", f"/conversations/{master}/bind",
json={"frontend": "markdown", "external_id": "x.md"}, json={"frontend": "markdown", "external_id": "x.md"},
headers=AUTH, headers=AUTH,
) )
+8
View File
@@ -4,6 +4,10 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "ui", "name": "ui",
"dependencies": {
"dompurify": "^3.4.14",
"marked": "^18.0.11",
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.9", "@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
@@ -230,6 +234,8 @@
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="], "devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
"dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="],
"empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -330,6 +336,8 @@
"magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="],
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+4
View File
@@ -13,6 +13,10 @@
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": {
"dompurify": "^3.4.14",
"marked": "^18.0.11"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.9", "@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
+2 -2
View File
@@ -127,9 +127,9 @@ export interface FrontendInfo {
default_agents: Record<string, string>; default_agents: Record<string, string>;
kinds: Kind[]; kinds: Kind[];
name: string; name: string;
port: number | null; path: string | null;
public_base_url: string | null;
type: string; type: string;
url: string | null;
} }
export interface AgentsResponse { export interface AgentsResponse {
+89 -27
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out"; import LogOutIcon from "@lucide/svelte/icons/log-out";
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { base } from "$app/paths"; import { base } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -9,9 +11,11 @@
import { gateway } from "$lib/gateway.svelte"; import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav"; import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
const running = $derived(gateway.running.length); const running = $derived(gateway.running.length);
const open = $derived(ui.nav);
async function signOut() { async function signOut() {
await session.logout(); await session.logout();
@@ -20,56 +24,114 @@
</script> </script>
<aside <aside
class="hidden w-52 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground sm:flex" class={cn(
"hidden shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-150 sm:flex",
open ? "w-52" : "w-12"
)}
> >
<a <a
class="flex h-12 items-center gap-2 border-b px-4 font-semibold tracking-tight" class={cn(
"flex h-12 items-center gap-2 border-b font-semibold tracking-tight",
open ? "px-4" : "justify-center"
)}
href="{base}/" href="{base}/"
title="Beaver"
> >
<span class="size-2.5 rounded-sm bg-primary"></span> <span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
Beaver {#if open}
Beaver
{/if}
</a> </a>
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2"> <nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
{#each NAV as item (item.href)} {#each NAV as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)} {@const active = isActive(page.url.pathname, base, item.href)}
<a <a
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
aria-label={item.label}
class={cn( class={cn(
"flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors", "relative flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors",
open ? "px-2.5" : "justify-center",
active active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground" : "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)} )}
href="{base}{item.href}" href="{base}{item.href}"
title={open ? undefined : item.label}
> >
<item.icon class="size-4 shrink-0 text-icon" /> <item.icon class="size-4 shrink-0 text-icon" />
<span class="flex-1">{item.label}</span> {#if open}
<span class="flex-1">{item.label}</span>
{/if}
{#if item.href === "/" && running > 0} {#if item.href === "/" && running > 0}
<span {#if open}
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs" <span
> class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
{running} >
</span> {running}
</span>
{:else}
<span
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
></span>
{/if}
{/if} {/if}
</a> </a>
{/each} {/each}
</nav> </nav>
<div class="flex flex-col gap-2 border-t p-3"> <div
<LiveDot detail={gateway.live.detail} state={gateway.live.state} /> class={cn(
<div class="flex items-center justify-between gap-2"> "flex flex-col gap-2 border-t",
<span class="truncate text-muted-foreground text-xs">{session.user}</span> open ? "p-3" : "items-center p-2"
<div class="flex items-center"> )}
<ThemeToggle /> >
<Button <LiveDot
aria-label="Sign out" detail={gateway.live.detail}
onclick={signOut} label={open}
size="icon-sm" state={gateway.live.state}
title="Sign out" />
variant="ghost" {#if open}
> <div class="flex items-center justify-between gap-2">
<LogOutIcon class="size-4" /> <span class="truncate text-muted-foreground text-xs">
</Button> {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>
</div> {:else}
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
{/if}
<Button
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
class={open ? "self-end" : ""}
onclick={() => ui.toggleNav()}
size="icon-sm"
title="{open ? 'Collapse' : 'Expand'} sidebar (⌘B)"
variant="ghost"
>
{#if open}
<PanelLeftCloseIcon class="size-4" />
{:else}
<PanelLeftOpenIcon class="size-4" />
{/if}
</Button>
</div> </div>
</aside> </aside>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PlusIcon from "@lucide/svelte/icons/plus"; import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -17,6 +18,7 @@
import { clip, fmtRelative, shortId } from "$lib/format"; import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte"; import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
let { selected = null }: { selected?: string | null } = $props(); let { selected = null }: { selected?: string | null } = $props();
@@ -162,6 +164,16 @@
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm"> <Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
</Button> </Button>
<Button
aria-label="Hide conversations"
class="hidden lg:inline-flex"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Hide conversations"
variant="ghost"
>
<PanelLeftCloseIcon class="size-4" />
</Button>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"} {#if gateway.live.state === "failed"}
+6 -10
View File
@@ -22,12 +22,10 @@
} }
function baseOf(fe: FrontendInfo): string { function baseOf(fe: FrontendInfo): string {
if (fe.public_base_url) { if (fe.url) {
return fe.public_base_url; return fe.url;
} }
return fe.port return fe.path ? `${window.location.origin}${fe.path}` : "";
? `${window.location.protocol}//${window.location.hostname}:${fe.port}`
: "";
} }
function describe(fe: FrontendInfo): Group { function describe(fe: FrontendInfo): Group {
@@ -92,14 +90,12 @@
{ {
hint: "Obsidian plugin → API origin; the admin uses it too. Routes live under /api/…", hint: "Obsidian plugin → API origin; the admin uses it too. Routes live under /api/…",
label: "api origin", label: "api origin",
url: fe.public_base_url url: base.replace(API_SUFFIX, ""),
? fe.public_base_url.replace(API_SUFFIX, "")
: base,
}, },
{ {
hint: "SSE of the whole gateway; per conversation: /api/conversations/{id}/events", hint: "SSE of the whole gateway; per conversation: /api/conversations/{id}/events",
label: "events", label: "events",
url: `${fe.public_base_url ? fe.public_base_url.replace(API_SUFFIX, "") : base}/api/events`, url: `${base}/events`,
}, },
], ],
frontend: fe, frontend: fe,
@@ -114,7 +110,7 @@
case "AdminFrontend": case "AdminFrontend":
return { return {
endpoints: [ endpoints: [
{ hint: "this console", label: "admin", url: `${base}/admin/` }, { hint: "this console", label: "admin", url: `${base}/` },
], ],
frontend: fe, frontend: fe,
note: "", note: "",
+5 -5
View File
@@ -10,6 +10,7 @@
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte"; import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte"; import { summarizeInput, toolLabel } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte"; import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte"; import TurnCard from "./turn-card.svelte";
@@ -159,14 +160,13 @@
> >
{#each parts as block, blockIndex (blockIndex)} {#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text} {#if block.type === "text" && block.text}
<p <Markdown
class={cn( class={cn(
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm", "max-w-[75ch] rounded-lg px-3 py-2",
message.role === "user" ? "bg-primary/10" : "bg-muted/50" message.role === "user" ? "bg-primary/10" : "bg-muted/50"
)} )}
> text={block.text}
{block.text} />
</p>
{:else if block.type === "tool_use"} {:else if block.type === "tool_use"}
<p class="flex items-baseline gap-2 px-1 text-xs"> <p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium">{toolLabel(block.name ?? "?")}</span> <span class="font-medium">{toolLabel(block.name ?? "?")}</span>
+22 -1
View File
@@ -15,7 +15,7 @@
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select"; import * as Select from "$lib/components/ui/select";
import { fmtRelative, shortId } from "$lib/format"; import { clip, fmtRelative, shortId } from "$lib/format";
let { let {
client, client,
@@ -43,6 +43,9 @@
let branchText = $state(""); let branchText = $state("");
let newTitle = $state(""); let newTitle = $state("");
const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible));
const SEEDS = [ const SEEDS = [
{ label: "clean - empty context", value: "clean" }, { label: "clean - empty context", value: "clean" },
{ label: "copy - copy of this history", value: "copy" }, { label: "copy - copy of this history", value: "copy" },
@@ -193,7 +196,25 @@
<span class="tabular"> <span class="tabular">
last activity {fmtRelative(info.last_activity_at)} last activity {fmtRelative(info.last_activity_at)}
</span> </span>
{#if info.queue.length > 0}
<span class="tabular font-medium text-note">
{info.queue.length}
queued
</span>
{/if}
</div> </div>
{#if windows.length > 0}
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
{#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}">
<span class="text-foreground/70">{window.frontend}</span>
{clip(window.external_id, WINDOW_MAX)}
</span>
{/each}
</div>
{/if}
</header> </header>
<Dialog.Root bind:open={branchOpen}> <Dialog.Root bind:open={branchOpen}>
+46 -63
View File
@@ -18,13 +18,11 @@
id, id,
href, href,
onOpen, onOpen,
compact = false,
}: { }: {
client: ApiClient; client: ApiClient;
id: string; id: string;
href: (id: string) => string; href: (id: string) => string;
onOpen: (id: string) => void; onOpen: (id: string) => void;
compact?: boolean;
} = $props(); } = $props();
const feed = new ConversationFeed( const feed = new ConversationFeed(
@@ -74,73 +72,58 @@
onChanged={refresh} onChanged={refresh}
{onOpen} {onOpen}
/> />
<div <div class="flex min-h-0 flex-1 flex-col">
class="grid min-h-0 flex-1 grid-cols-1 {compact <Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
? '' <Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}" <Tabs.Trigger value="chat">Chat</Tabs.Trigger>
> <Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<div class="flex min-h-0 flex-col"> <Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}> <Tabs.Trigger value="meta">Meta</Tabs.Trigger>
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6"> </Tabs.List>
<Tabs.Trigger value="chat">Chat</Tabs.Trigger> <Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<Tabs.Trigger value="activity">Activity</Tabs.Trigger> <ChatView
<Tabs.Trigger value="raw">Raw</Tabs.Trigger> {client}
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta"> conversationId={id}
Meta model={feed.model}
</Tabs.Trigger> refreshKey={historyKey}
</Tabs.List> />
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat"> </Tabs.Content>
<ChatView <Tabs.Content
{client} class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
conversationId={id} value="activity"
model={feed.model}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="activity"
>
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="raw"
>
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="meta"
>
{@render rail()}
</Tabs.Content>
</Tabs.Root>
<Composer
{client}
conversationId={id}
disabled={info.status !== "open"}
/>
</div>
{#if !compact}
<aside
class="hidden min-h-0 overflow-y-auto border-l bg-sidebar/50 p-4 lg:block"
> >
{@render rail()} <ActivityFeed
</aside> {client}
{/if} connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="raw"
>
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="meta"
>
{@render meta()}
</Tabs.Content>
</Tabs.Root>
<Composer
{client}
conversationId={id}
disabled={info.status !== "open"}
/>
</div> </div>
{/if} {/if}
</div> </div>
{#snippet rail()} {#snippet meta()}
{#if info} {#if info}
<div class="flex flex-col gap-5"> <div class="flex max-w-3xl flex-col gap-5">
<section class="flex flex-col gap-1.5"> <section class="flex flex-col gap-1.5">
<h2 <h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide" class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { renderMarkdown } from "./markdown";
let { text, class: className = "" }: { text: string; class?: string } =
$props();
const html = $derived(renderMarkdown(text));
</script>
<div class={cn("prose prose-sm max-w-none", className)}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMarkdown -->
{@html html}
</div>
+18
View File
@@ -0,0 +1,18 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
marked.use({ breaks: true, gfm: true });
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener");
}
});
// The one place chat text becomes HTML. The Obsidian panel swaps this for
// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify.
export function renderMarkdown(text: string): string {
const html = marked.parse(text, { async: false });
return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] });
}
+10 -10
View File
@@ -10,6 +10,7 @@
} from "$lib/format"; } from "$lib/format";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { Turn } from "./activity.svelte"; import type { Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import ToolNodeView from "./tool-node.svelte"; import ToolNodeView from "./tool-node.svelte";
let { turn, now }: { turn: Turn; now: number } = $props(); let { turn, now }: { turn: Turn; now: number } = $props();
@@ -65,11 +66,10 @@
{/if} {/if}
</header> </header>
{#if turn.userText} {#if turn.userText}
<p <Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5 text-sm whitespace-pre-wrap" class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
> text={turn.userText}
{turn.userText} />
</p>
{/if} {/if}
{#if turn.roots.length > 0} {#if turn.roots.length > 0}
<div class="flex flex-col"> <div class="flex flex-col">
@@ -81,13 +81,13 @@
</div> </div>
{/if} {/if}
{#each turn.says as text, index (index)} {#each turn.says as text, index (index)}
<p class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5 text-sm"> <div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
<MessageSquareIcon class="mt-0.5 size-3.5 shrink-0 text-link" /> <MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
<span class="whitespace-pre-wrap">{text}</span> <Markdown class="min-w-0 flex-1" {text} />
</p> </div>
{/each} {/each}
{#if turn.text} {#if turn.text}
<p class="mx-1 px-1 text-sm whitespace-pre-wrap">{turn.text}</p> <Markdown class="mx-1 px-1" text={turn.text} />
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0} {:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm"> <p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model… Waiting for the model…
+1 -2
View File
@@ -2,7 +2,6 @@ import { base } from "$app/paths";
import { ApiClient } from "./api/client"; import { ApiClient } from "./api/client";
interface SessionPayload { interface SessionPayload {
api_base: string | null;
token: string; token: string;
user: string; user: string;
} }
@@ -37,7 +36,7 @@ class Session {
return; return;
} }
this.user = payload.user; this.user = payload.user;
this.apiBase = payload.api_base ?? window.location.origin; this.apiBase = window.location.origin;
this.client = new ApiClient(this.apiBase, payload.token, { this.client = new ApiClient(this.apiBase, payload.token, {
onUnauthorized: () => this.refreshToken(), onUnauthorized: () => this.refreshToken(),
}); });
+44
View File
@@ -0,0 +1,44 @@
import { browser } from "$app/environment";
const NAV_KEY = "beaver.ui.nav";
const RAIL_KEY = "beaver.ui.rail";
function stored(key: string, fallback: boolean): boolean {
if (!browser) {
return fallback;
}
const raw = localStorage.getItem(key);
return raw === null ? fallback : raw === "1";
}
function store(key: string, value: boolean): void {
if (browser) {
localStorage.setItem(key, value ? "1" : "0");
}
}
// Chrome state: the section sidebar and the conversation rail. Explicit
// toggles persist; the automatic collapse on the conversations pages does
// not, so leaving them restores what the user had.
class Ui {
nav = $state(stored(NAV_KEY, true));
rail = $state(stored(RAIL_KEY, true));
setNav(open: boolean, persist = false): void {
this.nav = open;
if (persist) {
store(NAV_KEY, open);
}
}
toggleNav(): void {
this.setNav(!this.nav, true);
}
toggleRail(): void {
this.rail = !this.rail;
store(RAIL_KEY, this.rail);
}
}
export const ui = new Ui();
+29 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import "./layout.css"; import "./layout.css";
import { ModeWatcher } from "mode-watcher"; import { ModeWatcher } from "mode-watcher";
import { onMount } from "svelte"; import { onMount, untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { base } from "$app/paths"; import { base } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -12,10 +12,15 @@
import * as Tooltip from "$lib/components/ui/tooltip"; import * as Tooltip from "$lib/components/ui/tooltip";
import { gateway } from "$lib/gateway.svelte"; import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte"; import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
let { children } = $props(); let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`); const isLogin = $derived(page.url.pathname === `${base}/login`);
const inConversations = $derived(
page.url.pathname.startsWith(`${base}/conversations`)
);
let navBefore: boolean | null = null;
onMount(() => { onMount(() => {
session.check(); session.check();
@@ -38,8 +43,31 @@
return () => gateway.stop(); return () => gateway.stop();
} }
}); });
$effect(() => {
if (inConversations) {
untrack(() => {
if (navBefore === null) {
navBefore = ui.nav;
ui.setNav(false);
}
});
} else if (navBefore !== null) {
ui.setNav(navBefore);
navBefore = null;
}
});
function onKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
event.preventDefault();
ui.toggleNav();
}
}
</script> </script>
<svelte:window onkeydown={onKeydown} />
<svelte:head><link href={favicon} rel="icon"></svelte:head> <svelte:head><link href={favicon} rel="icon"></svelte:head>
<ModeWatcher /> <ModeWatcher />
+43 -3
View File
@@ -1,26 +1,66 @@
<script lang="ts"> <script lang="ts">
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { page } from "$app/state"; import { page } from "$app/state";
import ConversationList from "$lib/components/conversation-list.svelte"; import ConversationList from "$lib/components/conversation-list.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
let { children } = $props(); let { children } = $props();
const selected = $derived(page.params.id ?? null); const selected = $derived(page.params.id ?? null);
const running = $derived(gateway.running.length);
</script> </script>
<svelte:head><title>Conversations · Beaver</title></svelte:head> <svelte:head><title>Conversations · Beaver</title></svelte:head>
<div class="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[22rem_minmax(0,1fr)]"> <div
class={cn(
"grid min-h-0 flex-1 grid-cols-1",
ui.rail
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
)}
>
<div <div
class={cn( class={cn(
"min-h-0 border-r bg-sidebar/40", "min-h-0 border-r bg-sidebar/40",
selected ? "hidden lg:block" : "block" selected ? "hidden lg:block" : "block"
)} )}
> >
<ConversationList {selected} /> {#if ui.rail}
<ConversationList {selected} />
{:else}
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
<Button
aria-label="Show conversations"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Show conversations"
variant="ghost"
>
<PanelLeftOpenIcon class="size-4" />
</Button>
{#if running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running"
>
{running}
</span>
{/if}
</div>
<div class="h-full lg:hidden">
<ConversationList {selected} />
</div>
{/if}
</div> </div>
<div <div
class={cn("min-h-0", selected ? "flex flex-col" : "hidden lg:flex lg:flex-col")} class={cn(
"min-h-0",
selected ? "flex flex-col" : "hidden lg:flex lg:flex-col"
)}
> >
{@render children()} {@render children()}
</div> </div>
+57
View File
@@ -297,3 +297,60 @@
@utility hairline { @utility hairline {
border-color: color-mix(in oklab, var(--border) 100%, transparent); border-color: color-mix(in oklab, var(--border) 100%, transparent);
} }
/* chat markdown: typography plugin on the theme tokens, compact rhythm */
.prose {
--tw-prose-body: var(--foreground);
--tw-prose-headings: var(--foreground);
--tw-prose-lead: var(--muted-foreground);
--tw-prose-links: var(--link);
--tw-prose-bold: var(--foreground);
--tw-prose-counters: var(--muted-foreground);
--tw-prose-bullets: var(--muted-foreground);
--tw-prose-hr: var(--border);
--tw-prose-quotes: var(--foreground);
--tw-prose-quote-borders: var(--border);
--tw-prose-captions: var(--muted-foreground);
--tw-prose-code: var(--foreground);
--tw-prose-pre-code: var(--foreground);
--tw-prose-pre-bg: var(--muted);
--tw-prose-th-borders: var(--border);
--tw-prose-td-borders: var(--border);
font-size: 0.875rem;
line-height: 1.5;
}
.prose
:where(p, ul, ol, pre, blockquote, table):not(
:where([class~="not-prose"] *)
) {
margin-block: 0.4em;
}
.prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) {
margin-block: 0.8em 0.4em;
font-size: 1em;
font-weight: 600;
}
.prose :where(li):not(:where([class~="not-prose"] *)) {
margin-block: 0.15em;
}
.prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(code):not(:where(pre *)):not(:where([class~="not-prose"] *)) {
padding: 0.1em 0.3em;
font-weight: 500;
background: color-mix(in oklch, var(--muted) 70%, transparent);
border-radius: 0.25rem;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
padding: 0.6em 0.8em;
font-size: 0.8125rem;
border-radius: 0.375rem;
}
.prose > :first-child {
margin-top: 0;
}
.prose > :last-child {
margin-bottom: 0;
}
+3 -4
View File
@@ -3,8 +3,7 @@ import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
const adminOrigin = process.env.ADMIN_ORIGIN ?? "http://127.0.0.1:62992"; const gatewayOrigin = process.env.GATEWAY_ORIGIN ?? "http://127.0.0.1:62990";
const apiOrigin = process.env.API_ORIGIN ?? "http://127.0.0.1:62994";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -22,8 +21,8 @@ export default defineConfig({
], ],
server: { server: {
proxy: { proxy: {
"/admin/auth": adminOrigin, "/admin/auth": gatewayOrigin,
"/api": apiOrigin, "/api": gatewayOrigin,
}, },
}, },
}); });