feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot

This commit is contained in:
hh
2026-08-28 22:14:15 +02:00
parent aaaf459e6d
commit 00618ab7ad
158 changed files with 8459 additions and 2531 deletions
+121 -12
View File
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import (
AssistantMessage,
RateLimitEvent,
ResultMessage,
StreamEvent,
ToolResultBlock,
@@ -43,6 +44,7 @@ from beaver_gateway.core.transcript import (
messages_from_entries,
render_messages,
strip_tool_entries,
text_of,
window_entries,
)
from beaver_gateway.core.turn_capture import TurnCapture
@@ -51,6 +53,7 @@ from beaver_gateway.storage.models import (
Conversation,
ConversationBinding,
InjectQueueItem,
RateLimit,
Schedule,
)
@@ -133,6 +136,23 @@ class _Runner:
wake: asyncio.Event = field(default_factory=asyncio.Event)
task: asyncio.Task[None] | None = None
turn_id: str | None = None
origin: str | None = None
text: str | None = None
started_at: datetime | None = None
tools: dict[str, dict[str, Any]] = field(default_factory=dict)
"""Tool calls of the running turn, in order; ``describe`` hands them to a
panel that subscribed mid-turn."""
def snapshot(self) -> dict[str, Any] | None:
if self.turn_id is None:
return None
return {
"id": self.turn_id,
"origin": self.origin,
"text": self.text,
"started_at": _iso(self.started_at),
"tools": list(self.tools.values()),
}
@dataclass(frozen=True, slots=True)
@@ -442,8 +462,21 @@ class Conversations:
live = self._pool.get(conv.external_id)
out["live"] = live is not None
out["busy"] = live.busy if live is not None else False
runner = self._runners.get(cast("int", conv.id))
out["turn"] = runner.snapshot() if runner is not None else None
pending = self.pending_question(conv.external_id)
out["question"] = (
{"id": pending[0], "questions": pending[1]} if pending else None
)
return out
async def rate_limits(self, *, limit: int = 100) -> list[RateLimit]:
async with self._db.session() as session:
result = await session.exec(
select(RateLimit).order_by(col(RateLimit.id).desc()).limit(limit)
)
return list(result.all())
# ---- routing -------------------------------------------------------
@property
@@ -593,14 +626,21 @@ class Conversations:
return ForkResult(conversation=child, text=text, capture=capture)
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
return render_messages(await self.history(conv), window=window)
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
return messages_from_entries(cast("Any", await self.entries(conv)))
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None:
return ""
entries = await self._store.load(cast("Any", self._store_key(conv)))
if not entries:
return ""
return render_messages(
messages_from_entries(cast("Any", entries)), window=window
)
return []
key = {**self._store_key(conv), "subpath": subpath}
return list(await self._store.load(cast("Any", key)) or [])
async def subpaths(self, conv: Conversation) -> list[str]:
if conv.session_id is None:
return []
return list(await self._store.list_subkeys(cast("Any", self._store_key(conv))))
async def inject(
self,
@@ -816,6 +856,10 @@ class Conversations:
resume = session_id if session_id is not None else conv.session_id
async with runner.lock:
runner.turn_id = turn_id
runner.origin = origin
runner.text = _prompt_preview(messages)
runner.started_at = datetime.now(UTC)
runner.tools = {}
await self._mark_running(conv, turn_id)
self._bus.publish(
"turn.start",
@@ -823,6 +867,7 @@ class Conversations:
turn_id=turn_id,
origin=origin,
item_origin=item_origin,
text=runner.text,
)
stop = "error"
cut = False
@@ -836,7 +881,7 @@ class Conversations:
kind=conv.kind,
pinned=conv.kind == "master",
tools=tools,
observer=self._observer(conv.external_id, turn_id, origin),
observer=self._observer(conv, runner, turn_id, origin),
turn_id=turn_id,
)
async for event in events:
@@ -1117,11 +1162,15 @@ class Conversations:
)
def _observer(
self, conversation_id: str, turn_id: str, origin: str
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str
) -> Callable[[Any], None]:
conversation_id = conv.external_id
def observe(message: Any) -> None:
parent = getattr(message, "parent_tool_use_id", None)
if isinstance(message, StreamEvent):
if isinstance(message, RateLimitEvent):
self._observe_rate_limit(conv, message)
elif isinstance(message, StreamEvent):
self._bus.publish(
"stream",
conversation_id=conversation_id,
@@ -1133,7 +1182,7 @@ class Conversations:
elif isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
self._bus.publish(
event = self._bus.publish(
"tool",
conversation_id=conversation_id,
turn_id=turn_id,
@@ -1143,11 +1192,21 @@ class Conversations:
name=block.name,
input=block.input,
)
runner.tools[block.id] = {
"tool_use_id": block.id,
"name": block.name,
"input": block.input,
"parent_tool_use_id": parent,
"started_at": event["ts"],
"ended_at": None,
"is_error": None,
"content": None,
}
elif isinstance(message, UserMessage):
blocks = message.content if isinstance(message.content, list) else ()
for block in blocks:
if isinstance(block, ToolResultBlock):
self._bus.publish(
event = self._bus.publish(
"tool.result",
conversation_id=conversation_id,
turn_id=turn_id,
@@ -1157,6 +1216,11 @@ class Conversations:
is_error=bool(block.is_error),
content=_result_preview(block.content),
)
node = runner.tools.get(block.tool_use_id)
if node is not None:
node["ended_at"] = event["ts"]
node["is_error"] = event["is_error"]
node["content"] = event["content"]
elif isinstance(message, ResultMessage) and parent is None:
self._bus.publish(
"result",
@@ -1170,6 +1234,40 @@ class Conversations:
return observe
def _observe_rate_limit(self, conv: Conversation, message: RateLimitEvent) -> None:
info = message.rate_limit_info
row = RateLimit(
window=info.rate_limit_type or "unknown",
status=info.status,
utilization=info.utilization,
resets_at=_from_unix(info.resets_at),
overage_status=info.overage_status,
overage_resets_at=_from_unix(info.overage_resets_at),
agent_name=conv.agent_name,
session_id=message.session_id,
raw=dict(info.raw),
)
self._bus.publish(
"rate_limit",
conversation_id=conv.external_id,
window=row.window,
status=row.status,
utilization=row.utilization,
resets_at=_iso(row.resets_at),
overage_status=row.overage_status,
)
task = asyncio.create_task(self._record_rate_limit(row))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _record_rate_limit(self, row: RateLimit) -> None:
try:
async with self._db.session() as session:
session.add(row)
await session.commit()
except Exception: # noqa: BLE001
_log.exception("rate limit write failed")
async def _mark_running(self, conv: Conversation, turn_id: str) -> None:
async def apply(row: Conversation) -> None:
row.running_turn = turn_id
@@ -1254,6 +1352,17 @@ def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat(timespec="seconds") if value is not None else None
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
if not messages:
return None
text = text_of(messages[-1].get("content"))
return text[:limit] if text else None
def _from_unix(value: int | None) -> datetime | None:
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
def _result_preview(
content: str | list[dict[str, Any]] | None, limit: int = 400
) -> str: