feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
+15
-30
@@ -144,41 +144,27 @@ gateway = Gateway(
|
||||
# Anthropic-compatible Messages endpoint. Auth comes from
|
||||
# `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`).
|
||||
#
|
||||
# Behind a reverse proxy (Caddy / nginx / Cloudflare) pass
|
||||
# `public_base_url=` so the admin dashboard advertises the
|
||||
# outside URL instead of `host:port`. Caddy strips its own
|
||||
# prefix and the frontend's internal paths (`/v1/messages`,
|
||||
# `/v1/models`) get appended:
|
||||
# Caddy: handle_path /ai/* { reverse_proxy localhost:8000 }
|
||||
# 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),
|
||||
# Every HTTP frontend is mounted under its own path on the one
|
||||
# gateway port (`Gateway.port`, 8000 here): `/anthropic/v1/messages`.
|
||||
# Behind a reverse proxy set `Gateway(public_url="https://domain.com")`
|
||||
# so advertised endpoints use the outside origin; the proxy just
|
||||
# forwards everything to the gateway, no prefix stripping.
|
||||
AnthropicMessagesFrontend(),
|
||||
# Phase 3 — re-exposes every declared `McpServer` outside the
|
||||
# gateway with bearer auth + audit log. Each namespace lives
|
||||
# at `/<name>/` on this port (the port itself disambiguates
|
||||
# MCP traffic — no extra `/mcp` segment in the route); a flat
|
||||
# bundle is published at `/all/`. Discovery page (HTML,
|
||||
# auth-gated) at `/` with copy-pastable Cursor / Claude
|
||||
# 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),
|
||||
# gateway with bearer auth + audit log. Each namespace lives at
|
||||
# `/mcp/<name>/`; a flat bundle is published at `/mcp/all/`.
|
||||
# Discovery page (HTML, auth-gated) at `/mcp/` with copy-pastable
|
||||
# Cursor / Claude Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
|
||||
McpServerFrontend(),
|
||||
# Phase 4.3 — browser admin UI. Creds come from
|
||||
# `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with
|
||||
# `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in
|
||||
# the DB), revoke them, and watch the audit log. Scope is
|
||||
# enforced on the bearer frontends: tokens minted with scope
|
||||
# `messages` only work on `/v1/messages`; `mcp` only on
|
||||
# `/mcp/<name>`; `*` works everywhere.
|
||||
AdminFrontend(host="0.0.0.0", port=8002),
|
||||
# `/mcp/<name>`; `*` works everywhere. Served at `/admin/`; `/`
|
||||
# redirects there.
|
||||
AdminFrontend(),
|
||||
# Obsidian-vault chat frontend. Each `.md` is one conversation
|
||||
# (User/Assistant turn pairs). The Obsidian companion plugin
|
||||
# POSTs `{filename, content?}` to `/chat` — the frontend reads
|
||||
@@ -191,9 +177,8 @@ gateway = Gateway(
|
||||
# example boots cleanly; in real deployments mount the
|
||||
# Obsidian-sync container's vault volume to a stable path and
|
||||
# pass that instead.
|
||||
# Mounted at `/md` (`/md/chat`, `/md/chat/stream`).
|
||||
MarkdownFrontend(
|
||||
host="0.0.0.0",
|
||||
port=8003,
|
||||
# Point at the dedicated chats subdir of your real Obsidian
|
||||
# vault — the gateway has no idea (and no need) about other
|
||||
# notes outside it. Path resolution / vault-escape checks
|
||||
|
||||
@@ -24,11 +24,8 @@ services:
|
||||
# config.py declares one, so set these (or remove the agent)
|
||||
# before exposing port 8000.
|
||||
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"
|
||||
# MCP server frontend
|
||||
- "8001:8001"
|
||||
# Admin UI (Phase 4.3) — change ADMIN_USER/ADMIN_PASS/SESSION_SECRET
|
||||
- "8002:8002"
|
||||
volumes:
|
||||
- ./config.py:/config/config.py:ro
|
||||
|
||||
@@ -47,9 +47,10 @@ from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.bus import EventBus
|
||||
from beaver_gateway.core.conversations import Conversations
|
||||
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.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.settings import Settings
|
||||
from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage
|
||||
@@ -188,6 +189,7 @@ async def _async_main() -> None:
|
||||
conversations=conversations,
|
||||
bus=bus,
|
||||
pool=pool,
|
||||
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
||||
)
|
||||
|
||||
for fe in gateway.frontends:
|
||||
@@ -219,10 +221,24 @@ async def _async_main() -> None:
|
||||
tg.create_task(pool.reap_loop())
|
||||
if internal_app is not None:
|
||||
tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
|
||||
tg.create_task(_serve_root(gateway))
|
||||
for fe in gateway.frontends:
|
||||
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(
|
||||
mcps: list[McpServerT], *, settings: Settings
|
||||
) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]:
|
||||
|
||||
@@ -84,3 +84,11 @@ class Gateway:
|
||||
frontends: list[Frontend] = field(default_factory=list)
|
||||
texts: ConversationTexts | None = None
|
||||
"""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."""
|
||||
|
||||
@@ -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
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Admin console: serves the ``ui/`` SPA and signs the operator in.
|
||||
|
||||
Everything the console shows comes from ``/api/*`` (``ApiFrontend``)
|
||||
with a bearer. The admin port only owns three JSON routes under
|
||||
``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
|
||||
with a bearer. This app, mounted at ``/admin``, owns three JSON routes
|
||||
under ``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
|
||||
session cookie signed with ``SESSION_SECRET``, 8 h), logout, and
|
||||
``session``, which hands a signed-in browser the process-lifetime admin
|
||||
bearer plus the API origin - and the static build under ``/admin/``.
|
||||
bearer - and the static build under ``/admin/``.
|
||||
|
||||
The bearer is minted at startup, registered in the token store with
|
||||
scope ``*`` and never persisted; a gateway restart rotates it, and the
|
||||
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import itsdangerous
|
||||
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.frontends.base import Frontend
|
||||
@@ -49,17 +49,10 @@ __all__ = ["AdminFrontend"]
|
||||
|
||||
|
||||
class AdminFrontend(Frontend):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8002,
|
||||
public_base_url: str | None = None,
|
||||
ui_dir: Path | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
path = BASE
|
||||
landing = True
|
||||
|
||||
def __init__(self, *, ui_dir: Path | None = None) -> None:
|
||||
self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve()
|
||||
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
|
||||
)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
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 app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Response:
|
||||
return RedirectResponse(
|
||||
f"{BASE}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
@app.post(f"{BASE}/auth/login")
|
||||
@app.post("/auth/login")
|
||||
async def login(request: Request) -> Response:
|
||||
try:
|
||||
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)
|
||||
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:
|
||||
user = _current_user(request, signer)
|
||||
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")
|
||||
return response
|
||||
|
||||
@app.get(f"{BASE}/auth/session")
|
||||
@app.get("/auth/session")
|
||||
async def session(request: Request) -> dict[str, Any]:
|
||||
user = _current_user(request, signer)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in")
|
||||
return {"user": user, "token": token, "api_base": _api_base(request, runtime)}
|
||||
return {"user": user, "token": token}
|
||||
|
||||
@app.get(BASE)
|
||||
@app.get(f"{BASE}/{{path:path}}")
|
||||
@app.get("/")
|
||||
@app.get("/{path:path}")
|
||||
async def spa(path: str = "") -> Response:
|
||||
if path:
|
||||
target = (ui_dir / path).resolve()
|
||||
@@ -204,21 +183,6 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
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(
|
||||
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||
) -> str | None:
|
||||
|
||||
@@ -58,17 +58,9 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
path = "/anthropic"
|
||||
|
||||
def __init__(
|
||||
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
|
||||
def __init__(self) -> None:
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
@@ -76,18 +68,8 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
self._runtime = runtime
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
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 app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
|
||||
app = FastAPI(title="beaver-gateway / Anthropic Messages")
|
||||
|
||||
@@ -39,6 +39,7 @@ from beaver_gateway.frontends._sse import (
|
||||
events_with_heartbeat,
|
||||
sse_pack,
|
||||
)
|
||||
from beaver_gateway.frontends._urls import frontend_url
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage import (
|
||||
create_token,
|
||||
@@ -82,22 +83,17 @@ MEMORY_MAX_ENTRIES = 5000
|
||||
class ApiFrontend(Frontend):
|
||||
name = "api"
|
||||
kinds = ("master", "branch", "deep", "job")
|
||||
path = "/api"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8004,
|
||||
public_base_url: str | None = None,
|
||||
master_agent: str | None = None,
|
||||
branch_agent: str | None = None,
|
||||
deep_agent: str | None = None,
|
||||
job_agent: str | None = None,
|
||||
memory_root: Path | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
self.master_agent = master_agent
|
||||
self.branch_agent = branch_agent
|
||||
self.deep_agent = deep_agent
|
||||
@@ -119,16 +115,8 @@ class ApiFrontend(Frontend):
|
||||
raise RuntimeError(msg)
|
||||
self._app = build_app(runtime, memory_root=self.memory_root)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
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 app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
|
||||
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]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/agents")
|
||||
@app.get("/agents")
|
||||
async def list_agents(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
return {
|
||||
@@ -209,15 +197,15 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"default_agents": {
|
||||
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
||||
},
|
||||
"port": getattr(fe, "port", None),
|
||||
"public_base_url": getattr(fe, "public_base_url", None),
|
||||
"path": fe.path,
|
||||
"url": frontend_url(request, runtime, fe),
|
||||
}
|
||||
for fe in runtime.frontends
|
||||
],
|
||||
"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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
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)
|
||||
|
||||
@app.get("/api/conversations/{public_id}")
|
||||
@app.get("/conversations/{public_id}")
|
||||
async def get_conversation(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
@@ -292,7 +280,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
]
|
||||
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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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),
|
||||
}
|
||||
|
||||
@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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
return {"id": conv.external_id, "messages": await conversations.history(conv)}
|
||||
|
||||
@app.get("/api/conversations/{public_id}/entries")
|
||||
@app.get("/conversations/{public_id}/entries")
|
||||
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
@@ -327,7 +315,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
}
|
||||
|
||||
@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]:
|
||||
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}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
@app.post("/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def post_inject(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
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}
|
||||
|
||||
@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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@app.post("/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED)
|
||||
async def post_branch(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
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)
|
||||
|
||||
@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]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
@@ -452,7 +436,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
@@ -472,7 +456,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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
|
||||
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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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
|
||||
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:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
return _sse(runtime, conversation_id=conv.external_id)
|
||||
|
||||
@app.get("/api/events")
|
||||
@app.get("/events")
|
||||
async def all_events(request: Request) -> Any:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
return _sse(runtime, conversation_id=None)
|
||||
|
||||
@app.get("/api/sessions")
|
||||
@app.get("/sessions")
|
||||
async def sessions(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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 [],
|
||||
}
|
||||
|
||||
@app.get("/api/schedules")
|
||||
@app.get("/schedules")
|
||||
async def schedules(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
q = request.query_params
|
||||
@@ -578,7 +562,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"rows": groups,
|
||||
}
|
||||
|
||||
@app.get("/api/limits")
|
||||
@app.get("/limits")
|
||||
async def limits(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
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]]}
|
||||
|
||||
@app.get("/api/memory")
|
||||
@app.get("/memory")
|
||||
async def memory_tree(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
root = _memory_root(memory_root)
|
||||
return {"root": str(root), "tree": _tree(root, root, depth=0)}
|
||||
|
||||
@app.get("/api/memory/file")
|
||||
@app.get("/memory/file")
|
||||
async def memory_file(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
root = _memory_root(memory_root)
|
||||
@@ -631,7 +615,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"content": content,
|
||||
}
|
||||
|
||||
@app.get("/api/tokens")
|
||||
@app.get("/tokens")
|
||||
async def tokens(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||
include_revoked = request.query_params.get("include_revoked") == "1"
|
||||
@@ -639,7 +623,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
rows = await list_tokens(session, include_revoked=include_revoked)
|
||||
return {"tokens": [_token_public(t) for t in rows]}
|
||||
|
||||
@app.post("/api/tokens", status_code=status.HTTP_201_CREATED)
|
||||
@app.post("/tokens", status_code=status.HTTP_201_CREATED)
|
||||
async def token_create(request: Request) -> dict[str, Any]:
|
||||
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||
data = await body_of(request)
|
||||
@@ -668,7 +652,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
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]:
|
||||
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||
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}
|
||||
|
||||
@app.get("/api/audit")
|
||||
@app.get("/audit")
|
||||
async def audit_list(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||
before_raw = request.query_params.get("before")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Frontend ABC + the runtime context handed to ``configure``.
|
||||
|
||||
A frontend is anything that listens on a port and routes inbound traffic
|
||||
into the gateway. ``GatewayRuntime`` carries everything a frontend may
|
||||
need that isn't user-config: built registries, per-agent backends, and
|
||||
the in-memory token store. The user's ``/config/config.py`` defines a
|
||||
``Gateway`` (lists); ``cli.main`` turns that into a ``GatewayRuntime``
|
||||
and hands it to each frontend's ``configure``.
|
||||
A frontend is anything that routes inbound traffic into the gateway: an
|
||||
HTTP surface mounted under its ``path`` on the single gateway port
|
||||
(``frontends/root.py``), a poller (Telegram), or both. ``GatewayRuntime``
|
||||
carries everything a frontend may need that isn't user-config: built
|
||||
registries, per-agent backends, and the in-memory token store. The
|
||||
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
|
||||
@@ -17,6 +19,8 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from beaver_gateway.backends.base import Backend
|
||||
from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.kinds import Kind
|
||||
@@ -85,10 +89,20 @@ class GatewayRuntime:
|
||||
conversations: Any = None
|
||||
bus: 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):
|
||||
"""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
|
||||
key) and ``kinds`` (which conversation kinds it shows);
|
||||
@@ -103,12 +117,17 @@ class Frontend(ABC):
|
||||
|
||||
name: str = ""
|
||||
kinds: tuple[Kind, ...] = ()
|
||||
path: str | None = None
|
||||
landing: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def serve(self) -> None: ...
|
||||
def app(self) -> ASGIApp | None:
|
||||
return None
|
||||
|
||||
async def serve(self) -> None:
|
||||
return
|
||||
|
||||
def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002
|
||||
return None
|
||||
|
||||
@@ -33,8 +33,6 @@ shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -107,31 +105,22 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
path = "/md"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vault_path: Path | str,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8003,
|
||||
default_agent: str | None = None,
|
||||
log_all_chats: bool = False,
|
||||
logged_subdir: str = "_logs",
|
||||
chat_path: Callable[[str, str, Path], Path] | None = None,
|
||||
public_base_url: str | None = None,
|
||||
) -> None:
|
||||
self.vault_path = Path(vault_path).expanduser().resolve()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.default_agent = default_agent
|
||||
self.log_all_chats = log_all_chats
|
||||
self.logged_subdir = logged_subdir
|
||||
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._app: FastAPI | None = None
|
||||
# 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:
|
||||
return await self.mirror.materialize(conv)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
def app(self) -> FastAPI | None:
|
||||
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)
|
||||
mirror = asyncio.create_task(self.mirror.run())
|
||||
try:
|
||||
await server.serve()
|
||||
finally:
|
||||
mirror.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await mirror
|
||||
async def serve(self) -> None:
|
||||
await self.mirror.run()
|
||||
|
||||
# ---- app builder ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -47,6 +46,7 @@ from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.frontends._urls import external_base
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
|
||||
|
||||
@@ -94,76 +94,31 @@ __all__ = ["McpServerFrontend"]
|
||||
class McpServerFrontend(Frontend):
|
||||
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
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
|
||||
path = "/mcp"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._runtime: GatewayRuntime | 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
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
def app(self) -> Starlette | None:
|
||||
return self._app
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
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"
|
||||
self._http = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await self._http.close()
|
||||
self._http = None
|
||||
|
||||
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 = [
|
||||
Route("/", self._discovery, methods=["GET"]),
|
||||
Route("/healthz", self._healthz, methods=["GET"]),
|
||||
@@ -186,7 +141,7 @@ class McpServerFrontend(Frontend):
|
||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
),
|
||||
]
|
||||
return Starlette(routes=routes, lifespan=lifespan)
|
||||
return Starlette(routes=routes)
|
||||
|
||||
async def _healthz(self, _request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
@@ -197,13 +152,7 @@ class McpServerFrontend(Frontend):
|
||||
if err is not None:
|
||||
return err
|
||||
assert token_name is not None # noqa: S101 — narrow for ty
|
||||
# ``public_base_url`` wins if configured — it's the operator's
|
||||
# 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)
|
||||
base = external_base(request, runtime)
|
||||
html = _render_discovery_page(
|
||||
base_url=base, namespaces=list(runtime.mcps), actor=token_name
|
||||
)
|
||||
@@ -240,7 +189,6 @@ class McpServerFrontend(Frontend):
|
||||
)
|
||||
|
||||
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 await _reverse_proxy(
|
||||
@@ -337,14 +285,6 @@ def _join_subpath(base_url: str, subpath: str) -> str:
|
||||
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(
|
||||
*,
|
||||
client: aiohttp.ClientSession,
|
||||
|
||||
@@ -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
@@ -21,9 +21,12 @@ from test_conversations import ScriptedClient, World
|
||||
from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.registry import McpRegistry
|
||||
from beaver_gateway.core.transcript import build_entries
|
||||
from beaver_gateway.frontends.admin import AdminFrontend
|
||||
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.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
|
||||
|
||||
TOKEN = "tok"
|
||||
@@ -114,6 +117,11 @@ class 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:
|
||||
res = await self.http.get(path, params=params, headers=HEADERS)
|
||||
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 by_agent["total"] == {
|
||||
"turns": 1,
|
||||
@@ -187,16 +195,16 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
|
||||
"web_searches": 2,
|
||||
}
|
||||
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 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"]}
|
||||
assert models["claude-opus-5"]["web_searches"] == 2
|
||||
assert models["claude-sonnet-5"]["output"] == 2
|
||||
by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since})
|
||||
by_conv = await api.get("/usage", {"group_by": "conversation", "since": since})
|
||||
assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"}
|
||||
bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS)
|
||||
bad = await api.http.get("/usage", params={"group_by": "x"}, headers=HEADERS)
|
||||
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"]}
|
||||
assert windows["five_hour"]["utilization"] == 0.9
|
||||
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 / ".hidden").write_text("x", encoding="utf-8")
|
||||
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"]]
|
||||
assert names == ["дни", "состояние.md"]
|
||||
assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md"
|
||||
file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"})
|
||||
file = await api.get("/memory/file", {"path": "дни/2026-08-27.md"})
|
||||
assert file["content"] == "# day"
|
||||
escape = await api.http.get(
|
||||
"/api/memory/file", params={"path": "../w.db"}, headers=HEADERS
|
||||
"/memory/file", params={"path": "../w.db"}, headers=HEADERS
|
||||
)
|
||||
assert escape.status_code == 404
|
||||
unset = Api(world)
|
||||
res = await unset.http.get("/api/memory", headers=HEADERS)
|
||||
res = await unset.http.get("/memory", headers=HEADERS)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@@ -291,7 +299,7 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
|
||||
ScriptedClient.hold = asyncio.Event()
|
||||
await world.conversations.post(conv, "go")
|
||||
await asyncio.sleep(0.3)
|
||||
described = await api.get(f"/api/conversations/{conv.external_id}")
|
||||
described = await api.get(f"/conversations/{conv.external_id}")
|
||||
turn = described["turn"]
|
||||
assert turn is not None and turn["id"] == described["running_turn"]
|
||||
tools = {t["tool_use_id"]: t for t in turn["tools"]}
|
||||
@@ -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"
|
||||
ScriptedClient.hold.set()
|
||||
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
|
||||
limits = await api.get("/api/limits")
|
||||
limits = await api.get("/limits")
|
||||
assert limits["windows"][0]["utilization"] == 0.8
|
||||
assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5
|
||||
usage = await api.get("/api/usage", {"group_by": "model"})
|
||||
usage = await api.get("/usage", {"group_by": "model"})
|
||||
assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"}
|
||||
assert usage["total"]["web_searches"] == 1
|
||||
conv = await world.conversations.get(conv.external_id)
|
||||
@@ -321,12 +329,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
|
||||
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"]
|
||||
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
|
||||
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
|
||||
|
||||
@@ -335,22 +343,22 @@ async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
|
||||
api = Api(world)
|
||||
api.store.grant("api-only", "weak", scope="api")
|
||||
weak = {"Authorization": "Bearer weak"}
|
||||
assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403
|
||||
assert (await api.http.get("/tokens", headers=weak)).status_code == 403
|
||||
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
|
||||
plaintext = created.json()["plaintext"]
|
||||
await api.store.invalidate()
|
||||
identity = await api.store.verify(plaintext)
|
||||
assert identity is not None and identity.scope == "mcp"
|
||||
listed = await api.get("/api/tokens")
|
||||
listed = await api.get("/tokens")
|
||||
assert [t["name"] for t in listed["tokens"]] == ["cursor"]
|
||||
token_id = listed["tokens"][0]["id"]
|
||||
revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS)
|
||||
revoked = await api.http.post(f"/tokens/{token_id}/revoke", headers=HEADERS)
|
||||
assert revoked.status_code == 200
|
||||
assert (await api.get("/api/tokens"))["tokens"] == []
|
||||
audit = await api.get("/api/audit")
|
||||
assert (await api.get("/tokens"))["tokens"] == []
|
||||
audit = await api.get("/audit")
|
||||
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.mkdir()
|
||||
(ui_dir / "index.html").write_text("<html>ui</html>", encoding="utf-8")
|
||||
admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir)
|
||||
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")
|
||||
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
|
||||
bad = await http.post(
|
||||
"/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
|
||||
session = (await http.get("/admin/auth/session")).json()
|
||||
assert session["user"] == "admin" and session["token"] == "ui-bearer"
|
||||
assert session["api_base"] is None
|
||||
assert session == {"user": "admin", "token": "ui-bearer"}
|
||||
spa = await http.get("/admin/conversations/abc")
|
||||
assert spa.status_code == 200 and spa.text == "<html>ui</html>"
|
||||
assert (await http.get("/")).status_code == 307
|
||||
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")
|
||||
assert escape.status_code == 200 and escape.text == "<html>ui</html>"
|
||||
me = await api.http.get(
|
||||
"/api/agents", headers={"Authorization": "Bearer ui-bearer"}
|
||||
)
|
||||
me = await api.http.get("/agents", headers={"Authorization": "Bearer ui-bearer"})
|
||||
assert me.status_code == 200
|
||||
assert (await http.post("/admin/auth/logout")).status_code == 204
|
||||
assert (await http.get("/admin/auth/session")).status_code == 401
|
||||
|
||||
@@ -14,6 +14,7 @@ from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||
from beaver_gateway.frontends.api import ApiFrontend
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.frontends.markdown import MarkdownFrontend
|
||||
from beaver_gateway.frontends.root import build_root_app
|
||||
from test_conversations import ScriptedClient, World
|
||||
|
||||
AUTH = {"Authorization": "Bearer tok"}
|
||||
@@ -42,10 +43,11 @@ class Stack:
|
||||
for fe in frontends:
|
||||
fe.configure(self.runtime)
|
||||
self.mirror = asyncio.create_task(self.markdown.mirror.run())
|
||||
self.root = build_root_app(frontends)
|
||||
|
||||
def client(self, fe) -> 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:
|
||||
@@ -83,14 +85,14 @@ async def stack() -> Stack:
|
||||
async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None:
|
||||
async with stack.client(stack.api) as c:
|
||||
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 "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 "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"]}
|
||||
assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]}
|
||||
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 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"
|
||||
master = r.json()["id"]
|
||||
r = await c.post(
|
||||
"/api/conversations",
|
||||
"/conversations",
|
||||
json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"},
|
||||
headers=AUTH,
|
||||
)
|
||||
@@ -118,7 +120,7 @@ async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
|
||||
rel = body["bindings"][0]["external_id"]
|
||||
assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/")
|
||||
r = await c.post(
|
||||
f"/api/conversations/{master}/bind",
|
||||
f"/conversations/{master}/bind",
|
||||
json={"frontend": "markdown", "external_id": "x.md"},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.14",
|
||||
"marked": "^18.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.9",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
@@ -230,6 +234,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.14",
|
||||
"marked": "^18.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.9",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
|
||||
@@ -127,9 +127,9 @@ export interface FrontendInfo {
|
||||
default_agents: Record<string, string>;
|
||||
kinds: Kind[];
|
||||
name: string;
|
||||
port: number | null;
|
||||
public_base_url: string | null;
|
||||
path: string | null;
|
||||
type: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface AgentsResponse {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { base } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
@@ -9,9 +11,11 @@
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { isActive, NAV } from "$lib/nav";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const running = $derived(gateway.running.length);
|
||||
const open = $derived(ui.nav);
|
||||
|
||||
async function signOut() {
|
||||
await session.logout();
|
||||
@@ -20,56 +24,114 @@
|
||||
</script>
|
||||
|
||||
<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
|
||||
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}/"
|
||||
title="Beaver"
|
||||
>
|
||||
<span class="size-2.5 rounded-sm bg-primary"></span>
|
||||
Beaver
|
||||
<span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
|
||||
{#if open}
|
||||
Beaver
|
||||
{/if}
|
||||
</a>
|
||||
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
|
||||
{#each NAV as item (item.href)}
|
||||
{@const active = isActive(page.url.pathname, base, item.href)}
|
||||
<a
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={item.label}
|
||||
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
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
|
||||
)}
|
||||
href="{base}{item.href}"
|
||||
title={open ? undefined : item.label}
|
||||
>
|
||||
<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}
|
||||
<span
|
||||
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{#if open}
|
||||
<span
|
||||
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
|
||||
></span>
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="flex flex-col gap-2 border-t p-3">
|
||||
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-muted-foreground text-xs">{session.user}</span>
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
aria-label="Sign out"
|
||||
onclick={signOut}
|
||||
size="icon-sm"
|
||||
title="Sign out"
|
||||
variant="ghost"
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
</Button>
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col gap-2 border-t",
|
||||
open ? "p-3" : "items-center p-2"
|
||||
)}
|
||||
>
|
||||
<LiveDot
|
||||
detail={gateway.live.detail}
|
||||
label={open}
|
||||
state={gateway.live.state}
|
||||
/>
|
||||
{#if open}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{session.user}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
aria-label="Sign out"
|
||||
onclick={signOut}
|
||||
size="icon-sm"
|
||||
title="Sign out"
|
||||
variant="ghost"
|
||||
>
|
||||
<LogOutIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{: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>
|
||||
</aside>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -17,6 +18,7 @@
|
||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { selected = null }: { selected?: string | null } = $props();
|
||||
@@ -162,6 +164,16 @@
|
||||
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
|
||||
<PlusIcon class="size-4" />
|
||||
</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 class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#if gateway.live.state === "failed"}
|
||||
|
||||
@@ -22,12 +22,10 @@
|
||||
}
|
||||
|
||||
function baseOf(fe: FrontendInfo): string {
|
||||
if (fe.public_base_url) {
|
||||
return fe.public_base_url;
|
||||
if (fe.url) {
|
||||
return fe.url;
|
||||
}
|
||||
return fe.port
|
||||
? `${window.location.protocol}//${window.location.hostname}:${fe.port}`
|
||||
: "";
|
||||
return fe.path ? `${window.location.origin}${fe.path}` : "";
|
||||
}
|
||||
|
||||
function describe(fe: FrontendInfo): Group {
|
||||
@@ -92,14 +90,12 @@
|
||||
{
|
||||
hint: "Obsidian plugin → API origin; the admin uses it too. Routes live under /api/…",
|
||||
label: "api origin",
|
||||
url: fe.public_base_url
|
||||
? fe.public_base_url.replace(API_SUFFIX, "")
|
||||
: base,
|
||||
url: base.replace(API_SUFFIX, ""),
|
||||
},
|
||||
{
|
||||
hint: "SSE of the whole gateway; per conversation: /api/conversations/{id}/events",
|
||||
label: "events",
|
||||
url: `${fe.public_base_url ? fe.public_base_url.replace(API_SUFFIX, "") : base}/api/events`,
|
||||
url: `${base}/events`,
|
||||
},
|
||||
],
|
||||
frontend: fe,
|
||||
@@ -114,7 +110,7 @@
|
||||
case "AdminFrontend":
|
||||
return {
|
||||
endpoints: [
|
||||
{ hint: "this console", label: "admin", url: `${base}/admin/` },
|
||||
{ hint: "this console", label: "admin", url: `${base}/` },
|
||||
],
|
||||
frontend: fe,
|
||||
note: "",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import QuestionCard from "./question-card.svelte";
|
||||
import TurnCard from "./turn-card.svelte";
|
||||
|
||||
@@ -159,14 +160,13 @@
|
||||
>
|
||||
{#each parts as block, blockIndex (blockIndex)}
|
||||
{#if block.type === "text" && block.text}
|
||||
<p
|
||||
<Markdown
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
text={block.text}
|
||||
/>
|
||||
{:else if block.type === "tool_use"}
|
||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
||||
<span class="font-medium">{toolLabel(block.name ?? "?")}</span>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { fmtRelative, shortId } from "$lib/format";
|
||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -43,6 +43,9 @@
|
||||
let branchText = $state("");
|
||||
let newTitle = $state("");
|
||||
|
||||
const WINDOW_MAX = 56;
|
||||
const windows = $derived(info.bindings.filter((b) => b.visible));
|
||||
|
||||
const SEEDS = [
|
||||
{ label: "clean - empty context", value: "clean" },
|
||||
{ label: "copy - copy of this history", value: "copy" },
|
||||
@@ -193,7 +196,25 @@
|
||||
<span class="tabular">
|
||||
last activity {fmtRelative(info.last_activity_at)}
|
||||
</span>
|
||||
{#if info.queue.length > 0}
|
||||
<span class="tabular font-medium text-note">
|
||||
{info.queue.length}
|
||||
queued
|
||||
</span>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
<Dialog.Root bind:open={branchOpen}>
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
id,
|
||||
href,
|
||||
onOpen,
|
||||
compact = false,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
href: (id: string) => string;
|
||||
onOpen: (id: string) => void;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
const feed = new ConversationFeed(
|
||||
@@ -74,73 +72,58 @@
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
/>
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 {compact
|
||||
? ''
|
||||
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}"
|
||||
>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<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">
|
||||
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta">
|
||||
Meta
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||
<ChatView
|
||||
{client}
|
||||
conversationId={id}
|
||||
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"
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<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">
|
||||
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||
<ChatView
|
||||
{client}
|
||||
conversationId={id}
|
||||
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"
|
||||
>
|
||||
{@render rail()}
|
||||
</aside>
|
||||
{/if}
|
||||
<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 meta()}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Composer
|
||||
{client}
|
||||
conversationId={id}
|
||||
disabled={info.status !== "open"}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet rail()}
|
||||
{#snippet meta()}
|
||||
{#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">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
|
||||
@@ -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>
|
||||
@@ -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,6 +10,7 @@
|
||||
} from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { Turn } from "./activity.svelte";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import ToolNodeView from "./tool-node.svelte";
|
||||
|
||||
let { turn, now }: { turn: Turn; now: number } = $props();
|
||||
@@ -65,11 +66,10 @@
|
||||
{/if}
|
||||
</header>
|
||||
{#if turn.userText}
|
||||
<p
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5 text-sm whitespace-pre-wrap"
|
||||
>
|
||||
{turn.userText}
|
||||
</p>
|
||||
<Markdown
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
|
||||
text={turn.userText}
|
||||
/>
|
||||
{/if}
|
||||
{#if turn.roots.length > 0}
|
||||
<div class="flex flex-col">
|
||||
@@ -81,13 +81,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#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">
|
||||
<MessageSquareIcon class="mt-0.5 size-3.5 shrink-0 text-link" />
|
||||
<span class="whitespace-pre-wrap">{text}</span>
|
||||
</p>
|
||||
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
|
||||
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
|
||||
<Markdown class="min-w-0 flex-1" {text} />
|
||||
</div>
|
||||
{/each}
|
||||
{#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}
|
||||
<p class="mx-1 px-1 text-muted-foreground text-sm">
|
||||
Waiting for the model…
|
||||
|
||||
@@ -2,7 +2,6 @@ import { base } from "$app/paths";
|
||||
import { ApiClient } from "./api/client";
|
||||
|
||||
interface SessionPayload {
|
||||
api_base: string | null;
|
||||
token: string;
|
||||
user: string;
|
||||
}
|
||||
@@ -37,7 +36,7 @@ class Session {
|
||||
return;
|
||||
}
|
||||
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, {
|
||||
onUnauthorized: () => this.refreshToken(),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { base } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
@@ -12,10 +12,15 @@
|
||||
import * as Tooltip from "$lib/components/ui/tooltip";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
||||
const inConversations = $derived(
|
||||
page.url.pathname.startsWith(`${base}/conversations`)
|
||||
);
|
||||
let navBefore: boolean | null = null;
|
||||
|
||||
onMount(() => {
|
||||
session.check();
|
||||
@@ -38,8 +43,31 @@
|
||||
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>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<svelte:head><link href={favicon} rel="icon"></svelte:head>
|
||||
|
||||
<ModeWatcher />
|
||||
|
||||
@@ -1,26 +1,66 @@
|
||||
<script lang="ts">
|
||||
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
|
||||
import { page } from "$app/state";
|
||||
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";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const selected = $derived(page.params.id ?? null);
|
||||
const running = $derived(gateway.running.length);
|
||||
</script>
|
||||
|
||||
<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
|
||||
class={cn(
|
||||
"min-h-0 border-r bg-sidebar/40",
|
||||
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
|
||||
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()}
|
||||
</div>
|
||||
|
||||
@@ -297,3 +297,60 @@
|
||||
@utility hairline {
|
||||
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
@@ -3,8 +3,7 @@ import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const adminOrigin = process.env.ADMIN_ORIGIN ?? "http://127.0.0.1:62992";
|
||||
const apiOrigin = process.env.API_ORIGIN ?? "http://127.0.0.1:62994";
|
||||
const gatewayOrigin = process.env.GATEWAY_ORIGIN ?? "http://127.0.0.1:62990";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -22,8 +21,8 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/admin/auth": adminOrigin,
|
||||
"/api": apiOrigin,
|
||||
"/admin/auth": gatewayOrigin,
|
||||
"/api": gatewayOrigin,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user