feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph

This commit is contained in:
hh committed 2026-09-02 03:48:26 +02:00
1 parent 85b14e2c2f
commit 025f2dbc3f
60 files changed
+5071 -2347

No files matched your search

+121 -2
View File
@@ -18,6 +18,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from sqlmodel import col, select
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.prompts import assemble
from beaver_gateway.backends.transcript import prompt_count
from beaver_gateway.conversations.context import compose
from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import Kind, as_kind
from beaver_gateway.conversations.service import SEEDS, implied_title
@@ -45,6 +49,7 @@ from beaver_gateway.storage.models import (
Token,
Usage,
)
from beaver_gateway.vault.links import LinkIndex
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable, Sequence
@@ -70,6 +75,10 @@ WINDOWS: dict[str, timedelta] = {
MEMORY_MAX_DEPTH = 12
MEMORY_MAX_FILE = 2_000_000
MEMORY_MAX_ENTRIES = 5000
SEARCH_MIN = 2
GRAPH_LIMIT = 120
SEARCH_LIMIT = 20
SNIPPET_MAX = 160
class ApiFrontend(Frontend):
@@ -85,12 +94,14 @@ class ApiFrontend(Frontend):
deep_agent: str | None = None,
job_agent: str | None = None,
memory_root: Path | None = None,
vault_root: Path | None = None,
) -> None:
self.master_agent = master_agent
self.branch_agent = branch_agent
self.deep_agent = deep_agent
self.job_agent = job_agent
self.memory_root = memory_root.resolve() if memory_root is not None else None
self.vault_root = vault_root.resolve() if vault_root is not None else None
self._app: FastAPI | None = None
def agent_for(self, kind: Kind) -> str | None:
@@ -105,14 +116,22 @@ class ApiFrontend(Frontend):
if runtime.conversations is None or runtime.bus is None:
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
raise RuntimeError(msg)
self._app = build_app(runtime, memory_root=self.memory_root)
self._app = build_app(
runtime, memory_root=self.memory_root, vault_root=self.vault_root
)
def app(self) -> FastAPI | None:
return self._app
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
def build_app( # noqa: PLR0915
runtime: GatewayRuntime,
*,
memory_root: Path | None = None,
vault_root: Path | None = None,
) -> FastAPI:
app = FastAPI(title="beaver-gateway / API")
links = LinkIndex(vault_root) if vault_root is not None else None
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -218,17 +237,49 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
firsts = await conversations.first_user_texts(
cast("int", r.id) for r in rows if not r.title
)
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
public_ids = {r.id: r.external_id for r in rows}
return {
"conversations": [
{
**conversations.public(r),
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
"last_item": _queue_item(latest.get(cast("int", r.id))),
"context_tokens": contexts.get(r.external_id, 0),
"parent": public_ids.get(r.parent_id) if r.parent_id else None,
}
for r in rows
]
}
@app.get("/search")
async def search(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
query = (request.query_params.get("q") or "").strip()
if len(query) < SEARCH_MIN:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"q must be at least {SEARCH_MIN} chars"
)
limit = min(query_int(request, "limit", SEARCH_LIMIT), SEARCH_LIMIT)
rows = await conversations.search(query, limit=limit)
firsts = await conversations.first_user_texts(
cast("int", r.id) for r in rows if not r.title
)
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
files = _grep_memory(memory_root, query, limit=limit) if memory_root else []
return {
"query": query,
"conversations": [
{
**conversations.public(r),
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
"context_tokens": contexts.get(r.external_id, 0),
}
for r in rows
],
"files": files,
}
@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)
@@ -301,6 +352,45 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
conv = await conv_of(public_id)
return {"id": conv.external_id, "messages": await conversations.history(conv)}
@app.get("/conversations/{public_id}/context")
async def get_context(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
agent = runtime.agents.get(conv.agent_name)
if not isinstance(agent, ClaudeAgent):
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no claude agent behind {public_id}"
)
entries = list(await conversations.entries(conv))
for sub in await conversations.subpaths(conv):
entries.extend(await conversations.entries(conv, subpath=sub))
out = compose(
agent,
as_kind(conv.kind),
entries,
context_tokens=await conversations.context_tokens(conv),
)
out["id"] = conv.external_id
out["turns"] = prompt_count(entries)
if request.query_params.get("prompt"):
sources = agent.prompt_for(as_kind(conv.kind))
out["prompt_text"] = (
assemble(sources) if sources is not None else agent.system_prompt
)
return out
@app.get("/vault/graph")
async def vault_graph(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
if links is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"no vault root: set ApiFrontend(vault_root=...)",
)
paths = [p for p in request.query_params.getlist("path") if p]
limit = min(query_int(request, "limit", GRAPH_LIMIT), GRAPH_LIMIT)
return {"root": str(links.root), **links.neighbours(paths, limit=limit)}
@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)
@@ -993,6 +1083,35 @@ def _memory_path(root: Path, raw: str) -> Path:
return target
def _grep_memory(root: Path, query: str, *, limit: int) -> list[dict[str, Any]]:
needle = query.casefold()
hits: list[dict[str, Any]] = []
for path in sorted(root.rglob("*")):
if len(hits) >= limit:
break
if not path.is_file() or any(part.startswith(".") for part in path.parts):
continue
if path.suffix not in (".md", ".txt", ".json", ".yaml", ".yml"):
continue
try:
if path.stat().st_size > MEMORY_MAX_FILE:
continue
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
rel = str(path.relative_to(root))
if needle in rel.casefold():
hits.append({"path": rel, "line": 0, "snippet": ""})
continue
for number, line in enumerate(text.splitlines(), 1):
if needle in line.casefold():
hits.append(
{"path": rel, "line": number, "snippet": line.strip()[:SNIPPET_MAX]}
)
break
return hits
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
if depth > MEMORY_MAX_DEPTH:
return []