feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
1 parent
85b14e2c2f
commit
025f2dbc3f
60 files changed
+5071
-2347
No files matched your search
@@ -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 []
|
||||
|
||||
Reference in new issue
Block a user