Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c120953b02 | ||
|
|
5eb9f435ec | ||
|
|
e79e9fc149 | ||
|
|
60782d6276 | ||
|
|
421248b0ad | ||
|
|
8019c76e53 | ||
|
|
2afd3bc3c1 | ||
|
|
5b851c11d4 | ||
|
|
2d903c7c5f | ||
|
|
ef7474f636 | ||
|
|
fe1c894b93 | ||
|
|
2815956f5b | ||
|
|
c46ba026ba | ||
|
|
81d6560bf7 | ||
|
|
f35980ba85 | ||
|
|
c6401f1114 | ||
|
|
025f2dbc3f | ||
|
|
85b14e2c2f | ||
|
|
7a8ee0f200 | ||
|
|
2d34d488d7 | ||
|
|
0e07e8409d | ||
|
|
3915ab48f9 | ||
|
|
9aaddbed75 | ||
|
|
253bde1b11 | ||
|
|
61b5bda31a | ||
|
|
e2d611dd81 | ||
|
|
bac40ff268 | ||
|
|
39f2bc05d9 | ||
|
|
76d6f81789 | ||
|
|
8f9ca2800c | ||
|
|
f9bf51badf | ||
|
|
90063a7cfe | ||
|
|
a8491330fa | ||
|
|
064379ca90 | ||
|
|
e90963a6c3 | ||
|
|
064d855382 | ||
|
|
5d67cfcc9e | ||
|
|
b4853b9fd0 | ||
|
|
2d12391176 | ||
|
|
60dc7db925 | ||
|
|
be114e6eaf | ||
|
|
4a59c59bda | ||
|
|
72639c4b90 | ||
|
|
1786406b31 | ||
|
|
12728b2eb4 | ||
|
|
bc4233977d | ||
|
|
fd69eae05d | ||
|
|
d3a3e7b1f1 | ||
|
|
772db11c7f | ||
|
|
4fb020c788 | ||
|
|
932a7a80af | ||
|
|
3ca5843868 | ||
|
|
8ea4cea3ef | ||
|
|
b6c5a80326 | ||
|
|
98796a82c6 | ||
|
|
61562e947d | ||
|
|
609af9fe67 | ||
|
|
e469e7e174 | ||
|
|
c88726db9e | ||
|
|
38986cb82e | ||
|
|
43407016d0 | ||
|
|
7240e0cdac | ||
|
|
7b0c61c18e | ||
|
|
0110cbd1cc | ||
|
|
9663fbf956 | ||
|
|
5177392f46 | ||
|
|
5fc58e3bc7 | ||
|
|
5d2ce68f6b | ||
|
|
70349e5ff4 | ||
|
|
827fa0977b | ||
|
|
e3074c266a | ||
|
|
ab52fdc2b8 | ||
|
|
7424d52f88 | ||
|
|
b3a584a362 | ||
|
|
a2a80ab150 | ||
|
|
d33039f497 |
No files matched your search
@@ -28,3 +28,7 @@ RAYCAST_BEARER=
|
|||||||
# токен от @BotFather, свой user id - @userinfobot
|
# токен от @BotFather, свой user id - @userinfobot
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_USER_ID=
|
TELEGRAM_USER_ID=
|
||||||
|
|
||||||
|
# Откуда тулза send_file имеет право брать файлы (через ':').
|
||||||
|
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
|
||||||
|
SEND_FILE_ROOTS=/mnt/hole/shared
|
||||||
@@ -7,6 +7,8 @@ boundary - without an opinion about what the agent is for. A setup is one
|
|||||||
holds the primitives, [beaver-agent](https://git.kotikot.com/beaver/beaver-agent)
|
holds the primitives, [beaver-agent](https://git.kotikot.com/beaver/beaver-agent)
|
||||||
is a full setup built on them.
|
is a full setup built on them.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## What it gives a setup
|
## What it gives a setup
|
||||||
|
|
||||||
- **agents/** - what an agent is: a Claude agent with a prompt per
|
- **agents/** - what an agent is: a Claude agent with a prompt per
|
||||||
@@ -20,9 +22,13 @@ is a full setup built on them.
|
|||||||
the envelope, the in-process gateway tools.
|
the envelope, the in-process gateway tools.
|
||||||
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
|
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
|
||||||
the subscription budget.
|
the subscription budget.
|
||||||
- **frontends/** - the windows: Telegram (master = General, topic = branch),
|
- **frontends/** - the windows: Telegram (master = General, topic = branch,
|
||||||
|
files out of the agent through `send_file`),
|
||||||
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
||||||
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`.
|
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
|
||||||
|
`WebhookFrontend` for a window a setup declares itself - its own request
|
||||||
|
and response schema, its own agent, its own token scope
|
||||||
|
(`docs/FRONTEND-PLUGINS.md`).
|
||||||
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
|
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
|
||||||
aggregated in-process and handed to agents by name.
|
aggregated in-process and handed to agents by name.
|
||||||
- **vault/** - watching a directory of notes for the envelope.
|
- **vault/** - watching a directory of notes for the envelope.
|
||||||
@@ -32,21 +38,29 @@ is a full setup built on them.
|
|||||||
- **storage/** - SQLModel tables and the Postgres session store.
|
- **storage/** - SQLModel tables and the Postgres session store.
|
||||||
|
|
||||||
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
|
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
|
||||||
from it; `cli.py` is the entrypoint. Every string the gateway puts in front of
|
from it; `cli.py` is the entrypoint. The admin SPA in `ui/` is the browser
|
||||||
|
window on all of it: conversations by day, the thread with the tree of tool
|
||||||
|
calls, the context of the last turn, memory, jobs, usage, tokens and the audit.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
 Every string the gateway puts in front of
|
||||||
a model or a user has an English default and is overridable
|
a model or a user has an English default and is overridable
|
||||||
(`ConversationTexts`, `TelegramTexts`).
|
(`ConversationTexts`, `TelegramTexts`).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync # python
|
cd examples
|
||||||
cd ui && bun install && cd .. # admin + panel SPA, built into ui/build
|
CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token) docker compose up --build
|
||||||
cp examples/config.py /config/config.py
|
|
||||||
DATABASE_URL=postgresql+psycopg://... CONFIG_PATH=/config/config.py uv run beaver-gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`examples/config.py` is the smallest setup that starts. The setup's own
|
`examples/config.py` is the smallest setup that starts: one agent, one
|
||||||
secrets come from `.env` (`load_dotenv` runs before the config is executed).
|
python tool, one cron job, the built-in frontends on port 8000; the compose
|
||||||
|
file next to it adds Postgres. Without Docker: `uv sync`, build the SPA with
|
||||||
|
`cd ui && bun install && bun run build`, then run `uv run beaver-gateway` with
|
||||||
|
`DATABASE_URL` and `CONFIG_PATH` set. A setup's own secrets come from `.env`
|
||||||
|
next to its config (`load_dotenv` runs before the config is executed).
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
|
|||||||
+27
-22
@@ -1,31 +1,36 @@
|
|||||||
# Minimal compose for the Phase 0 DoD: build the image, mount a
|
|
||||||
# single-file user config, watch the process print its summary line
|
|
||||||
# and exit cleanly.
|
|
||||||
#
|
|
||||||
# Real deployments add a postgres service, raycast-config.json bind,
|
|
||||||
# ~/.config/claude bind, etc. — Phase 5 lives in a separate example
|
|
||||||
# repo (see PLAN §5.1).
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: beaver
|
||||||
|
POSTGRES_PASSWORD: beaver
|
||||||
|
POSTGRES_DB: beaver
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U beaver -d beaver"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: Dockerfile
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "sqlite:///./gateway.db"
|
CONFIG_PATH: /config/config.py
|
||||||
ADMIN_USER: "admin"
|
DATABASE_URL: postgresql+psycopg://beaver:beaver@postgres:5432/beaver
|
||||||
ADMIN_PASS: "change-me"
|
ADMIN_USER: admin
|
||||||
SESSION_SECRET: "dev-secret-change-me"
|
ADMIN_PASS: change-me
|
||||||
# Phase 1.4 — comma-separated `name:value` pairs. The frontend
|
SESSION_SECRET: change-me-too
|
||||||
# 401s every request until at least one token is set.
|
BOOTSTRAP_TOKENS: dev:change-me:*
|
||||||
BOOTSTRAP_TOKENS: "cursor:dev-token-change-me"
|
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
|
||||||
# RAYCAST_BEARER / RAYCAST_DEVICE_ID + a raycast.json bind go
|
|
||||||
# here once you wire up an actual RaycastAgent. The example
|
|
||||||
# config.py declares one, so set these (or remove the agent)
|
|
||||||
# before exposing port 8000.
|
|
||||||
ports:
|
ports:
|
||||||
# The one gateway port: /anthropic, /mcp, /admin, /md under it
|
|
||||||
# (change ADMIN_USER/ADMIN_PASS/SESSION_SECRET before exposing).
|
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.py:/config/config.py:ro
|
- ./config.py:/config/config.py:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -33,7 +34,7 @@ from beaver_gateway.frontends.bearer import require_token
|
|||||||
from beaver_gateway.frontends.root import build_root_app
|
from beaver_gateway.frontends.root import build_root_app
|
||||||
from beaver_gateway.jobs.scheduler import Scheduler
|
from beaver_gateway.jobs.scheduler import Scheduler
|
||||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||||
from beaver_gateway.security.auth import TokenStore
|
from beaver_gateway.security.auth import TokenStore, scopes_with
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
Database,
|
Database,
|
||||||
PostgresSessionStore,
|
PostgresSessionStore,
|
||||||
@@ -194,6 +195,8 @@ async def run(gateway: Gateway, settings: Settings) -> None:
|
|||||||
pool=pool,
|
pool=pool,
|
||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
||||||
|
scopes=scopes_with(fe.scope for fe in gateway.frontends),
|
||||||
|
send_file_roots=_roots(settings.send_file_roots),
|
||||||
)
|
)
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
fe.configure(runtime)
|
fe.configure(runtime)
|
||||||
@@ -296,6 +299,10 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
|
|||||||
await uvicorn.Server(config).serve()
|
await uvicorn.Server(config).serve()
|
||||||
|
|
||||||
|
|
||||||
|
def _roots(value: str) -> tuple[Path, ...]:
|
||||||
|
return tuple(Path(p) for p in value.split(":") if p.strip())
|
||||||
|
|
||||||
|
|
||||||
class _LateConversations:
|
class _LateConversations:
|
||||||
conversations: Conversations | None = None
|
conversations: Conversations | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from claude_agent_sdk import (
|
|||||||
PermissionResultDeny,
|
PermissionResultDeny,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
StreamEvent,
|
StreamEvent,
|
||||||
|
TaskNotificationMessage,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
ToolResultBlock,
|
ToolResultBlock,
|
||||||
@@ -72,7 +73,14 @@ from beaver_gateway.events.stream import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
from collections.abc import (
|
||||||
|
AsyncIterator,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
Coroutine,
|
||||||
|
Iterable,
|
||||||
|
Sequence,
|
||||||
|
)
|
||||||
|
|
||||||
from anthropic.types import MessageParam
|
from anthropic.types import MessageParam
|
||||||
from claude_agent_sdk import (
|
from claude_agent_sdk import (
|
||||||
@@ -201,8 +209,16 @@ class ClaudeSdkBackend:
|
|||||||
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
|
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
|
||||||
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
|
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
|
||||||
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
|
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
|
||||||
|
waker: Callable[[str, str], Coroutine[Any, Any, None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""One backend per Claude agent.
|
||||||
|
|
||||||
|
``waker(key, note)`` is called when a session starts a turn by itself
|
||||||
|
(a background task reported back); the caller then attaches.
|
||||||
|
"""
|
||||||
self._agent = agent
|
self._agent = agent
|
||||||
|
self.waker = waker
|
||||||
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
self._audit_sink = audit_sink
|
self._audit_sink = audit_sink
|
||||||
self._store = session_store
|
self._store = session_store
|
||||||
self._runner = runner or RunnerConfig()
|
self._runner = runner or RunnerConfig()
|
||||||
@@ -256,6 +272,51 @@ class ClaudeSdkBackend:
|
|||||||
def live(self, key: str) -> Session | None:
|
def live(self, key: str) -> Session | None:
|
||||||
return self._pool.get(key)
|
return self._pool.get(key)
|
||||||
|
|
||||||
|
def has_pending(self, key: str) -> bool:
|
||||||
|
"""A turn the session started on its own is waiting for ``attach``."""
|
||||||
|
live = self._pool.get(key)
|
||||||
|
return live is not None and bool(live.pending)
|
||||||
|
|
||||||
|
def pending_note(self, key: str) -> str | None:
|
||||||
|
live = self._pool.get(key)
|
||||||
|
return live.note if live is not None else None
|
||||||
|
|
||||||
|
async def attach(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
capture: TurnCapture | None = None,
|
||||||
|
observer: Callable[[Any], None] | None = None,
|
||||||
|
turn_id: str | None = None,
|
||||||
|
) -> AsyncIterator[MessageStreamEvent]:
|
||||||
|
"""Consume the turn a live session started by itself as a normal turn.
|
||||||
|
|
||||||
|
The CLI runs one when a background task (a subagent) reports back:
|
||||||
|
no prompt goes out, the events are what the model said in reply to
|
||||||
|
the report, the turn ends on that result.
|
||||||
|
"""
|
||||||
|
live = self._pool.get(key)
|
||||||
|
if live is None or not live.pending:
|
||||||
|
return
|
||||||
|
message_id = f"msg_{uuid.uuid4().hex}"
|
||||||
|
yield build_message_start(message_id=message_id, model=self._agent.model)
|
||||||
|
turn = _Turn()
|
||||||
|
async with live.lock:
|
||||||
|
live.running_turn = turn_id or message_id
|
||||||
|
live.last_used = time.monotonic()
|
||||||
|
async for event in self._run_turn(live, None, turn, observer, capture):
|
||||||
|
yield event
|
||||||
|
live.turns += 1
|
||||||
|
live.last_used = time.monotonic()
|
||||||
|
live.running_turn = None
|
||||||
|
usage, _ = await self._after_turn(
|
||||||
|
live, turn, conversation_id=key, history=[], capture=capture
|
||||||
|
)
|
||||||
|
yield build_message_delta(
|
||||||
|
stop_reason=turn.stop_reason, usage=_wire_usage(usage)
|
||||||
|
)
|
||||||
|
yield build_message_stop()
|
||||||
|
|
||||||
async def repair_session(
|
async def repair_session(
|
||||||
self, session_id: str, *, text: str = "interrupted"
|
self, session_id: str, *, text: str = "interrupted"
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -313,7 +374,7 @@ class ClaudeSdkBackend:
|
|||||||
if not history or history[-1].get("role") != "user":
|
if not history or history[-1].get("role") != "user":
|
||||||
msg = "the last message must be a user turn"
|
msg = "the last message must be a user turn"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
prompt = _prompt_text(history[-1].get("content"))
|
prompt = _prompt(history[-1].get("content"))
|
||||||
prior = history[:-1]
|
prior = history[:-1]
|
||||||
key = conversation_id or fingerprint(prior)
|
key = conversation_id or fingerprint(prior)
|
||||||
spec = _SessionSpec(kind=kind, pinned=pinned, tools=tools)
|
spec = _SessionSpec(kind=kind, pinned=pinned, tools=tools)
|
||||||
@@ -404,70 +465,71 @@ class ClaudeSdkBackend:
|
|||||||
async def _run_turn(
|
async def _run_turn(
|
||||||
self,
|
self,
|
||||||
live: Session,
|
live: Session,
|
||||||
prompt: str,
|
prompt: str | list[dict[str, Any]] | None,
|
||||||
turn: _Turn,
|
turn: _Turn,
|
||||||
observer: Callable[[Any], None] | None = None,
|
observer: Callable[[Any], None] | None = None,
|
||||||
capture: TurnCapture | None = None,
|
capture: TurnCapture | None = None,
|
||||||
) -> AsyncIterator[MessageStreamEvent]:
|
) -> AsyncIterator[MessageStreamEvent]:
|
||||||
"""Run one prompt, yielding wire events as they arrive.
|
"""Run one prompt, or attach to a self-started one, yielding events.
|
||||||
|
|
||||||
``turn`` is filled in place (result, synthesized history, count of
|
``turn`` is filled in place (result, synthesized history, count of
|
||||||
events already yielded) so the caller can finish bookkeeping - and
|
events already yielded) so the caller can finish bookkeeping - and
|
||||||
decide whether a retry is still possible - after a failure. The
|
decide whether a retry is still possible - after a failure. The
|
||||||
session id lands in ``capture`` with the first frame, so a turn cut
|
session id lands in ``capture`` with the first frame, so a turn cut
|
||||||
by a restart still leaves a resumable session behind.
|
by a restart still leaves a resumable session behind.
|
||||||
|
|
||||||
|
A result whose ``origin`` is not the human's ends a turn the CLI ran
|
||||||
|
on its own; inside a prompted turn it is passed over, the events it
|
||||||
|
came with stay in this turn rather than getting lost.
|
||||||
"""
|
"""
|
||||||
streaming = self._agent.options.include_partial_messages
|
|
||||||
raw: list[Any] = []
|
raw: list[Any] = []
|
||||||
next_index = 0
|
cursor = _Cursor()
|
||||||
offset = 0
|
live.observer = observer
|
||||||
await live.client.query(prompt)
|
live.attached = True
|
||||||
async for message in live.client.receive_response():
|
for queued in live.pending:
|
||||||
if observer is not None:
|
live.inbox.put_nowait(queued)
|
||||||
observer(message)
|
live.pending.clear()
|
||||||
session_id = getattr(message, "session_id", None)
|
try:
|
||||||
if isinstance(session_id, str) and session_id and live.session_id is None:
|
if prompt is not None:
|
||||||
live.session_id = session_id
|
await live.client.query(
|
||||||
if capture is not None:
|
prompt if isinstance(prompt, str) else _stream(prompt)
|
||||||
capture.session_id = session_id
|
|
||||||
if isinstance(message, MirrorErrorMessage):
|
|
||||||
live.dirty = True
|
|
||||||
_log.error(
|
|
||||||
"session %s: mirror error, marked dirty: %s",
|
|
||||||
live.session_id,
|
|
||||||
message.error,
|
|
||||||
)
|
)
|
||||||
continue
|
while True:
|
||||||
if getattr(message, "parent_tool_use_id", None) is not None:
|
message = await live.inbox.get()
|
||||||
continue
|
if message is _END:
|
||||||
if isinstance(message, StreamEvent):
|
if prompt is not None:
|
||||||
event = message.event
|
msg = f"claude exited before answering ({live.session_id})"
|
||||||
if event.get("type") == "message_start":
|
raise RuntimeError(msg)
|
||||||
offset = next_index
|
break
|
||||||
turn.context_tokens = _context_of(event.get("message"))
|
if isinstance(message, BaseException):
|
||||||
|
raise message
|
||||||
|
if observer is not None:
|
||||||
|
observer(message)
|
||||||
|
if not self._note(live, message, capture):
|
||||||
continue
|
continue
|
||||||
index = event.get("index")
|
if isinstance(message, ResultMessage):
|
||||||
if isinstance(index, int):
|
if prompt is not None and _self_started(message):
|
||||||
next_index = max(next_index, offset + index + 1)
|
_log.info(
|
||||||
if streaming:
|
"session %s: a self-started turn ended inside a "
|
||||||
for out in _emit_stream_event(event, offset + index):
|
"prompted one, folded in",
|
||||||
turn.events += 1
|
live.session_id,
|
||||||
yield out
|
)
|
||||||
elif isinstance(message, AssistantMessage):
|
continue
|
||||||
raw.append(message)
|
turn.result = message
|
||||||
if not streaming:
|
turn.stop_reason = _STOP_REASONS.get(
|
||||||
for block in message.content:
|
message.stop_reason or "", "end_turn"
|
||||||
for out in _emit_block(block, next_index):
|
)
|
||||||
turn.events += 1
|
break
|
||||||
yield out
|
for out in self._emit(message, turn, cursor, raw):
|
||||||
next_index += 1
|
turn.events += 1
|
||||||
elif isinstance(message, UserMessage):
|
yield out
|
||||||
raw.append(message)
|
if turn.events == 0:
|
||||||
elif isinstance(message, ResultMessage):
|
for out in _emit_whole(raw, cursor):
|
||||||
turn.result = message
|
turn.events += 1
|
||||||
turn.stop_reason = _STOP_REASONS.get(
|
yield out
|
||||||
message.stop_reason or "", "end_turn"
|
finally:
|
||||||
)
|
live.attached = False
|
||||||
|
live.woken = False
|
||||||
turn.synthesized = synthesize_turn_messages(raw)
|
turn.synthesized = synthesize_turn_messages(raw)
|
||||||
_log.info(
|
_log.info(
|
||||||
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
|
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
|
||||||
@@ -478,6 +540,88 @@ class ClaudeSdkBackend:
|
|||||||
turn.stop_reason,
|
turn.stop_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _note(live: Session, message: Any, capture: TurnCapture | None) -> bool:
|
||||||
|
"""Session bookkeeping for one message; False if not the main thread's."""
|
||||||
|
session_id = getattr(message, "session_id", None)
|
||||||
|
if isinstance(session_id, str) and session_id and live.session_id is None:
|
||||||
|
live.session_id = session_id
|
||||||
|
if capture is not None:
|
||||||
|
capture.session_id = session_id
|
||||||
|
if isinstance(message, MirrorErrorMessage):
|
||||||
|
live.dirty = True
|
||||||
|
_log.error(
|
||||||
|
"session %s: mirror error, marked dirty: %s",
|
||||||
|
live.session_id,
|
||||||
|
message.error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if isinstance(message, TaskNotificationMessage):
|
||||||
|
live.note = _note_of(message)
|
||||||
|
return False
|
||||||
|
return getattr(message, "parent_tool_use_id", None) is None
|
||||||
|
|
||||||
|
def _emit(
|
||||||
|
self, message: Any, turn: _Turn, cursor: _Cursor, raw: list[Any]
|
||||||
|
) -> Iterable[MessageStreamEvent]:
|
||||||
|
streaming = self._agent.options.include_partial_messages
|
||||||
|
if isinstance(message, StreamEvent):
|
||||||
|
event = message.event
|
||||||
|
if event.get("type") == "message_start":
|
||||||
|
cursor.offset = cursor.next_index
|
||||||
|
turn.context_tokens = _context_of(event.get("message"))
|
||||||
|
return
|
||||||
|
index = event.get("index")
|
||||||
|
if isinstance(index, int):
|
||||||
|
cursor.next_index = max(cursor.next_index, cursor.offset + index + 1)
|
||||||
|
if streaming:
|
||||||
|
yield from _emit_stream_event(event, cursor.offset + index)
|
||||||
|
elif isinstance(message, AssistantMessage):
|
||||||
|
raw.append(message)
|
||||||
|
if not streaming:
|
||||||
|
for block in message.content:
|
||||||
|
yield from _emit_block(block, cursor.next_index)
|
||||||
|
cursor.next_index += 1
|
||||||
|
elif isinstance(message, UserMessage):
|
||||||
|
raw.append(message)
|
||||||
|
|
||||||
|
async def _read(self, live: Session) -> None:
|
||||||
|
"""Read the client for the session's whole life.
|
||||||
|
|
||||||
|
During a turn every message goes to the turn's inbox. Between turns
|
||||||
|
a subagent still running is shown to the last observer, and the
|
||||||
|
first frame of the main thread means the CLI started a turn by
|
||||||
|
itself: it is buffered in ``pending`` and the waker is told once.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async for message in live.client.receive_messages():
|
||||||
|
if live.attached:
|
||||||
|
live.inbox.put_nowait(message)
|
||||||
|
continue
|
||||||
|
if isinstance(message, TaskNotificationMessage):
|
||||||
|
live.note = _note_of(message)
|
||||||
|
if _main_thread(message):
|
||||||
|
live.pending.append(message)
|
||||||
|
if not live.woken:
|
||||||
|
live.woken = True
|
||||||
|
self._wake(live)
|
||||||
|
elif live.observer is not None:
|
||||||
|
live.observer(message)
|
||||||
|
live.inbox.put_nowait(_END)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_log.error("session %s: reader died: %s", live.session_id, exc)
|
||||||
|
live.dirty = True
|
||||||
|
live.inbox.put_nowait(exc)
|
||||||
|
|
||||||
|
def _wake(self, live: Session) -> None:
|
||||||
|
if self.waker is None:
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(self.waker(live.key, live.note or ""))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
async def _acquire(
|
async def _acquire(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
@@ -541,7 +685,7 @@ class ClaudeSdkBackend:
|
|||||||
spec.tools,
|
spec.tools,
|
||||||
self._runner.user,
|
self._runner.user,
|
||||||
)
|
)
|
||||||
return Session(
|
live = Session(
|
||||||
key=key,
|
key=key,
|
||||||
agent=self._agent.name,
|
agent=self._agent.name,
|
||||||
kind=spec.kind,
|
kind=spec.kind,
|
||||||
@@ -550,6 +694,8 @@ class ClaudeSdkBackend:
|
|||||||
resumed=resume is not None,
|
resumed=resume is not None,
|
||||||
pinned=spec.pinned,
|
pinned=spec.pinned,
|
||||||
)
|
)
|
||||||
|
live.reader = asyncio.create_task(self._read(live))
|
||||||
|
return live
|
||||||
|
|
||||||
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
|
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
|
||||||
return _RunnerClient(options, uid=self._uid)
|
return _RunnerClient(options, uid=self._uid)
|
||||||
@@ -739,6 +885,15 @@ class _SessionSpec:
|
|||||||
tools: bool
|
tools: bool
|
||||||
|
|
||||||
|
|
||||||
|
_END = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Cursor:
|
||||||
|
next_index: int = 0
|
||||||
|
offset: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Turn:
|
class _Turn:
|
||||||
events: int = 0
|
events: int = 0
|
||||||
@@ -845,14 +1000,54 @@ def _mcp_disallowed(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _prompt_text(content: Any) -> str:
|
def _prompt(content: Any) -> str | list[dict[str, Any]]:
|
||||||
|
"""Plain text when the turn is text only; the block list when images ride along."""
|
||||||
text = text_of(content)
|
text = text_of(content)
|
||||||
if not text:
|
if not text:
|
||||||
msg = "user message has no text content"
|
msg = "user message has no text content"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
if isinstance(content, list) and any(
|
||||||
|
isinstance(b, dict) and b.get("type") != "text" for b in content
|
||||||
|
):
|
||||||
|
return [dict(b) for b in content if isinstance(b, dict)]
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream(content: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
yield {
|
||||||
|
"type": "user",
|
||||||
|
"message": {"role": "user", "content": content},
|
||||||
|
"parent_tool_use_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_whole(raw: Iterable[Any], cursor: _Cursor) -> Iterable[MessageStreamEvent]:
|
||||||
|
"""Blocks of a turn that streamed no deltas, e.g. one reported whole."""
|
||||||
|
for message in raw:
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
yield from _emit_block(block, cursor.next_index)
|
||||||
|
cursor.next_index += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _self_started(result: ResultMessage) -> bool:
|
||||||
|
origin = result.origin
|
||||||
|
return origin is not None and origin.get("kind") != "human"
|
||||||
|
|
||||||
|
|
||||||
|
def _main_thread(message: Any) -> bool:
|
||||||
|
if getattr(message, "parent_tool_use_id", None) is not None:
|
||||||
|
return False
|
||||||
|
return isinstance(
|
||||||
|
message, (StreamEvent, AssistantMessage, UserMessage, ResultMessage)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _note_of(message: TaskNotificationMessage) -> str:
|
||||||
|
summary = message.summary or message.data.get("description") or message.task_id
|
||||||
|
return f"{summary} ({message.status})"
|
||||||
|
|
||||||
|
|
||||||
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
|
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for message in raw:
|
for message in raw:
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ from typing import TYPE_CHECKING, Any, Protocol
|
|||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
from collections.abc import (
|
||||||
|
AsyncIterable,
|
||||||
|
AsyncIterator,
|
||||||
|
Callable,
|
||||||
|
Iterator,
|
||||||
|
Mapping,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
|
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
|
||||||
|
|
||||||
@@ -41,8 +47,8 @@ _RSS_HEADROOM = 0.8
|
|||||||
|
|
||||||
class SessionClient(Protocol):
|
class SessionClient(Protocol):
|
||||||
async def connect(self) -> None: ...
|
async def connect(self) -> None: ...
|
||||||
async def query(self, prompt: str) -> None: ...
|
async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ...
|
||||||
def receive_response(self) -> AsyncIterator[Any]: ...
|
def receive_messages(self) -> AsyncIterator[Any]: ...
|
||||||
async def interrupt(self) -> None: ...
|
async def interrupt(self) -> None: ...
|
||||||
async def disconnect(self) -> None: ...
|
async def disconnect(self) -> None: ...
|
||||||
|
|
||||||
@@ -67,6 +73,20 @@ class Session:
|
|||||||
state: dict[str, Any] = field(default_factory=dict)
|
state: dict[str, Any] = field(default_factory=dict)
|
||||||
"""Scratch for policy rules (``agents/policy.py``); dies with the process."""
|
"""Scratch for policy rules (``agents/policy.py``); dies with the process."""
|
||||||
|
|
||||||
|
reader: asyncio.Task[None] | None = None
|
||||||
|
"""Reads the client for the session's whole life, not only during a turn."""
|
||||||
|
inbox: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
|
||||||
|
"""Where the reader puts messages while a turn is attached."""
|
||||||
|
pending: list[Any] = field(default_factory=list)
|
||||||
|
"""A turn the CLI started on its own (a background task reported back),
|
||||||
|
buffered until a consumer attaches."""
|
||||||
|
attached: bool = False
|
||||||
|
woken: bool = False
|
||||||
|
note: str | None = None
|
||||||
|
"""Summary of the last task notification, the pending turn's prompt."""
|
||||||
|
observer: Callable[[Any], None] | None = None
|
||||||
|
"""The last turn's observer; sees subagent traffic between turns."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def busy(self) -> bool:
|
def busy(self) -> bool:
|
||||||
return self.lock.locked() or self.running_turn is not None
|
return self.lock.locked() or self.running_turn is not None
|
||||||
@@ -192,6 +212,8 @@ class SessionPool:
|
|||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
|
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
|
||||||
|
if session.reader is not None and session.reader is not asyncio.current_task():
|
||||||
|
session.reader.cancel()
|
||||||
try:
|
try:
|
||||||
await session.client.disconnect()
|
await session.client.disconnect()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -238,6 +260,7 @@ class SessionPool:
|
|||||||
"turns": s.turns,
|
"turns": s.turns,
|
||||||
"busy": s.busy,
|
"busy": s.busy,
|
||||||
"running_turn": s.running_turn,
|
"running_turn": s.running_turn,
|
||||||
|
"pending": len(s.pending),
|
||||||
"pending_question": s.pending_question,
|
"pending_question": s.pending_question,
|
||||||
"pinned": s.pinned,
|
"pinned": s.pinned,
|
||||||
"dirty": s.dirty,
|
"dirty": s.dirty,
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ def build_entries(
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
def messages_from_entries(
|
||||||
|
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Messages as the model saw them; ``with_ts`` adds when each one was written."""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
last_message_id: str | None = None
|
last_message_id: str | None = None
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -180,7 +183,13 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str
|
|||||||
):
|
):
|
||||||
out[-1]["content"].extend(results)
|
out[-1]["content"].extend(results)
|
||||||
else:
|
else:
|
||||||
out.append({"role": "user", "content": _user_content(content)})
|
message_out: dict[str, Any] = {
|
||||||
|
"role": "user",
|
||||||
|
"content": _user_content(content),
|
||||||
|
}
|
||||||
|
if with_ts:
|
||||||
|
message_out["ts"] = entry.get("timestamp")
|
||||||
|
out.append(message_out)
|
||||||
last_message_id = None
|
last_message_id = None
|
||||||
continue
|
continue
|
||||||
blocks = _assistant_blocks(content)
|
blocks = _assistant_blocks(content)
|
||||||
@@ -193,7 +202,10 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str
|
|||||||
):
|
):
|
||||||
out[-1]["content"].extend(blocks)
|
out[-1]["content"].extend(blocks)
|
||||||
else:
|
else:
|
||||||
out.append({"role": "assistant", "content": blocks})
|
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
|
||||||
|
if with_ts:
|
||||||
|
message_out["ts"] = entry.get("timestamp")
|
||||||
|
out.append(message_out)
|
||||||
last_message_id = message_id
|
last_message_id = message_id
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -30,6 +31,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
__all__ = ["ConfigError", "Gateway", "load"]
|
__all__ = ["ConfigError", "Gateway", "load"]
|
||||||
|
|
||||||
|
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Gateway:
|
class Gateway:
|
||||||
@@ -135,6 +138,12 @@ def _validate(gw: Gateway, path: Path) -> None:
|
|||||||
f"got {type(f).__name__}"
|
f"got {type(f).__name__}"
|
||||||
)
|
)
|
||||||
raise ConfigError(msg)
|
raise ConfigError(msg)
|
||||||
|
if f.scope is not None and not _SCOPE.fullmatch(f.scope):
|
||||||
|
msg = (
|
||||||
|
f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; "
|
||||||
|
f"a frontend scope is a lowercase name like 'voice', never '*'"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
names: set[str] = set()
|
names: set[str] = set()
|
||||||
for i, j in enumerate(gw.jobs):
|
for i, j in enumerate(gw.jobs):
|
||||||
if not isinstance(j, Job):
|
if not isinstance(j, Job):
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""What a conversation's model holds: prompt granules, skills, tools, files touched."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
|
from beaver_gateway.conversations.kinds import Kind
|
||||||
|
|
||||||
|
__all__ = ["bash_paths", "compose", "files_touched", "granules", "skill_sets"]
|
||||||
|
|
||||||
|
CHARS_PER_TOKEN = 3.2
|
||||||
|
READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
||||||
|
WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
|
||||||
|
SEARCH_TOOLS = frozenset({"Glob", "Grep"})
|
||||||
|
PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename")
|
||||||
|
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
|
||||||
|
# A shell command names files as quoted strings or bare words; only notes
|
||||||
|
# and plain text files count, code and binaries are the agent's own business.
|
||||||
|
_SHELL_TOKEN = re.compile(r'"([^"\n]+)"|\'([^\'\n]+)\'|([^\s"\'`;|&()<>]+)')
|
||||||
|
_NOTE_SUFFIXES = (".md", ".canvas", ".txt", ".csv", ".json", ".yaml", ".yml")
|
||||||
|
_SHELL_CD = re.compile(r'(?:^|&&|;|\|\|)\s*cd\s+("([^"]+)"|\'([^\']+)\'|(\S+))')
|
||||||
|
_SHELL_WRITES = re.compile(
|
||||||
|
r"write_text|write_bytes|>>?\s*[\"\']?[^\s&|]|\btee\b|\bsed\s+-i|\bmv\b|\brm\b"
|
||||||
|
r"|\btouch\b|\bcp\b|open\([^)]*[\"\'][wa]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bash_paths(command: str, cwd: Path | None) -> tuple[list[str], Path | None, bool]:
|
||||||
|
"""Files a shell command names, where it leaves the shell, whether it writes.
|
||||||
|
|
||||||
|
Relative names resolve against ``cwd``, or against the directory of a
|
||||||
|
leading ``cd`` in the same command. Absolute names stay as they are.
|
||||||
|
"""
|
||||||
|
here = cwd
|
||||||
|
for match in _SHELL_CD.finditer(command):
|
||||||
|
target = match.group(2) or match.group(3) or match.group(4) or ""
|
||||||
|
target = target.strip()
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
here = Path(target) if target.startswith("/") else (here or Path()) / target
|
||||||
|
found: list[str] = []
|
||||||
|
for match in _SHELL_TOKEN.finditer(command):
|
||||||
|
raw = (match.group(1) or match.group(2) or match.group(3) or "").strip()
|
||||||
|
if not raw.lower().endswith(_NOTE_SUFFIXES) or "*" in raw:
|
||||||
|
continue
|
||||||
|
path = Path(raw) if raw.startswith("/") else (here or Path()) / raw
|
||||||
|
text = str(path)
|
||||||
|
if text not in found:
|
||||||
|
found.append(text)
|
||||||
|
return found, here, bool(_SHELL_WRITES.search(command))
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(chars: int) -> int:
|
||||||
|
return round(chars / CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(path: str, cwd: Path | None) -> str:
|
||||||
|
if cwd is None:
|
||||||
|
return path
|
||||||
|
try:
|
||||||
|
return str(Path(path).relative_to(cwd))
|
||||||
|
except ValueError:
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_uses(entries: Iterable[Mapping[str, Any]]) -> Iterable[tuple[str, Any, str]]:
|
||||||
|
for entry in entries:
|
||||||
|
message = entry.get("message")
|
||||||
|
if entry.get("type") != "assistant" or not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
content = message.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
stamp = str(entry.get("timestamp") or "")
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||||
|
yield str(block.get("name") or "?"), block.get("input"), stamp
|
||||||
|
|
||||||
|
|
||||||
|
def files_touched(
|
||||||
|
entries: Iterable[Mapping[str, Any]], *, cwd: Path | None = None
|
||||||
|
) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
||||||
|
"""Files named in tool inputs, with how they were touched, plus tool counts."""
|
||||||
|
files: dict[str, dict[str, Any]] = {}
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
# The shell keeps its directory between calls; follow it.
|
||||||
|
shell_cwd = cwd
|
||||||
|
|
||||||
|
def touch(raw: str, how: str, stamp: str) -> None:
|
||||||
|
key = _relative(raw, cwd)
|
||||||
|
row = files.setdefault(
|
||||||
|
key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp}
|
||||||
|
)
|
||||||
|
row[how] += 1
|
||||||
|
row["last_at"] = max(row["last_at"], stamp)
|
||||||
|
|
||||||
|
for name, tool_input, stamp in _tool_uses(entries):
|
||||||
|
counts[name] += 1
|
||||||
|
if not isinstance(tool_input, dict):
|
||||||
|
continue
|
||||||
|
if name == "Bash" and isinstance(tool_input.get("command"), str):
|
||||||
|
paths, shell_cwd, writes = bash_paths(tool_input["command"], shell_cwd)
|
||||||
|
for raw in paths:
|
||||||
|
touch(raw, "writes" if writes else "reads", stamp)
|
||||||
|
continue
|
||||||
|
raw = next(
|
||||||
|
(v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input):
|
||||||
|
continue
|
||||||
|
if name in READ_TOOLS:
|
||||||
|
touch(raw, "reads", stamp)
|
||||||
|
elif name in WRITE_TOOLS:
|
||||||
|
touch(raw, "writes", stamp)
|
||||||
|
else:
|
||||||
|
touch(raw, "other", stamp)
|
||||||
|
ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True)
|
||||||
|
return ordered, dict(counts)
|
||||||
|
|
||||||
|
|
||||||
|
def granules(agent: ClaudeAgent, kind: Kind) -> dict[str, Any]:
|
||||||
|
"""The system prompt as its sources: tag, path, size, token estimate."""
|
||||||
|
sources = agent.prompt_for(kind) if agent.serves(kind) else None
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
if sources is None:
|
||||||
|
chars = len(agent.system_prompt)
|
||||||
|
if chars:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"tag": None,
|
||||||
|
"path": None,
|
||||||
|
"bytes": chars,
|
||||||
|
"tokens_est": _tokens(chars),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for source in sources:
|
||||||
|
tag, raw = source if isinstance(source, tuple) else (None, source)
|
||||||
|
path = Path(raw)
|
||||||
|
try:
|
||||||
|
chars = len(path.read_text(encoding="utf-8"))
|
||||||
|
except OSError:
|
||||||
|
chars = 0
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"tag": tag,
|
||||||
|
"path": _relative(str(path), agent.cwd),
|
||||||
|
"bytes": chars,
|
||||||
|
"tokens_est": _tokens(chars),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total = sum(r["bytes"] for r in rows)
|
||||||
|
return {"bytes": total, "tokens_est": _tokens(total), "granules": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_meta(skill_md: Path) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
text = skill_md.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
match = _FRONTMATTER.match(text)
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
if match:
|
||||||
|
for line in match.group(1).splitlines():
|
||||||
|
key, sep, value = line.partition(":")
|
||||||
|
if sep:
|
||||||
|
meta[key.strip()] = value.strip()
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def skill_sets(agent: ClaudeAgent, kind: Kind) -> list[dict[str, Any]]:
|
||||||
|
"""Skill-set directories the kind loads, with each skill's name and description."""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for directory in agent.skills_for(kind):
|
||||||
|
skills = []
|
||||||
|
try:
|
||||||
|
children = sorted(p for p in directory.iterdir() if p.is_dir())
|
||||||
|
except OSError:
|
||||||
|
children = []
|
||||||
|
for child in children:
|
||||||
|
skill_md = child / "SKILL.md"
|
||||||
|
if skill_md.is_file():
|
||||||
|
meta = _skill_meta(skill_md)
|
||||||
|
skills.append(
|
||||||
|
{
|
||||||
|
"name": meta.get("name") or child.name,
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"path": _relative(str(child), agent.cwd),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"set": directory.name,
|
||||||
|
"path": _relative(str(directory), agent.cwd),
|
||||||
|
"skills": skills,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compose(
|
||||||
|
agent: ClaudeAgent,
|
||||||
|
kind: Kind,
|
||||||
|
entries: Iterable[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
context_tokens: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
files, counts = files_touched(entries, cwd=agent.cwd)
|
||||||
|
prompt = granules(agent, kind)
|
||||||
|
skills = skill_sets(agent, kind)
|
||||||
|
skills_chars = sum(
|
||||||
|
len(s["name"]) + len(s["description"])
|
||||||
|
for group in skills
|
||||||
|
for s in group["skills"]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"agent": {
|
||||||
|
"name": agent.name,
|
||||||
|
"model": agent.model,
|
||||||
|
"effort": agent.options.effort,
|
||||||
|
},
|
||||||
|
"kind": kind,
|
||||||
|
"context_tokens": context_tokens,
|
||||||
|
"prompt": prompt,
|
||||||
|
"skills": skills,
|
||||||
|
"skills_tokens_est": _tokens(skills_chars),
|
||||||
|
"tools": {
|
||||||
|
"allowed": list(agent.options.tools) if agent.options.tools else None,
|
||||||
|
"disallowed": list(agent.options.disallowed_tools),
|
||||||
|
"gateway": list(agent.gateway_tools),
|
||||||
|
"mcps": [m.name for m in agent.expose_mcps],
|
||||||
|
},
|
||||||
|
"files": files,
|
||||||
|
"tool_counts": counts,
|
||||||
|
"history_tokens_est": max(
|
||||||
|
0, context_tokens - prompt["tokens_est"] - _tokens(skills_chars)
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ class RecallContext:
|
|||||||
text: str
|
text: str
|
||||||
kind: str
|
kind: str
|
||||||
now: datetime
|
now: datetime
|
||||||
|
agent: str = ""
|
||||||
|
"""Agent whose turn it is; a setup with several tells them apart by it."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -40,7 +42,12 @@ class Envelope:
|
|||||||
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
||||||
|
|
||||||
def build(
|
def build(
|
||||||
self, *, now: datetime | None = None, text: str = "", kind: str = "master"
|
self,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
text: str = "",
|
||||||
|
kind: str = "master",
|
||||||
|
agent: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
now = now or datetime.now(UTC)
|
now = now or datetime.now(UTC)
|
||||||
changes = self.watch.take() if self.watch is not None else []
|
changes = self.watch.take() if self.watch is not None else []
|
||||||
@@ -59,20 +66,26 @@ class Envelope:
|
|||||||
texts=self.texts,
|
texts=self.texts,
|
||||||
)
|
)
|
||||||
self.last_at = now
|
self.last_at = now
|
||||||
block = self.recall_block(text=text, kind=kind, now=now)
|
block = self.recall_block(text=text, kind=kind, now=now, agent=agent)
|
||||||
return f"{out}\n{block}" if block else out
|
return f"{out}\n{block}" if block else out
|
||||||
|
|
||||||
def recall_only(
|
def recall_only(
|
||||||
self, *, text: str, kind: str, now: datetime | None = None
|
self, *, text: str, kind: str, now: datetime | None = None, agent: str = ""
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
block = self.recall_block(text=text, kind=kind, now=now or datetime.now(UTC))
|
block = self.recall_block(
|
||||||
|
text=text, kind=kind, now=now or datetime.now(UTC), agent=agent
|
||||||
|
)
|
||||||
return f"{self.texts.header}\n{block}" if block else None
|
return f"{self.texts.header}\n{block}" if block else None
|
||||||
|
|
||||||
def recall_block(self, *, text: str, kind: str, now: datetime) -> str | None:
|
def recall_block(
|
||||||
|
self, *, text: str, kind: str, now: datetime, agent: str = ""
|
||||||
|
) -> str | None:
|
||||||
if self.recall is None or not text.strip():
|
if self.recall is None or not text.strip():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
block = self.recall(RecallContext(text=text, kind=kind, now=now))
|
block = self.recall(
|
||||||
|
RecallContext(text=text, kind=kind, now=now, agent=agent)
|
||||||
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("recall hook failed")
|
_log.exception("recall hook failed")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Literal, cast
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
@@ -70,13 +70,23 @@ class InjectQueue:
|
|||||||
self._db = db
|
self._db = db
|
||||||
|
|
||||||
async def push(
|
async def push(
|
||||||
self, *, conversation_id: int, priority: Priority, origin: str, text: str
|
self,
|
||||||
|
*,
|
||||||
|
conversation_id: int,
|
||||||
|
priority: Priority,
|
||||||
|
origin: str,
|
||||||
|
text: str,
|
||||||
|
attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> InjectQueueItem:
|
) -> InjectQueueItem:
|
||||||
if priority not in PRIORITY_RANK:
|
if priority not in PRIORITY_RANK:
|
||||||
msg = f"unknown priority {priority!r}"
|
msg = f"unknown priority {priority!r}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
row = InjectQueueItem(
|
row = InjectQueueItem(
|
||||||
conversation_id=conversation_id, priority=priority, origin=origin, text=text
|
conversation_id=conversation_id,
|
||||||
|
priority=priority,
|
||||||
|
origin=origin,
|
||||||
|
text=text,
|
||||||
|
attachments=attachments or None,
|
||||||
)
|
)
|
||||||
async with self._db.session() as session:
|
async with self._db.session() as session:
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Putting words into a conversation: a message, an inject, ``say``, ``schedule``."""
|
"""Putting words into a conversation: a message, an inject, ``say``, a file."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from beaver_gateway.conversations.turns import Turns
|
from beaver_gateway.conversations.turns import Turns
|
||||||
|
from beaver_gateway.frontends.files import SendFileError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -20,13 +21,19 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class Messaging(Turns):
|
class Messaging(Turns):
|
||||||
async def post(
|
async def post(
|
||||||
self, conv: Conversation, text: str, *, origin: str = "user"
|
self,
|
||||||
|
conv: Conversation,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
origin: str = "user",
|
||||||
|
attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> InjectQueueItem:
|
) -> InjectQueueItem:
|
||||||
item = await self._queue.push(
|
item = await self._queue.push(
|
||||||
conversation_id=cast("int", conv.id),
|
conversation_id=cast("int", conv.id),
|
||||||
priority="user",
|
priority="user",
|
||||||
origin=origin,
|
origin=origin,
|
||||||
text=text,
|
text=text,
|
||||||
|
attachments=attachments,
|
||||||
)
|
)
|
||||||
await self.touch_user(conv)
|
await self.touch_user(conv)
|
||||||
self._bus.publish(
|
self._bus.publish(
|
||||||
@@ -86,6 +93,14 @@ class Messaging(Turns):
|
|||||||
self._ensure_worker(cast("int", conv.id))
|
self._ensure_worker(cast("int", conv.id))
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
async def interrupt(self, conv: Conversation) -> bool:
|
||||||
|
"""Cut the running turn; the reply so far still lands."""
|
||||||
|
try:
|
||||||
|
backend = self._backend(conv.agent_name)
|
||||||
|
except LookupError:
|
||||||
|
return False
|
||||||
|
return await backend.interrupt(conv.external_id)
|
||||||
|
|
||||||
async def say(self, conv: Conversation, text: str) -> dict[str, Any]:
|
async def say(self, conv: Conversation, text: str) -> dict[str, Any]:
|
||||||
runner = self._runners.get(cast("int", conv.id))
|
runner = self._runners.get(cast("int", conv.id))
|
||||||
_log.info("say[%s]: %s", conv.external_id, text[:200])
|
_log.info("say[%s]: %s", conv.external_id, text[:200])
|
||||||
@@ -96,6 +111,31 @@ class Messaging(Turns):
|
|||||||
turn_id=runner.turn_id if runner is not None else None,
|
turn_id=runner.turn_id if runner is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
|
||||||
|
) -> str:
|
||||||
|
"""Hand a file to the human: this conversation's window, else the master's."""
|
||||||
|
for target in (conv, await self.open_master()):
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
for fe in self._frontends:
|
||||||
|
if target.kind in fe.kinds and fe.sends_files:
|
||||||
|
note = await fe.send_file(
|
||||||
|
target, path, caption=caption, method=method
|
||||||
|
)
|
||||||
|
_log.info("send_file[%s]: %s", target.external_id, note)
|
||||||
|
return note
|
||||||
|
msg = (
|
||||||
|
"nowhere to send: no frontend that sends files shows this "
|
||||||
|
"conversation, and there is no open master"
|
||||||
|
)
|
||||||
|
raise SendFileError(msg)
|
||||||
|
|
||||||
|
def file_note(self) -> str:
|
||||||
|
"""What the ``send_file`` tool tells the model about roots and size."""
|
||||||
|
sender = next((f for f in self._frontends if f.sends_files), None)
|
||||||
|
return sender.file_note() if sender else "no frontend here sends files"
|
||||||
|
|
||||||
async def schedule(
|
async def schedule(
|
||||||
self,
|
self,
|
||||||
conv: Conversation,
|
conv: Conversation,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from datetime import UTC, datetime
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import String, func
|
||||||
|
from sqlalchemy import cast as sa_cast
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
|
|
||||||
from beaver_gateway.backends.transcript import (
|
from beaver_gateway.backends.transcript import (
|
||||||
@@ -23,6 +25,7 @@ from beaver_gateway.storage.models import (
|
|||||||
ConversationBinding,
|
ConversationBinding,
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
RateLimit,
|
RateLimit,
|
||||||
|
TranscriptEntry,
|
||||||
Usage,
|
Usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,6 +135,30 @@ class Rows(State):
|
|||||||
async with self._db.session() as session:
|
async with self._db.session() as session:
|
||||||
return list((await session.exec(stmt)).all())
|
return list((await session.exec(stmt)).all())
|
||||||
|
|
||||||
|
async def search(self, query: str, *, limit: int = 20) -> list[Conversation]:
|
||||||
|
needle = f"%{query}%"
|
||||||
|
by_title = select(Conversation.id).where(col(Conversation.title).ilike(needle))
|
||||||
|
by_first = select(ConversationMessage.conversation_id).where(
|
||||||
|
ConversationMessage.seq == 0,
|
||||||
|
col(ConversationMessage.content_json).ilike(needle),
|
||||||
|
)
|
||||||
|
by_entry = (
|
||||||
|
select(Conversation.id)
|
||||||
|
.join(
|
||||||
|
TranscriptEntry,
|
||||||
|
col(TranscriptEntry.session_id) == col(Conversation.session_id),
|
||||||
|
)
|
||||||
|
.where(sa_cast(col(TranscriptEntry.entry), String).ilike(needle))
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Conversation)
|
||||||
|
.where(col(Conversation.id).in_(by_title.union(by_first, by_entry)))
|
||||||
|
.order_by(col(Conversation.id).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
async with self._db.session() as session:
|
||||||
|
return list((await session.exec(stmt)).all())
|
||||||
|
|
||||||
async def bindings(self, conv: Conversation) -> list[ConversationBinding]:
|
async def bindings(self, conv: Conversation) -> list[ConversationBinding]:
|
||||||
async with self._db.session() as session:
|
async with self._db.session() as session:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
@@ -317,6 +344,7 @@ class Rows(State):
|
|||||||
async def describe(self, conv: Conversation) -> dict[str, Any]:
|
async def describe(self, conv: Conversation) -> dict[str, Any]:
|
||||||
out = self.public(conv)
|
out = self.public(conv)
|
||||||
out["title"] = await self.implied_title(conv)
|
out["title"] = await self.implied_title(conv)
|
||||||
|
out["context_tokens"] = await self.context_tokens(conv)
|
||||||
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
|
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
|
||||||
out["parent"] = parent.external_id if parent is not None else None
|
out["parent"] = parent.external_id if parent is not None else None
|
||||||
out["bindings"] = [
|
out["bindings"] = [
|
||||||
@@ -359,6 +387,25 @@ class Rows(State):
|
|||||||
).first()
|
).first()
|
||||||
return context_of(row)
|
return context_of(row)
|
||||||
|
|
||||||
|
async def context_tokens_by_id(self, ids: Iterable[str]) -> dict[str, int]:
|
||||||
|
wanted = list(ids)
|
||||||
|
if not wanted:
|
||||||
|
return {}
|
||||||
|
latest = (
|
||||||
|
select(func.max(Usage.id))
|
||||||
|
.where(col(Usage.conversation_id).in_(wanted))
|
||||||
|
.group_by(Usage.conversation_id)
|
||||||
|
)
|
||||||
|
async with self._db.session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.exec(select(Usage).where(col(Usage.id).in_(latest)))
|
||||||
|
).all()
|
||||||
|
return {
|
||||||
|
cast("str", row.conversation_id): context_of(row)
|
||||||
|
for row in rows
|
||||||
|
if row.conversation_id
|
||||||
|
}
|
||||||
|
|
||||||
async def usage_tokens(self, since: datetime) -> int:
|
async def usage_tokens(self, since: datetime) -> int:
|
||||||
async with self._db.session() as session:
|
async with self._db.session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
@@ -427,7 +474,9 @@ class Rows(State):
|
|||||||
return await load_messages(
|
return await load_messages(
|
||||||
session, conversation_id=cast("int", conv.id)
|
session, conversation_id=cast("int", conv.id)
|
||||||
)
|
)
|
||||||
return messages_from_entries(cast("Any", await self.entries(conv)))
|
return messages_from_entries(
|
||||||
|
cast("Any", await self.entries(conv)), with_ts=True
|
||||||
|
)
|
||||||
|
|
||||||
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||||
if conv.session_id is None:
|
if conv.session_id is None:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class Spawning(Messaging):
|
|||||||
origin: str = "api",
|
origin: str = "api",
|
||||||
binding: tuple[str, str] | None = None,
|
binding: tuple[str, str] | None = None,
|
||||||
flags: dict[str, Any] | None = None,
|
flags: dict[str, Any] | None = None,
|
||||||
|
attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> Conversation:
|
) -> Conversation:
|
||||||
"""Create a conversation in a window and queue its seed.
|
"""Create a conversation in a window and queue its seed.
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ class Spawning(Messaging):
|
|||||||
priority="user",
|
priority="user",
|
||||||
origin=f"seed:{seed}" if seed == "brief" else origin,
|
origin=f"seed:{seed}" if seed == "brief" else origin,
|
||||||
text=await self.seed_text(ctx, window=window),
|
text=await self.seed_text(ctx, window=window),
|
||||||
|
attachments=attachments,
|
||||||
)
|
)
|
||||||
self._ensure_worker(cast("int", conv.id))
|
self._ensure_worker(cast("int", conv.id))
|
||||||
return conv
|
return conv
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ class State:
|
|||||||
self._tasks = set()
|
self._tasks = set()
|
||||||
self._idle_task = None
|
self._idle_task = None
|
||||||
self.scheduler = None
|
self.scheduler = None
|
||||||
|
for backend in backends.values():
|
||||||
|
if hasattr(backend, "waker"):
|
||||||
|
backend.waker = self._woken
|
||||||
|
|
||||||
|
async def _woken(self, key: str, note: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self) -> Database:
|
def db(self) -> Database:
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class UserSaid:
|
|||||||
title: str | None
|
title: str | None
|
||||||
text: str
|
text: str
|
||||||
at: datetime
|
at: datetime
|
||||||
|
agent: str = ""
|
||||||
|
"""Agent whose turn it is; a setup with several tells them apart by it."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -77,6 +79,8 @@ class ConversationTexts:
|
|||||||
)
|
)
|
||||||
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
|
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
|
||||||
bundle_header: str = "[injects accumulated since {since}; not the user]"
|
bundle_header: str = "[injects accumulated since {since}; not the user]"
|
||||||
|
task_prompt: str = "[subagent reported back: {note}]"
|
||||||
|
"""Stands in for the user's words in mirrors of a turn a subagent started."""
|
||||||
interrupted: str = "interrupted"
|
interrupted: str = "interrupted"
|
||||||
answered: str = "The user answered: {answer}"
|
answered: str = "The user answered: {answer}"
|
||||||
unanswered: str = (
|
unanswered: str = (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from claude_agent_sdk import create_sdk_mcp_server, tool
|
|||||||
|
|
||||||
from beaver_gateway.conversations.injects import URGENCY
|
from beaver_gateway.conversations.injects import URGENCY
|
||||||
from beaver_gateway.conversations.kinds import as_kind
|
from beaver_gateway.conversations.kinds import as_kind
|
||||||
|
from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError
|
||||||
from beaver_gateway.security.redact import redact_data
|
from beaver_gateway.security.redact import redact_data
|
||||||
|
|
||||||
URGENCY_HELP = (
|
URGENCY_HELP = (
|
||||||
@@ -42,7 +43,23 @@ SAY_IN_USER_TURN = (
|
|||||||
"reply text reaches them by itself - put what you wanted to say into the "
|
"reply text reaches them by itself - put what you wanted to say into the "
|
||||||
"reply instead of repeating it here"
|
"reply instead of repeating it here"
|
||||||
)
|
)
|
||||||
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat")
|
TOOL_NAMES = (
|
||||||
|
"read_conversation",
|
||||||
|
"spawn",
|
||||||
|
"say",
|
||||||
|
"schedule",
|
||||||
|
"inject",
|
||||||
|
"close_chat",
|
||||||
|
"send_file",
|
||||||
|
)
|
||||||
|
SEND_FILE_HELP = (
|
||||||
|
"Send a file from disk to the human, into this conversation's window (the "
|
||||||
|
"master's if it has none): {note}. `method` is how Telegram renders it; "
|
||||||
|
"`auto` reads the extension - image is a photo, mp3/flac/m4a audio, ogg "
|
||||||
|
"voice, mp4/mov video, anything else a document. Photo and video are "
|
||||||
|
"recompressed, so pass `document` when the bytes must arrive untouched. "
|
||||||
|
"`caption` takes the same markdown as your replies."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_tool_server(
|
def build_tool_server(
|
||||||
@@ -232,6 +249,33 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|||||||
)
|
)
|
||||||
return _text(f"queued #{item.id}")
|
return _text(f"queued #{item.id}")
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
"send_file",
|
||||||
|
SEND_FILE_HELP.format(note=conversations.file_note()),
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "absolute path on disk"},
|
||||||
|
"caption": {"type": "string"},
|
||||||
|
"method": {"type": "string", "enum": [AUTO, *METHODS], "default": AUTO},
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def send_file(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
conv = await current()
|
||||||
|
try:
|
||||||
|
return _text(
|
||||||
|
await conversations.send_file(
|
||||||
|
conv,
|
||||||
|
str(args["path"]),
|
||||||
|
caption=str(args.get("caption") or ""),
|
||||||
|
method=str(args.get("method") or AUTO),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except SendFileError as exc:
|
||||||
|
return _error(str(exc))
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
"close_chat",
|
"close_chat",
|
||||||
"Close this deep chat once the current reply is finished: the "
|
"Close this deep chat once the current reply is finished: the "
|
||||||
@@ -248,7 +292,7 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|||||||
return _error(str(exc))
|
return _error(str(exc))
|
||||||
return _text("ok: the chat closes after this reply")
|
return _text("ok: the chat closes after this reply")
|
||||||
|
|
||||||
return [read_conversation, spawn, say, schedule, inject, close_chat]
|
return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
|
||||||
|
|
||||||
|
|
||||||
def _text(text: str) -> dict[str, Any]:
|
def _text(text: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from beaver_gateway.events.stream import MessageStreamEvent
|
from beaver_gateway.events.stream import MessageStreamEvent
|
||||||
|
|
||||||
__all__ = ["Turns"]
|
__all__ = ["Turns", "content_of", "image_block"]
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,8 +54,15 @@ class Turns(Seeds):
|
|||||||
tools: bool = True,
|
tools: bool = True,
|
||||||
turn_id: str | None = None,
|
turn_id: str | None = None,
|
||||||
item_origin: str | None = None,
|
item_origin: str | None = None,
|
||||||
|
shown: str | None = None,
|
||||||
|
attach: bool = False,
|
||||||
) -> AsyncIterator[MessageStreamEvent]:
|
) -> AsyncIterator[MessageStreamEvent]:
|
||||||
"""Run one turn under the conversation's lock; the only path to the backend."""
|
"""Run one turn under the conversation's lock; the only path to the backend.
|
||||||
|
|
||||||
|
``shown`` is what frontends display as the turn's prompt (the user's
|
||||||
|
words, not the envelope). ``attach`` sends nothing: the turn is one
|
||||||
|
the live session started by itself, a subagent reporting back.
|
||||||
|
"""
|
||||||
row_id = cast("int", conv.id)
|
row_id = cast("int", conv.id)
|
||||||
runner = self._runner(row_id)
|
runner = self._runner(row_id)
|
||||||
backend = self._backend(conv.agent_name)
|
backend = self._backend(conv.agent_name)
|
||||||
@@ -63,7 +72,7 @@ class Turns(Seeds):
|
|||||||
async with runner.lock:
|
async with runner.lock:
|
||||||
runner.turn_id = turn_id
|
runner.turn_id = turn_id
|
||||||
runner.origin = origin
|
runner.origin = origin
|
||||||
runner.text = _prompt_preview(messages)
|
runner.text = shown if shown is not None else _prompt_preview(messages)
|
||||||
runner.started_at = datetime.now(UTC)
|
runner.started_at = datetime.now(UTC)
|
||||||
runner.tools = {}
|
runner.tools = {}
|
||||||
await self._mark_running(conv, turn_id)
|
await self._mark_running(conv, turn_id)
|
||||||
@@ -79,18 +88,28 @@ class Turns(Seeds):
|
|||||||
stop = "error"
|
stop = "error"
|
||||||
cut = False
|
cut = False
|
||||||
try:
|
try:
|
||||||
events = backend.complete(
|
observer = self._observer(conv, runner, turn_id, origin)
|
||||||
agent=self._claude_agent(conv.agent_name),
|
events = (
|
||||||
messages=messages,
|
backend.attach(
|
||||||
conversation_id=conv.external_id,
|
key=conv.external_id,
|
||||||
session_id=resume if use_session else None,
|
capture=capture,
|
||||||
reseed=not use_session,
|
observer=observer,
|
||||||
capture=capture,
|
turn_id=turn_id,
|
||||||
kind=conv.kind,
|
)
|
||||||
pinned=conv.kind == "master",
|
if attach
|
||||||
tools=tools,
|
else backend.complete(
|
||||||
observer=self._observer(conv, runner, turn_id, origin),
|
agent=self._claude_agent(conv.agent_name),
|
||||||
turn_id=turn_id,
|
messages=messages,
|
||||||
|
conversation_id=conv.external_id,
|
||||||
|
session_id=resume if use_session else None,
|
||||||
|
reseed=not use_session,
|
||||||
|
capture=capture,
|
||||||
|
kind=conv.kind,
|
||||||
|
pinned=conv.kind == "master",
|
||||||
|
tools=tools,
|
||||||
|
observer=observer,
|
||||||
|
turn_id=turn_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
async for event in events:
|
async for event in events:
|
||||||
yield event
|
yield event
|
||||||
@@ -125,18 +144,23 @@ class Turns(Seeds):
|
|||||||
tools: bool = True,
|
tools: bool = True,
|
||||||
turn_id: str | None = None,
|
turn_id: str | None = None,
|
||||||
item_origin: str | None = None,
|
item_origin: str | None = None,
|
||||||
|
attachments: Sequence[dict[str, Any]] | None = None,
|
||||||
|
shown: str | None = None,
|
||||||
|
attach: bool = False,
|
||||||
) -> tuple[str, TurnCapture]:
|
) -> tuple[str, TurnCapture]:
|
||||||
capture = TurnCapture()
|
capture = TurnCapture()
|
||||||
acc = StreamAccumulator()
|
acc = StreamAccumulator()
|
||||||
agent = self._claude_agent(conv.agent_name)
|
agent = self._claude_agent(conv.agent_name)
|
||||||
async for event in self.turn(
|
async for event in self.turn(
|
||||||
conv,
|
conv,
|
||||||
messages=[{"role": "user", "content": text}],
|
messages=[{"role": "user", "content": content_of(text, attachments)}],
|
||||||
origin=origin,
|
origin=origin,
|
||||||
capture=capture,
|
capture=capture,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
item_origin=item_origin,
|
item_origin=item_origin,
|
||||||
|
shown=shown,
|
||||||
|
attach=attach,
|
||||||
):
|
):
|
||||||
acc.feed(event)
|
acc.feed(event)
|
||||||
message = acc.finalize(model=agent.model)
|
message = acc.finalize(model=agent.model)
|
||||||
@@ -157,6 +181,47 @@ class Turns(Seeds):
|
|||||||
runner = self._runners.get(cast("int", conv.id))
|
runner = self._runners.get(cast("int", conv.id))
|
||||||
return runner.origin if runner is not None and runner.turn_id else None
|
return runner.origin if runner is not None and runner.turn_id else None
|
||||||
|
|
||||||
|
async def _woken(self, key: str, note: str) -> None:
|
||||||
|
"""The live session of ``key`` started a turn by itself; run it next."""
|
||||||
|
conv = await self.get(key)
|
||||||
|
if conv is None:
|
||||||
|
_log.warning("self-started turn on unknown conversation %s: %s", key, note)
|
||||||
|
return
|
||||||
|
_log.info("conversation %s woke by itself: %s", key, note)
|
||||||
|
self._ensure_worker(cast("int", conv.id))
|
||||||
|
|
||||||
|
async def _run_pending(self, conv: Conversation) -> None:
|
||||||
|
turn_id = f"turn_{uuid4().hex[:12]}"
|
||||||
|
note = self._backend(conv.agent_name).pending_note(conv.external_id) or ""
|
||||||
|
try:
|
||||||
|
text, _ = await self.run_text_turn(
|
||||||
|
conv,
|
||||||
|
note,
|
||||||
|
origin="task",
|
||||||
|
turn_id=turn_id,
|
||||||
|
item_origin="task",
|
||||||
|
shown=note,
|
||||||
|
attach=True,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception(
|
||||||
|
"self-started turn %s on %s failed", turn_id, conv.external_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not text.strip():
|
||||||
|
return
|
||||||
|
self._bus.publish(
|
||||||
|
"reply",
|
||||||
|
conversation_id=conv.external_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
item=None,
|
||||||
|
item_origin="task",
|
||||||
|
source="task",
|
||||||
|
prompt=self._texts.task_prompt.format(note=note),
|
||||||
|
user_text=None,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
|
||||||
def _ensure_worker(self, row_id: int) -> None:
|
def _ensure_worker(self, row_id: int) -> None:
|
||||||
runner = self._runner(row_id)
|
runner = self._runner(row_id)
|
||||||
runner.wake.set()
|
runner.wake.set()
|
||||||
@@ -167,6 +232,10 @@ class Turns(Seeds):
|
|||||||
async def _worker(self, row_id: int) -> None:
|
async def _worker(self, row_id: int) -> None:
|
||||||
runner = self._runner(row_id)
|
runner = self._runner(row_id)
|
||||||
while True:
|
while True:
|
||||||
|
conv = await self.get_row(row_id)
|
||||||
|
if conv is not None and self._has_pending(conv):
|
||||||
|
await self._run_pending(conv)
|
||||||
|
continue
|
||||||
items = await self._queue.pending(row_id)
|
items = await self._queue.pending(row_id)
|
||||||
batch, wait = self._pick(items)
|
batch, wait = self._pick(items)
|
||||||
if batch is None:
|
if batch is None:
|
||||||
@@ -224,7 +293,13 @@ class Turns(Seeds):
|
|||||||
prompt = f"{seed}\n\n{prompt}"
|
prompt = f"{seed}\n\n{prompt}"
|
||||||
try:
|
try:
|
||||||
text, capture = await self.run_text_turn(
|
text, capture = await self.run_text_turn(
|
||||||
conv, prompt, origin=origin, turn_id=turn_id, item_origin=head.origin
|
conv,
|
||||||
|
prompt,
|
||||||
|
origin=origin,
|
||||||
|
turn_id=turn_id,
|
||||||
|
item_origin=head.origin,
|
||||||
|
attachments=head.attachments if origin == "user" else None,
|
||||||
|
shown=head.text if origin == "user" else None,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
|
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
|
||||||
@@ -246,6 +321,12 @@ class Turns(Seeds):
|
|||||||
text=text,
|
text=text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _has_pending(self, conv: Conversation) -> bool:
|
||||||
|
try:
|
||||||
|
return self._backend(conv.agent_name).has_pending(conv.external_id)
|
||||||
|
except LookupError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _bundle(self, items: Sequence[InjectQueueItem]) -> str:
|
def _bundle(self, items: Sequence[InjectQueueItem]) -> str:
|
||||||
lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))]
|
lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))]
|
||||||
lines.extend(f"- [{i.origin}] {i.text}" for i in items)
|
lines.extend(f"- [{i.origin}] {i.text}" for i in items)
|
||||||
@@ -255,9 +336,11 @@ class Turns(Seeds):
|
|||||||
if self._envelope is None:
|
if self._envelope is None:
|
||||||
return None
|
return None
|
||||||
if conv.kind == "master":
|
if conv.kind == "master":
|
||||||
return self._envelope.build(text=text, kind="master")
|
return self._envelope.build(text=text, kind="master", agent=conv.agent_name)
|
||||||
if conv.kind == "branch":
|
if conv.kind == "branch":
|
||||||
return self._envelope.recall_only(text=text, kind="branch")
|
return self._envelope.recall_only(
|
||||||
|
text=text, kind="branch", agent=conv.agent_name
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _note_user(self, conv: Conversation, text: str) -> None:
|
async def _note_user(self, conv: Conversation, text: str) -> None:
|
||||||
@@ -269,6 +352,7 @@ class Turns(Seeds):
|
|||||||
title=conv.title,
|
title=conv.title,
|
||||||
text=text,
|
text=text,
|
||||||
at=datetime.now(UTC),
|
at=datetime.now(UTC),
|
||||||
|
agent=conv.agent_name,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = self._user_sink(message)
|
result = self._user_sink(message)
|
||||||
@@ -402,6 +486,33 @@ class Turns(Seeds):
|
|||||||
await self._update(conv, apply)
|
await self._update(conv, apply)
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def content_of(
|
||||||
|
text: str, attachments: Sequence[dict[str, Any]] | None
|
||||||
|
) -> str | list[dict[str, Any]]:
|
||||||
|
"""Plain text, or text plus the images that ride with it."""
|
||||||
|
blocks = [b for a in attachments or () if (b := image_block(a)) is not None]
|
||||||
|
if not blocks:
|
||||||
|
return text
|
||||||
|
return [{"type": "text", "text": text}, *blocks]
|
||||||
|
|
||||||
|
|
||||||
|
def image_block(attachment: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
media_type = str(attachment.get("media_type") or "")
|
||||||
|
path = Path(str(attachment.get("path") or ""))
|
||||||
|
if not media_type.startswith("image/") or not path.is_file():
|
||||||
|
return None
|
||||||
|
if path.stat().st_size > IMAGE_MAX_BYTES:
|
||||||
|
return None
|
||||||
|
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||||
|
return {
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": media_type, "data": data},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
|
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from sqlmodel import col, select
|
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.injects import URGENCY
|
||||||
from beaver_gateway.conversations.kinds import Kind, as_kind
|
from beaver_gateway.conversations.kinds import Kind, as_kind
|
||||||
from beaver_gateway.conversations.service import SEEDS, implied_title
|
from beaver_gateway.conversations.service import SEEDS, implied_title
|
||||||
@@ -31,7 +35,7 @@ from beaver_gateway.frontends.sse import (
|
|||||||
)
|
)
|
||||||
from beaver_gateway.frontends.urls import frontend_url
|
from beaver_gateway.frontends.urls import frontend_url
|
||||||
from beaver_gateway.security import audit
|
from beaver_gateway.security import audit
|
||||||
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
|
from beaver_gateway.security.auth import hash_token
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
create_token,
|
create_token,
|
||||||
list_audit_records,
|
list_audit_records,
|
||||||
@@ -45,6 +49,7 @@ from beaver_gateway.storage.models import (
|
|||||||
Token,
|
Token,
|
||||||
Usage,
|
Usage,
|
||||||
)
|
)
|
||||||
|
from beaver_gateway.vault.links import LinkIndex
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Iterable, Sequence
|
from collections.abc import AsyncIterator, Iterable, Sequence
|
||||||
@@ -70,6 +75,10 @@ WINDOWS: dict[str, timedelta] = {
|
|||||||
MEMORY_MAX_DEPTH = 12
|
MEMORY_MAX_DEPTH = 12
|
||||||
MEMORY_MAX_FILE = 2_000_000
|
MEMORY_MAX_FILE = 2_000_000
|
||||||
MEMORY_MAX_ENTRIES = 5000
|
MEMORY_MAX_ENTRIES = 5000
|
||||||
|
SEARCH_MIN = 2
|
||||||
|
GRAPH_LIMIT = 120
|
||||||
|
SEARCH_LIMIT = 20
|
||||||
|
SNIPPET_MAX = 160
|
||||||
|
|
||||||
|
|
||||||
class ApiFrontend(Frontend):
|
class ApiFrontend(Frontend):
|
||||||
@@ -85,12 +94,14 @@ class ApiFrontend(Frontend):
|
|||||||
deep_agent: str | None = None,
|
deep_agent: str | None = None,
|
||||||
job_agent: str | None = None,
|
job_agent: str | None = None,
|
||||||
memory_root: Path | None = None,
|
memory_root: Path | None = None,
|
||||||
|
vault_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.master_agent = master_agent
|
self.master_agent = master_agent
|
||||||
self.branch_agent = branch_agent
|
self.branch_agent = branch_agent
|
||||||
self.deep_agent = deep_agent
|
self.deep_agent = deep_agent
|
||||||
self.job_agent = job_agent
|
self.job_agent = job_agent
|
||||||
self.memory_root = memory_root.resolve() if memory_root is not None else None
|
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
|
self._app: FastAPI | None = None
|
||||||
|
|
||||||
def agent_for(self, kind: Kind) -> str | 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:
|
if runtime.conversations is None or runtime.bus is None:
|
||||||
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self._app = build_app(runtime, memory_root=self.memory_root)
|
self._app = build_app(
|
||||||
|
runtime, memory_root=self.memory_root, vault_root=self.vault_root
|
||||||
|
)
|
||||||
|
|
||||||
def app(self) -> FastAPI | None:
|
def app(self) -> FastAPI | None:
|
||||||
return self._app
|
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")
|
app = FastAPI(title="beaver-gateway / API")
|
||||||
|
links = LinkIndex(vault_root) if vault_root is not None else None
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -198,6 +217,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
||||||
},
|
},
|
||||||
"path": fe.path,
|
"path": fe.path,
|
||||||
|
"scope": fe.scope,
|
||||||
"url": frontend_url(request, runtime, fe),
|
"url": frontend_url(request, runtime, fe),
|
||||||
}
|
}
|
||||||
for fe in runtime.frontends
|
for fe in runtime.frontends
|
||||||
@@ -218,17 +238,49 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
firsts = await conversations.first_user_texts(
|
firsts = await conversations.first_user_texts(
|
||||||
cast("int", r.id) for r in rows if not r.title
|
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 {
|
return {
|
||||||
"conversations": [
|
"conversations": [
|
||||||
{
|
{
|
||||||
**conversations.public(r),
|
**conversations.public(r),
|
||||||
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
|
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
|
||||||
"last_item": _queue_item(latest.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
|
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)
|
@app.post("/conversations", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_conversation(request: Request) -> dict[str, Any]:
|
async def create_conversation(request: Request) -> dict[str, Any]:
|
||||||
token = await require_token(request, runtime, scope=SCOPE)
|
token = await require_token(request, runtime, scope=SCOPE)
|
||||||
@@ -301,6 +353,45 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
conv = await conv_of(public_id)
|
conv = await conv_of(public_id)
|
||||||
return {"id": conv.external_id, "messages": await conversations.history(conv)}
|
return {"id": conv.external_id, "messages": await conversations.history(conv)}
|
||||||
|
|
||||||
|
@app.get("/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")
|
@app.get("/conversations/{public_id}/entries")
|
||||||
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
|
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=SCOPE)
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
@@ -647,22 +738,51 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
await require_token(request, runtime, scope=SCOPE)
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
rows = await conversations.rate_limits(limit=200)
|
rows = await conversations.rate_limits(limit=200)
|
||||||
latest: dict[str, RateLimit] = {}
|
latest: dict[str, RateLimit] = {}
|
||||||
|
unified: dict[str, tuple[RateLimit, dict[str, Any]]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
latest.setdefault(row.window, row)
|
latest.setdefault(row.window, row)
|
||||||
|
for name, info in _unified_windows(row).items():
|
||||||
|
unified.setdefault(name, (row, info))
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
windows = []
|
windows = []
|
||||||
for window, row in latest.items():
|
for window in dict.fromkeys([*latest, *unified]):
|
||||||
length = WINDOWS.get(window)
|
row = latest.get(window)
|
||||||
since = (
|
seen = unified.get(window)
|
||||||
_aware(row.resets_at) - length
|
public = _limit_public(row) if row else _limit_blank(window, seen)
|
||||||
if length is not None and row.resets_at is not None
|
resets = row.resets_at if row else None
|
||||||
else _aware(row.ts)
|
if seen and _newer_figure(seen, row):
|
||||||
)
|
carrier, info = seen
|
||||||
|
public["utilization"] = info.get("utilization")
|
||||||
|
public["reported_at"] = _iso(carrier.ts)
|
||||||
|
resets = _unix_dt(info.get("resetsAt")) or resets
|
||||||
|
public["resets_at"] = _iso(resets)
|
||||||
|
length = _window_length(window)
|
||||||
|
anchor = row if row is not None else (seen[0] if seen else None)
|
||||||
|
if length is not None and resets is not None:
|
||||||
|
since = _aware(resets) - length
|
||||||
|
else:
|
||||||
|
since = _aware(anchor.ts) if anchor else now
|
||||||
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
||||||
gateway["since"] = since.isoformat(timespec="seconds")
|
gateway["since"] = since.isoformat(timespec="seconds")
|
||||||
windows.append({**_limit_public(row), "gateway": gateway})
|
known = next(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if r.window == window
|
||||||
|
and r.utilization is not None
|
||||||
|
and _aware(r.ts) >= since
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
windows.append(
|
||||||
|
{
|
||||||
|
**public,
|
||||||
|
"gateway": gateway,
|
||||||
|
"last_known": _limit_public(known) if known else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
windows.sort(
|
windows.sort(
|
||||||
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
|
key=lambda w: (_window_length(w["window"]) or timedelta.max, w["window"])
|
||||||
)
|
)
|
||||||
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
|
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
|
||||||
|
|
||||||
@@ -695,6 +815,19 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/scopes")
|
||||||
|
async def scopes(request: Request) -> dict[str, Any]:
|
||||||
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
by_frontend = {
|
||||||
|
fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"scopes": [
|
||||||
|
{"scope": s, "frontend": by_frontend.get(s)}
|
||||||
|
for s in sorted(runtime.scopes)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/tokens")
|
@app.get("/tokens")
|
||||||
async def tokens(request: Request) -> dict[str, Any]:
|
async def tokens(request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
@@ -709,7 +842,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
|||||||
data = await body_of(request)
|
data = await body_of(request)
|
||||||
name = text_of(data, "name").strip()
|
name = text_of(data, "name").strip()
|
||||||
scope = str(data.get("scope") or "*")
|
scope = str(data.get("scope") or "*")
|
||||||
if scope not in VALID_SCOPES:
|
if scope not in runtime.scopes:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
||||||
plaintext = secrets.token_urlsafe(32)
|
plaintext = secrets.token_urlsafe(32)
|
||||||
async with runtime.db.session() as session:
|
async with runtime.db.session() as session:
|
||||||
@@ -944,6 +1077,59 @@ def _queue_item(item: InjectQueueItem | None) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _window_length(name: str) -> timedelta | None:
|
||||||
|
if name.startswith("five_hour"):
|
||||||
|
return timedelta(hours=5)
|
||||||
|
if "seven_day" in name:
|
||||||
|
return timedelta(days=7)
|
||||||
|
return WINDOWS.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _unix_dt(value: Any) -> datetime | None:
|
||||||
|
if isinstance(value, int | float) and value > 0:
|
||||||
|
return datetime.fromtimestamp(value, tz=UTC)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unified_windows(row: RateLimit) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Every window the event carried a figure for, not just the one it was about."""
|
||||||
|
raw = row.raw if isinstance(row.raw, dict) else {}
|
||||||
|
found = raw.get("unifiedWindows")
|
||||||
|
if not isinstance(found, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(name): info
|
||||||
|
for name, info in found.items()
|
||||||
|
if isinstance(info, dict) and info.get("utilization") is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _newer_figure(
|
||||||
|
seen: tuple[RateLimit, dict[str, Any]], row: RateLimit | None
|
||||||
|
) -> bool:
|
||||||
|
if row is None or row.utilization is None:
|
||||||
|
return True
|
||||||
|
return seen[0].id != row.id and _aware(seen[0].ts) > _aware(row.ts)
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_blank(
|
||||||
|
window: str, seen: tuple[RateLimit, dict[str, Any]] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
carrier = seen[0] if seen else None
|
||||||
|
return {
|
||||||
|
"id": None,
|
||||||
|
"ts": _iso(carrier.ts) if carrier else None,
|
||||||
|
"window": window,
|
||||||
|
"status": "allowed",
|
||||||
|
"utilization": None,
|
||||||
|
"resets_at": None,
|
||||||
|
"overage_status": None,
|
||||||
|
"overage_resets_at": None,
|
||||||
|
"agent": carrier.agent_name if carrier else None,
|
||||||
|
"session_id": carrier.session_id if carrier else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _limit_public(row: RateLimit) -> dict[str, Any]:
|
def _limit_public(row: RateLimit) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
@@ -993,6 +1179,35 @@ def _memory_path(root: Path, raw: str) -> Path:
|
|||||||
return target
|
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]]:
|
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
|
||||||
if depth > MEMORY_MAX_DEPTH:
|
if depth > MEMORY_MAX_DEPTH:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ from abc import ABC, abstractmethod
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from beaver_gateway.frontends.files import SendFileError
|
||||||
|
from beaver_gateway.security.auth import BUILTIN_SCOPES
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from starlette.types import ASGIApp
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
@@ -54,6 +58,10 @@ class GatewayRuntime:
|
|||||||
scheduler: Any = None
|
scheduler: Any = None
|
||||||
public_url: str | None = None
|
public_url: str | None = None
|
||||||
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
|
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
|
||||||
|
scopes: frozenset[str] = BUILTIN_SCOPES
|
||||||
|
"""Every token scope this gateway knows: the builtins plus each frontend's."""
|
||||||
|
send_file_roots: tuple[Path, ...] = ()
|
||||||
|
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
|
||||||
|
|
||||||
|
|
||||||
class Frontend(ABC):
|
class Frontend(ABC):
|
||||||
@@ -64,6 +72,10 @@ class Frontend(ABC):
|
|||||||
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
|
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
|
||||||
``landing`` marks the app that ``/`` redirects to.
|
``landing`` marks the app that ``/`` redirects to.
|
||||||
|
|
||||||
|
``scope`` is the token scope this frontend's routes are gated by; a
|
||||||
|
frontend that names its own scope gets keys nobody else's token opens,
|
||||||
|
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
|
||||||
|
|
||||||
A frontend that shows conversations declares ``name`` (the binding
|
A frontend that shows conversations declares ``name`` (the binding
|
||||||
key) and ``kinds`` (which conversation kinds it shows). The first
|
key) and ``kinds`` (which conversation kinds it shows). The first
|
||||||
frontend whose ``materialize`` returns a binding is the *home* of
|
frontend whose ``materialize`` returns a binding is the *home* of
|
||||||
@@ -76,6 +88,9 @@ class Frontend(ABC):
|
|||||||
kinds: tuple[Kind, ...] = ()
|
kinds: tuple[Kind, ...] = ()
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
landing: bool = False
|
landing: bool = False
|
||||||
|
scope: str | None = None
|
||||||
|
sends_files: bool = False
|
||||||
|
"""Whether ``send_file`` reaches a human through this frontend."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||||
@@ -95,3 +110,19 @@ class Frontend(ABC):
|
|||||||
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
|
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
|
||||||
"""Show in the window that the conversation is over (a renamed topic)."""
|
"""Show in the window that the conversation is over (a renamed topic)."""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self,
|
||||||
|
conv: Conversation, # noqa: ARG002
|
||||||
|
path: str, # noqa: ARG002
|
||||||
|
*,
|
||||||
|
caption: str = "", # noqa: ARG002
|
||||||
|
method: str = "auto", # noqa: ARG002
|
||||||
|
) -> str:
|
||||||
|
"""Put a file in this conversation's window; the answer goes to the model."""
|
||||||
|
msg = f"frontend {self.name!r} cannot send files"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
|
||||||
|
def file_note(self) -> str:
|
||||||
|
"""Roots and ceiling, for the tool description the model reads."""
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""What an agent may hand to a human: an allowed path, a ceiling, a method.
|
||||||
|
|
||||||
|
The ceiling and the method names belong to the frontend's API; the check
|
||||||
|
lives here so every frontend refuses the same way, in words the model reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = ["AUTO", "DEFAULTS", "METHODS", "SendFileError", "SendFiles", "method_for"]
|
||||||
|
|
||||||
|
AUTO = "auto"
|
||||||
|
METHODS = ("document", "photo", "audio", "voice", "video", "video_note", "animation")
|
||||||
|
|
||||||
|
_BY_SUFFIX = {
|
||||||
|
".jpg": "photo",
|
||||||
|
".jpeg": "photo",
|
||||||
|
".png": "photo",
|
||||||
|
".webp": "photo",
|
||||||
|
".gif": "animation",
|
||||||
|
".mp3": "audio",
|
||||||
|
".flac": "audio",
|
||||||
|
".m4a": "audio",
|
||||||
|
".wav": "audio",
|
||||||
|
".aac": "audio",
|
||||||
|
".ogg": "voice",
|
||||||
|
".oga": "voice",
|
||||||
|
".opus": "voice",
|
||||||
|
".mp4": "video",
|
||||||
|
".m4v": "video",
|
||||||
|
".mov": "video",
|
||||||
|
}
|
||||||
|
_BY_TYPE = {"image": "photo", "audio": "audio", "video": "video"}
|
||||||
|
|
||||||
|
|
||||||
|
class SendFileError(Exception):
|
||||||
|
"""Why a file was not sent, worded for the model that asked."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SendFiles:
|
||||||
|
"""Which files a frontend accepts from an agent and how big they may be."""
|
||||||
|
|
||||||
|
roots: tuple[Path, ...] = ()
|
||||||
|
"""Directories a path must resolve inside; nothing outside is sendable."""
|
||||||
|
max_bytes: int = 50 * 1024 * 1024
|
||||||
|
"""Upload ceiling of the API behind the frontend; the Bot API gives 50 MB."""
|
||||||
|
|
||||||
|
def with_roots(self, *roots: Path) -> SendFiles:
|
||||||
|
return replace(self, roots=tuple(dict.fromkeys((*self.roots, *roots))))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def note(self) -> str:
|
||||||
|
"""Roots and ceiling as one line for the tool description."""
|
||||||
|
where = ", ".join(str(r) for r in self.roots) or "none, so nothing is sendable"
|
||||||
|
return f"paths under {where}, up to {mb(self.max_bytes)} MB"
|
||||||
|
|
||||||
|
def resolve(self, raw: str) -> Path:
|
||||||
|
path = Path(raw.strip()).expanduser().resolve()
|
||||||
|
if not any(path.is_relative_to(r.expanduser().resolve()) for r in self.roots):
|
||||||
|
msg = f"{path} is outside the allowed roots ({self.note}); copy it there"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
if not path.is_file():
|
||||||
|
msg = f"{path} is not a file"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size > self.max_bytes:
|
||||||
|
msg = (
|
||||||
|
f"{path.name} is {mb(size)} MB, over the {mb(self.max_bytes)} MB limit"
|
||||||
|
)
|
||||||
|
raise SendFileError(msg)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULTS = SendFiles()
|
||||||
|
|
||||||
|
|
||||||
|
def method_for(path: Path, method: str = AUTO) -> str:
|
||||||
|
if method and method != AUTO:
|
||||||
|
if method not in METHODS:
|
||||||
|
msg = f"unknown method {method!r}; one of: {', '.join((AUTO, *METHODS))}"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
return method
|
||||||
|
guessed = _BY_SUFFIX.get(path.suffix.lower())
|
||||||
|
if guessed:
|
||||||
|
return guessed
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
return _BY_TYPE.get(mime.split("/")[0], "document") if mime else "document"
|
||||||
|
|
||||||
|
|
||||||
|
def mb(size: int) -> str:
|
||||||
|
return f"{round(size / 1024 / 1024, 2):g}"
|
||||||
@@ -48,14 +48,19 @@ def render_assistant_text(text: str) -> str:
|
|||||||
def render_assistant_message(message: Message) -> str:
|
def render_assistant_message(message: Message) -> str:
|
||||||
"""Render an assistant ``Message`` into a turn block.
|
"""Render an assistant ``Message`` into a turn block.
|
||||||
|
|
||||||
Tool-use blocks render to nothing; only text and thinking content reach
|
Tool-use blocks render to nothing; of the thinking blocks between two
|
||||||
the file (see :mod:`.parser` for how the reverse strip works).
|
texts only the first one reaches the file.
|
||||||
"""
|
"""
|
||||||
parts: list[str] = ["### Assistant:", ""]
|
parts: list[str] = ["### Assistant:", ""]
|
||||||
|
after_thinking = False
|
||||||
for block in message.content:
|
for block in message.content:
|
||||||
|
thinking = isinstance(block, ThinkingBlock)
|
||||||
|
if thinking and after_thinking:
|
||||||
|
continue
|
||||||
lines = list(_render_block(block))
|
lines = list(_render_block(block))
|
||||||
if not lines:
|
if not lines:
|
||||||
continue
|
continue
|
||||||
|
after_thinking = thinking
|
||||||
parts.extend(lines)
|
parts.extend(lines)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
return "\n".join(parts).rstrip() + "\n"
|
return "\n".join(parts).rstrip() + "\n"
|
||||||
|
|||||||
@@ -47,12 +47,17 @@ class Draft:
|
|||||||
self._interval = interval
|
self._interval = interval
|
||||||
self.status = status
|
self.status = status
|
||||||
self.text = ""
|
self.text = ""
|
||||||
|
self._paused = False
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self._broken = False
|
self._broken = False
|
||||||
self._last_sent = 0.0
|
self._last_sent = 0.0
|
||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
self._inflight: asyncio.Future[None] | None = None
|
self._inflight: asyncio.Future[None] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def draft_id(self) -> int:
|
||||||
|
return self._draft_id
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._task is None:
|
if self._task is None:
|
||||||
self._task = asyncio.create_task(self._run())
|
self._task = asyncio.create_task(self._run())
|
||||||
@@ -61,11 +66,22 @@ class Draft:
|
|||||||
if status != self.status:
|
if status != self.status:
|
||||||
self.status = status
|
self.status = status
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
self._paused = bool(self.text.strip())
|
||||||
|
|
||||||
def append(self, text: str) -> None:
|
def append(self, text: str) -> None:
|
||||||
if text:
|
"""Add streamed text; a tool call in between starts a new paragraph.
|
||||||
|
|
||||||
|
The model ends a sentence, calls a tool, then goes on without a
|
||||||
|
break - the same text the final message shows as paragraphs.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
if self._paused and not self.text.endswith("\n"):
|
||||||
|
self.text = self.text.rstrip() + "\n\n" + text.lstrip()
|
||||||
|
else:
|
||||||
self.text += text
|
self.text += text
|
||||||
self._dirty = True
|
self._paused = False
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop pushing and wait for the push in flight.
|
"""Stop pushing and wait for the push in flight.
|
||||||
@@ -126,6 +142,7 @@ class Draft:
|
|||||||
message_thread_id=self._thread_id,
|
message_thread_id=self._thread_id,
|
||||||
text=text,
|
text=text,
|
||||||
parse_mode=parse_mode,
|
parse_mode=parse_mode,
|
||||||
|
can_stop=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render(self) -> tuple[str, str]:
|
def _render(self) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.exceptions import TelegramAPIError
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
|
from aiogram.client.telegram import TelegramAPIServer
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
BotCommand,
|
BotCommand,
|
||||||
CallbackQuery,
|
CallbackQuery,
|
||||||
|
FSInputFile,
|
||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
InlineKeyboardMarkup,
|
InlineKeyboardMarkup,
|
||||||
Message,
|
Message,
|
||||||
@@ -33,10 +36,17 @@ from aiogram.types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from beaver_gateway.frontends.base import Frontend
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
from beaver_gateway.frontends.files import (
|
||||||
|
DEFAULTS,
|
||||||
|
SendFileError,
|
||||||
|
SendFiles,
|
||||||
|
mb,
|
||||||
|
method_for,
|
||||||
|
)
|
||||||
from beaver_gateway.frontends.telegram.drafts import Draft
|
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||||
from beaver_gateway.frontends.telegram.inbox import Inbox
|
from beaver_gateway.frontends.telegram.inbox import Inbox
|
||||||
from beaver_gateway.frontends.telegram.outbox import Outbox
|
from beaver_gateway.frontends.telegram.outbox import Outbox
|
||||||
from beaver_gateway.frontends.telegram.render import chunks, status_label
|
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
|
||||||
from beaver_gateway.frontends.telegram.texts import TelegramTexts
|
from beaver_gateway.frontends.telegram.texts import TelegramTexts
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -53,6 +63,8 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram")
|
|||||||
FRONTEND = "telegram"
|
FRONTEND = "telegram"
|
||||||
_DONE = "done"
|
_DONE = "done"
|
||||||
_COMMANDS = ("merge", "new", "chat", "status", "help", "start")
|
_COMMANDS = ("merge", "new", "chat", "status", "help", "start")
|
||||||
|
_CAPTION = 1024
|
||||||
|
_NO_CAPTION = ("video_note",)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -95,10 +107,46 @@ class _Ask:
|
|||||||
picked: dict[int, list[str]] = field(default_factory=dict)
|
picked: dict[int, list[str]] = field(default_factory=dict)
|
||||||
done: set[int] = field(default_factory=set)
|
done: set[int] = field(default_factory=set)
|
||||||
|
|
||||||
|
def to_flags(self, question_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": question_id,
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"chat_id": self.chat_id,
|
||||||
|
"thread_id": self.thread_id,
|
||||||
|
"questions": self.questions,
|
||||||
|
"messages": self.messages,
|
||||||
|
"picked": {str(k): v for k, v in self.picked.items()},
|
||||||
|
"done": sorted(self.done),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_flags(cls, data: dict[str, Any]) -> _Ask:
|
||||||
|
return cls(
|
||||||
|
conversation_id=str(data["conversation_id"]),
|
||||||
|
chat_id=int(data["chat_id"]),
|
||||||
|
thread_id=data.get("thread_id"),
|
||||||
|
questions=list(data.get("questions") or []),
|
||||||
|
messages=list(data.get("messages") or []),
|
||||||
|
picked={int(k): list(v) for k, v in (data.get("picked") or {}).items()},
|
||||||
|
done=set(data.get("done") or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Album:
|
||||||
|
message: Message
|
||||||
|
thread_id: int | None
|
||||||
|
is_master: bool
|
||||||
|
texts: list[str] = field(default_factory=list)
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TelegramFrontend(Frontend):
|
class TelegramFrontend(Frontend):
|
||||||
name = FRONTEND
|
name = FRONTEND
|
||||||
kinds = ("master", "branch")
|
kinds = ("master", "branch")
|
||||||
|
sends_files = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -109,12 +157,15 @@ class TelegramFrontend(Frontend):
|
|||||||
branch_agent: str | None = None,
|
branch_agent: str | None = None,
|
||||||
chat_id: int | None = None,
|
chat_id: int | None = None,
|
||||||
attachments: Attachments = EPHEMERAL,
|
attachments: Attachments = EPHEMERAL,
|
||||||
|
send_files: SendFiles = DEFAULTS,
|
||||||
|
api_base_url: str | None = None,
|
||||||
draft_interval: float = 0.7,
|
draft_interval: float = 0.7,
|
||||||
master_topic: str = "🦫 General",
|
master_topic: str = "🦫 General",
|
||||||
queued_reaction: str = "👀",
|
queued_reaction: str = "👀",
|
||||||
poll_timeout: int = 30,
|
poll_timeout: int = 30,
|
||||||
outbox_backoff: float = 2.0,
|
outbox_backoff: float = 2.0,
|
||||||
texts: TelegramTexts | None = None,
|
texts: TelegramTexts | None = None,
|
||||||
|
album_delay: float = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._token = token
|
self._token = token
|
||||||
self.texts = texts or TelegramTexts()
|
self.texts = texts or TelegramTexts()
|
||||||
@@ -123,6 +174,8 @@ class TelegramFrontend(Frontend):
|
|||||||
self.master_agent = master_agent
|
self.master_agent = master_agent
|
||||||
self.branch_agent = branch_agent
|
self.branch_agent = branch_agent
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
|
self.send_files = send_files
|
||||||
|
self.api_base_url = api_base_url
|
||||||
self.master_topic = master_topic
|
self.master_topic = master_topic
|
||||||
self.draft_interval = draft_interval
|
self.draft_interval = draft_interval
|
||||||
self.queued_reaction = queued_reaction
|
self.queued_reaction = queued_reaction
|
||||||
@@ -136,7 +189,9 @@ class TelegramFrontend(Frontend):
|
|||||||
self._master_window: str | None = None
|
self._master_window: str | None = None
|
||||||
self._topic_names: dict[int, str] = {}
|
self._topic_names: dict[int, str] = {}
|
||||||
self._drafts: dict[str, Draft] = {}
|
self._drafts: dict[str, Draft] = {}
|
||||||
|
self.album_delay = album_delay
|
||||||
self._asks: dict[str, _Ask] = {}
|
self._asks: dict[str, _Ask] = {}
|
||||||
|
self._albums: dict[str, _Album] = {}
|
||||||
self._reactions: dict[int, tuple[int, int]] = {}
|
self._reactions: dict[int, tuple[int, int]] = {}
|
||||||
self._tasks: set[asyncio.Task[None]] = set()
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
@@ -149,7 +204,14 @@ class TelegramFrontend(Frontend):
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
if self._bot is None:
|
if self._bot is None:
|
||||||
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None))
|
self._bot = Bot(
|
||||||
|
self._token,
|
||||||
|
session=self._session(),
|
||||||
|
default=DefaultBotProperties(parse_mode=None),
|
||||||
|
)
|
||||||
|
self.send_files = self.send_files.with_roots(
|
||||||
|
*runtime.send_file_roots, self.attachments.root
|
||||||
|
)
|
||||||
self._inbox = Inbox(
|
self._inbox = Inbox(
|
||||||
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
|
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
|
||||||
)
|
)
|
||||||
@@ -157,6 +219,16 @@ class TelegramFrontend(Frontend):
|
|||||||
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
|
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _session(self) -> AiohttpSession | None:
|
||||||
|
"""A local Bot API server instead of api.telegram.org (2 GB uploads)."""
|
||||||
|
if not self.api_base_url:
|
||||||
|
return None
|
||||||
|
return AiohttpSession(
|
||||||
|
api=TelegramAPIServer.from_base(
|
||||||
|
self.api_base_url.rstrip("/"), is_local=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
me = await self.bot.get_me()
|
me = await self.bot.get_me()
|
||||||
_log.info(
|
_log.info(
|
||||||
@@ -213,6 +285,44 @@ class TelegramFrontend(Frontend):
|
|||||||
self._topic_names[target[1]] = f"{prefix}{name}"
|
self._topic_names[target[1]] = f"{prefix}{name}"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def file_note(self) -> str:
|
||||||
|
return self.send_files.note
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
|
||||||
|
) -> str:
|
||||||
|
"""Upload the file into this conversation's topic, caption and all."""
|
||||||
|
target = await self._target_of(conv)
|
||||||
|
if target is None:
|
||||||
|
msg = f"conversation {conv.external_id} has no Telegram window"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
file = self.send_files.resolve(path)
|
||||||
|
how = method_for(file, method)
|
||||||
|
rest = caption.strip()
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"message_thread_id": target[1],
|
||||||
|
how: FSInputFile(file),
|
||||||
|
}
|
||||||
|
if how == "audio":
|
||||||
|
payload["title"] = file.stem
|
||||||
|
if rest and how not in _NO_CAPTION and len(rest) <= _CAPTION:
|
||||||
|
payload |= {"caption": to_html(rest), "parse_mode": "HTML"}
|
||||||
|
rest = ""
|
||||||
|
try:
|
||||||
|
await getattr(self.bot, f"send_{how}")(target[0], **payload)
|
||||||
|
except TelegramNetworkError as exc:
|
||||||
|
msg = f"Telegram did not answer while sending {file.name}: {exc}"
|
||||||
|
raise SendFileError(msg) from exc
|
||||||
|
except TelegramAPIError as exc:
|
||||||
|
msg = f"Telegram refused {file.name} as {how}: {exc}"
|
||||||
|
raise SendFileError(msg) from exc
|
||||||
|
except (OSError, TimeoutError) as exc:
|
||||||
|
msg = f"sending {file.name} failed: {exc}"
|
||||||
|
raise SendFileError(msg) from exc
|
||||||
|
if rest:
|
||||||
|
await self._deliver(conv, rest)
|
||||||
|
return f"sent {file.name} as {how}, {mb(file.stat().st_size)} MB"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot(self) -> Bot:
|
def bot(self) -> Bot:
|
||||||
if self._bot is None:
|
if self._bot is None:
|
||||||
@@ -302,7 +412,9 @@ class TelegramFrontend(Frontend):
|
|||||||
self._master_window = self._ext(topic.message_thread_id)
|
self._master_window = self._ext(topic.message_thread_id)
|
||||||
return self._master_window
|
return self._master_window
|
||||||
|
|
||||||
async def _master(self, text: str | None = None) -> tuple[Conversation, bool]:
|
async def _master(
|
||||||
|
self, text: str | None = None, attachments: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[Conversation, bool]:
|
||||||
"""The open master behind its window, spawning one when there is none.
|
"""The open master behind its window, spawning one when there is none.
|
||||||
|
|
||||||
``text`` rides with the seed of a fresh master; the second value says
|
``text`` rides with the seed of a fresh master; the second value says
|
||||||
@@ -324,11 +436,17 @@ class TelegramFrontend(Frontend):
|
|||||||
text=text,
|
text=text,
|
||||||
origin=FRONTEND,
|
origin=FRONTEND,
|
||||||
binding=(FRONTEND, ext),
|
binding=(FRONTEND, ext),
|
||||||
|
attachments=attachments,
|
||||||
)
|
)
|
||||||
return conv, text is not None
|
return conv, text is not None
|
||||||
|
|
||||||
async def _branch(
|
async def _branch(
|
||||||
self, thread_id: int, *, title: str | None, text: str | None
|
self,
|
||||||
|
thread_id: int,
|
||||||
|
*,
|
||||||
|
title: str | None,
|
||||||
|
text: str | None,
|
||||||
|
attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> Conversation:
|
) -> Conversation:
|
||||||
master, _ = await self._master()
|
master, _ = await self._master()
|
||||||
return await self.conversations.spawn(
|
return await self.conversations.spawn(
|
||||||
@@ -341,6 +459,7 @@ class TelegramFrontend(Frontend):
|
|||||||
text=text,
|
text=text,
|
||||||
origin=FRONTEND,
|
origin=FRONTEND,
|
||||||
binding=(FRONTEND, self._ext(thread_id)),
|
binding=(FRONTEND, self._ext(thread_id)),
|
||||||
|
attachments=attachments,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle(self, update: Update) -> None:
|
async def _handle(self, update: Update) -> None:
|
||||||
@@ -348,6 +467,8 @@ class TelegramFrontend(Frontend):
|
|||||||
await self._on_message(update.message)
|
await self._on_message(update.message)
|
||||||
elif update.callback_query is not None:
|
elif update.callback_query is not None:
|
||||||
await self._on_callback(update.callback_query)
|
await self._on_callback(update.callback_query)
|
||||||
|
elif update.stopped_message_generation is not None:
|
||||||
|
await self._on_stopped(update.stopped_message_generation.draft_id)
|
||||||
|
|
||||||
async def _on_message(self, message: Message) -> None:
|
async def _on_message(self, message: Message) -> None:
|
||||||
created = message.forum_topic_created
|
created = message.forum_topic_created
|
||||||
@@ -382,9 +503,79 @@ class TelegramFrontend(Frontend):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
text = (message.text or message.caption or "").strip()
|
text = (message.text or message.caption or "").strip()
|
||||||
attachment = await self._save_attachment(message)
|
note, attachment = await self._save_attachment(message)
|
||||||
|
if message.media_group_id:
|
||||||
|
self._collect_album(
|
||||||
|
message,
|
||||||
|
thread_id,
|
||||||
|
is_master=is_master,
|
||||||
|
text=text,
|
||||||
|
note=note,
|
||||||
|
attachment=attachment,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._dispatch(
|
||||||
|
message,
|
||||||
|
thread_id,
|
||||||
|
is_master=is_master,
|
||||||
|
text=text,
|
||||||
|
notes=[note] if note else [],
|
||||||
|
attachments=[attachment] if attachment else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _collect_album(
|
||||||
|
self,
|
||||||
|
message: Message,
|
||||||
|
thread_id: int | None,
|
||||||
|
*,
|
||||||
|
is_master: bool,
|
||||||
|
text: str,
|
||||||
|
note: str | None,
|
||||||
|
attachment: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
"""An album arrives as separate messages; it becomes one turn."""
|
||||||
|
group = str(message.media_group_id)
|
||||||
|
album = self._albums.get(group)
|
||||||
|
if album is None:
|
||||||
|
album = _Album(message=message, thread_id=thread_id, is_master=is_master)
|
||||||
|
self._albums[group] = album
|
||||||
|
if text:
|
||||||
|
album.texts.append(text)
|
||||||
|
if note:
|
||||||
|
album.notes.append(note)
|
||||||
if attachment:
|
if attachment:
|
||||||
text = f"{text}\n\n{attachment}".strip()
|
album.attachments.append(attachment)
|
||||||
|
if album.task is not None:
|
||||||
|
album.task.cancel()
|
||||||
|
album.task = asyncio.create_task(self._flush_album(group))
|
||||||
|
self._tasks.add(album.task)
|
||||||
|
album.task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
|
async def _flush_album(self, group: str) -> None:
|
||||||
|
await asyncio.sleep(self.album_delay)
|
||||||
|
album = self._albums.pop(group, None)
|
||||||
|
if album is None:
|
||||||
|
return
|
||||||
|
await self._dispatch(
|
||||||
|
album.message,
|
||||||
|
album.thread_id,
|
||||||
|
is_master=album.is_master,
|
||||||
|
text="\n\n".join(album.texts),
|
||||||
|
notes=album.notes,
|
||||||
|
attachments=album.attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _dispatch(
|
||||||
|
self,
|
||||||
|
message: Message,
|
||||||
|
thread_id: int | None,
|
||||||
|
*,
|
||||||
|
is_master: bool,
|
||||||
|
text: str,
|
||||||
|
notes: list[str],
|
||||||
|
attachments: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
text = "\n\n".join(part for part in (text, *notes) if part).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
if message.text and message.text.startswith("/"):
|
if message.text and message.text.startswith("/"):
|
||||||
@@ -394,14 +585,17 @@ class TelegramFrontend(Frontend):
|
|||||||
await self._command(command, args.strip(), message, thread_id)
|
await self._command(command, args.strip(), message, thread_id)
|
||||||
return
|
return
|
||||||
if is_master:
|
if is_master:
|
||||||
conv, consumed = await self._master(text)
|
conv, consumed = await self._master(text, attachments or None)
|
||||||
if consumed:
|
if consumed:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
conv = await self._live(thread_id)
|
topic = cast("int", thread_id)
|
||||||
|
conv = await self._live(topic)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
title = self._topic_names.get(thread_id) or self._title_from(text)
|
title = self._topic_names.get(topic) or self._title_from(text)
|
||||||
await self._branch(thread_id, title=title, text=text)
|
await self._branch(
|
||||||
|
topic, title=title, text=text, attachments=attachments or None
|
||||||
|
)
|
||||||
return
|
return
|
||||||
pending = self.conversations.pending_question(conv.external_id)
|
pending = self.conversations.pending_question(conv.external_id)
|
||||||
if pending is not None and self.conversations.answer(pending[0], text):
|
if pending is not None and self.conversations.answer(pending[0], text):
|
||||||
@@ -409,7 +603,9 @@ class TelegramFrontend(Frontend):
|
|||||||
pending[0], self.texts.question_typed.format(text=text)
|
pending[0], self.texts.question_typed.format(text=text)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
item = await self.conversations.post(conv, text, origin=FRONTEND)
|
item = await self.conversations.post(
|
||||||
|
conv, text, origin=FRONTEND, attachments=attachments or None
|
||||||
|
)
|
||||||
if conv.running_turn or conv.pending_question:
|
if conv.running_turn or conv.pending_question:
|
||||||
await self._react(message, item.id)
|
await self._react(message, item.id)
|
||||||
|
|
||||||
@@ -453,55 +649,25 @@ class TelegramFrontend(Frontend):
|
|||||||
with contextlib.suppress(TelegramAPIError):
|
with contextlib.suppress(TelegramAPIError):
|
||||||
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
|
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
|
||||||
|
|
||||||
async def _save_attachment(self, message: Message) -> str | None:
|
async def _save_attachment(
|
||||||
file_id: str | None = None
|
self, message: Message
|
||||||
name: str | None = None
|
) -> tuple[str | None, dict[str, Any] | None]:
|
||||||
kind = ""
|
"""The note for the model and, for images, the file it gets to see inline."""
|
||||||
size: int | None = None
|
|
||||||
texts = self.texts
|
texts = self.texts
|
||||||
if message.sticker:
|
if message.sticker:
|
||||||
return texts.attachment_sticker
|
return texts.attachment_sticker, None
|
||||||
if message.animation:
|
if message.animation:
|
||||||
return texts.attachment_animation
|
return texts.attachment_animation, None
|
||||||
if message.photo:
|
file_id, kind, size, name, media_type = _media(message)
|
||||||
photo = message.photo[-1]
|
|
||||||
file_id, kind, size = photo.file_id, "photo", photo.file_size
|
|
||||||
name = f"{photo.file_unique_id}.jpg"
|
|
||||||
elif message.document:
|
|
||||||
doc = message.document
|
|
||||||
file_id, kind, size = doc.file_id, "document", doc.file_size
|
|
||||||
name = doc.file_name or f"{doc.file_unique_id}.bin"
|
|
||||||
elif message.voice:
|
|
||||||
file_id, kind, size = (
|
|
||||||
message.voice.file_id,
|
|
||||||
"voice",
|
|
||||||
message.voice.file_size,
|
|
||||||
)
|
|
||||||
name = f"{message.voice.file_unique_id}.ogg"
|
|
||||||
elif message.audio:
|
|
||||||
file_id, kind, size = (
|
|
||||||
message.audio.file_id,
|
|
||||||
"audio",
|
|
||||||
message.audio.file_size,
|
|
||||||
)
|
|
||||||
name = message.audio.file_name or f"{message.audio.file_unique_id}.mp3"
|
|
||||||
elif message.video:
|
|
||||||
file_id, kind, size = (
|
|
||||||
message.video.file_id,
|
|
||||||
"video",
|
|
||||||
message.video.file_size,
|
|
||||||
)
|
|
||||||
name = message.video.file_name or f"{message.video.file_unique_id}.mp4"
|
|
||||||
elif message.video_note:
|
|
||||||
note = message.video_note
|
|
||||||
file_id, kind, size = note.file_id, "video_note", note.file_size
|
|
||||||
name = f"{note.file_unique_id}.mp4"
|
|
||||||
if file_id is None or name is None:
|
if file_id is None or name is None:
|
||||||
return None
|
return None, None
|
||||||
kind = texts.attachment_kinds.get(kind, kind)
|
kind = texts.attachment_kinds.get(kind, kind)
|
||||||
if size and size > 20 * 1024 * 1024:
|
if size and size > 20 * 1024 * 1024:
|
||||||
return texts.attachment_too_big.format(
|
return (
|
||||||
kind=kind, name=name, mb=size // 1024 // 1024
|
texts.attachment_too_big.format(
|
||||||
|
kind=kind, name=name, mb=size // 1024 // 1024
|
||||||
|
),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
folder = self.attachments.root / self.attachments.day(now)
|
folder = self.attachments.root / self.attachments.day(now)
|
||||||
@@ -515,12 +681,20 @@ class TelegramFrontend(Frontend):
|
|||||||
path.chmod(0o644)
|
path.chmod(0o644)
|
||||||
except (TelegramAPIError, OSError) as exc:
|
except (TelegramAPIError, OSError) as exc:
|
||||||
_log.warning("attachment download failed: %s", exc)
|
_log.warning("attachment download failed: %s", exc)
|
||||||
return texts.attachment_failed.format(kind=kind, name=name, error=exc)
|
return texts.attachment_failed.format(kind=kind, name=name, error=exc), None
|
||||||
shown = texts.attachment_size.format(kb=size // 1024) if size else ""
|
shown = texts.attachment_size.format(kb=size // 1024) if size else ""
|
||||||
keep = self.attachments.keep_days
|
keep = self.attachments.keep_days
|
||||||
|
seen = (
|
||||||
|
{"path": str(path), "media_type": media_type}
|
||||||
|
if media_type and media_type.startswith("image/")
|
||||||
|
else None
|
||||||
|
)
|
||||||
if keep is None:
|
if keep is None:
|
||||||
return texts.attachment.format(kind=kind, path=path, size=shown)
|
return texts.attachment.format(kind=kind, path=path, size=shown), seen
|
||||||
return texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep)
|
return (
|
||||||
|
texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep),
|
||||||
|
seen,
|
||||||
|
)
|
||||||
|
|
||||||
async def _sweep_loop(self, interval: float = 6 * 3600) -> None:
|
async def _sweep_loop(self, interval: float = 6 * 3600) -> None:
|
||||||
while True:
|
while True:
|
||||||
@@ -675,7 +849,7 @@ class TelegramFrontend(Frontend):
|
|||||||
return
|
return
|
||||||
match kind:
|
match kind:
|
||||||
case "turn.start":
|
case "turn.start":
|
||||||
if event.get("origin") == "user":
|
if event.get("origin") in ("user", "task"):
|
||||||
await self._open_draft(key, event, target)
|
await self._open_draft(key, event, target)
|
||||||
case "stream":
|
case "stream":
|
||||||
self._feed_draft(key, event)
|
self._feed_draft(key, event)
|
||||||
@@ -721,6 +895,14 @@ class TelegramFrontend(Frontend):
|
|||||||
case "conversation.merged":
|
case "conversation.merged":
|
||||||
await self._deliver(conv, self.texts.merged, key=f"{key}:merged")
|
await self._deliver(conv, self.texts.merged, key=f"{key}:merged")
|
||||||
|
|
||||||
|
async def _on_stopped(self, draft_id: int) -> None:
|
||||||
|
key = next((k for k, d in self._drafts.items() if d.draft_id == draft_id), None)
|
||||||
|
if key is None:
|
||||||
|
return
|
||||||
|
conv = await self.conversations.get(key)
|
||||||
|
if conv is not None:
|
||||||
|
await self.conversations.interrupt(conv)
|
||||||
|
|
||||||
async def _on_delivery_failed(self, event: Event) -> None:
|
async def _on_delivery_failed(self, event: Event) -> None:
|
||||||
row = event.get("conversation_row")
|
row = event.get("conversation_row")
|
||||||
_log.error(
|
_log.error(
|
||||||
@@ -848,6 +1030,27 @@ class TelegramFrontend(Frontend):
|
|||||||
_log.exception("question %s could not be sent", question_id)
|
_log.exception("question %s could not be sent", question_id)
|
||||||
continue
|
continue
|
||||||
ask.messages.append(sent.message_id)
|
ask.messages.append(sent.message_id)
|
||||||
|
await self._persist_ask(question_id, ask)
|
||||||
|
|
||||||
|
async def _persist_ask(self, question_id: str, ask: _Ask | None) -> None:
|
||||||
|
"""The open question lives in the conversation's flags across restarts."""
|
||||||
|
conv = await self.conversations.get(
|
||||||
|
ask.conversation_id if ask is not None else question_id
|
||||||
|
)
|
||||||
|
if conv is None:
|
||||||
|
return
|
||||||
|
await self.conversations.set_flags(
|
||||||
|
conv, {"ask": ask.to_flags(question_id) if ask is not None else None}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _restore_ask(self, question_id: str) -> _Ask | None:
|
||||||
|
for conv in await self.conversations.find(status="open", limit=500):
|
||||||
|
data = conv.flags.get("ask")
|
||||||
|
if isinstance(data, dict) and data.get("id") == question_id:
|
||||||
|
ask = _Ask.from_flags(data)
|
||||||
|
self._asks[question_id] = ask
|
||||||
|
return ask
|
||||||
|
return None
|
||||||
|
|
||||||
async def _on_callback(self, query: CallbackQuery) -> None:
|
async def _on_callback(self, query: CallbackQuery) -> None:
|
||||||
if query.from_user.id != self.user_id or not query.data:
|
if query.from_user.id != self.user_id or not query.data:
|
||||||
@@ -856,7 +1059,7 @@ class TelegramFrontend(Frontend):
|
|||||||
if len(parts) != 4 or parts[0] != "q":
|
if len(parts) != 4 or parts[0] != "q":
|
||||||
return
|
return
|
||||||
_, question_id, qi_raw, choice = parts
|
_, question_id, qi_raw, choice = parts
|
||||||
ask = self._asks.get(question_id)
|
ask = self._asks.get(question_id) or await self._restore_ask(question_id)
|
||||||
if ask is None or not qi_raw.isdigit():
|
if ask is None or not qi_raw.isdigit():
|
||||||
await self._callback_reply(query, self.texts.question_closed)
|
await self._callback_reply(query, self.texts.question_closed)
|
||||||
await self._strip_keyboard(query)
|
await self._strip_keyboard(query)
|
||||||
@@ -896,11 +1099,20 @@ class TelegramFrontend(Frontend):
|
|||||||
reply_markup=self._keyboard(question_id, qi, question, picked),
|
reply_markup=self._keyboard(question_id, qi, question, picked),
|
||||||
)
|
)
|
||||||
await self._callback_reply(query, None)
|
await self._callback_reply(query, None)
|
||||||
if len(ask.done) == len(ask.questions):
|
if len(ask.done) < len(ask.questions):
|
||||||
answer = _answer_text(ask)
|
await self._persist_ask(question_id, ask)
|
||||||
self._asks.pop(question_id, None)
|
return
|
||||||
if not self.conversations.answer(question_id, answer):
|
answer = _answer_text(ask)
|
||||||
await self._edit_asks(ask, self.texts.question_timeout)
|
self._asks.pop(question_id, None)
|
||||||
|
conv = await self.conversations.get(ask.conversation_id)
|
||||||
|
if conv is not None:
|
||||||
|
await self.conversations.set_flags(conv, {"ask": None})
|
||||||
|
if self.conversations.answer(question_id, answer):
|
||||||
|
return
|
||||||
|
if conv is not None and conv.status == "open":
|
||||||
|
await self.conversations.post(conv, answer, origin=FRONTEND)
|
||||||
|
else:
|
||||||
|
await self._edit_asks(ask, self.texts.question_timeout)
|
||||||
|
|
||||||
async def _strip_keyboard(self, query: CallbackQuery) -> None:
|
async def _strip_keyboard(self, query: CallbackQuery) -> None:
|
||||||
message = query.message if isinstance(query.message, Message) else None
|
message = query.message if isinstance(query.message, Message) else None
|
||||||
@@ -920,6 +1132,7 @@ class TelegramFrontend(Frontend):
|
|||||||
async def _close_ask(self, question_id: str, note: str) -> None:
|
async def _close_ask(self, question_id: str, note: str) -> None:
|
||||||
ask = self._asks.pop(question_id, None)
|
ask = self._asks.pop(question_id, None)
|
||||||
if ask is not None:
|
if ask is not None:
|
||||||
|
await self._persist_ask(question_id, None)
|
||||||
await self._edit_asks(ask, note)
|
await self._edit_asks(ask, note)
|
||||||
|
|
||||||
async def _edit_asks(self, ask: _Ask, note: str) -> None:
|
async def _edit_asks(self, ask: _Ask, note: str) -> None:
|
||||||
@@ -935,6 +1148,50 @@ class TelegramFrontend(Frontend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _media(
|
||||||
|
message: Message,
|
||||||
|
) -> tuple[str | None, str, int | None, str | None, str | None]:
|
||||||
|
"""``(file_id, kind, size, name, media_type)`` of the message's media, if any."""
|
||||||
|
if message.photo:
|
||||||
|
photo = message.photo[-1]
|
||||||
|
return (
|
||||||
|
photo.file_id,
|
||||||
|
"photo",
|
||||||
|
photo.file_size,
|
||||||
|
f"{photo.file_unique_id}.jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
)
|
||||||
|
if message.document:
|
||||||
|
doc = message.document
|
||||||
|
name = doc.file_name or f"{doc.file_unique_id}.bin"
|
||||||
|
return doc.file_id, "document", doc.file_size, name, doc.mime_type
|
||||||
|
if message.voice:
|
||||||
|
v = message.voice
|
||||||
|
return v.file_id, "voice", v.file_size, f"{v.file_unique_id}.ogg", None
|
||||||
|
if message.audio:
|
||||||
|
a = message.audio
|
||||||
|
return (
|
||||||
|
a.file_id,
|
||||||
|
"audio",
|
||||||
|
a.file_size,
|
||||||
|
a.file_name or f"{a.file_unique_id}.mp3",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if message.video:
|
||||||
|
v = message.video
|
||||||
|
return (
|
||||||
|
v.file_id,
|
||||||
|
"video",
|
||||||
|
v.file_size,
|
||||||
|
v.file_name or f"{v.file_unique_id}.mp4",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if message.video_note:
|
||||||
|
n = message.video_note
|
||||||
|
return n.file_id, "video_note", n.file_size, f"{n.file_unique_id}.mp4", None
|
||||||
|
return None, "", None, None, None
|
||||||
|
|
||||||
|
|
||||||
def _question_html(question: dict[str, Any]) -> str:
|
def _question_html(question: dict[str, Any]) -> str:
|
||||||
header = html.escape(str(question.get("header") or "").strip())
|
header = html.escape(str(question.get("header") or "").strip())
|
||||||
body = html.escape(str(question.get("question") or "").strip())
|
body = html.escape(str(question.get("question") or "").strip())
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ __all__ = ["Inbox"]
|
|||||||
|
|
||||||
_log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
|
_log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
|
||||||
|
|
||||||
ALLOWED_UPDATES = ("message", "callback_query")
|
ALLOWED_UPDATES = ("message", "callback_query", "stopped_message_generation")
|
||||||
|
|
||||||
|
|
||||||
def _unset(_value: object) -> None:
|
def _unset(_value: object) -> None:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ TOOL_LABELS: dict[str, str] = {
|
|||||||
"mcp__gateway__say": "speaking",
|
"mcp__gateway__say": "speaking",
|
||||||
"mcp__gateway__schedule": "setting a reminder",
|
"mcp__gateway__schedule": "setting a reminder",
|
||||||
"mcp__gateway__inject": "passing to another conversation",
|
"mcp__gateway__inject": "passing to another conversation",
|
||||||
|
"mcp__gateway__send_file": "sending a file",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
|||||||
from beaver_gateway.storage import list_active_tokens, touch_token
|
from beaver_gateway.storage import list_active_tokens, touch_token
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
from beaver_gateway.storage import Database
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ class TokenStoreError(ValueError):
|
|||||||
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
||||||
|
|
||||||
|
|
||||||
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
||||||
"""The scopes a ``Token.scope`` may hold.
|
"""The scopes the gateway's own frontends define.
|
||||||
|
|
||||||
* ``*`` — wildcard, may use any frontend
|
* ``*`` — wildcard, may use any frontend
|
||||||
* ``messages`` — Anthropic Messages frontend only
|
* ``messages`` — Anthropic Messages frontend only
|
||||||
@@ -45,16 +45,24 @@ VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"
|
|||||||
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
||||||
itself authenticates via session cookies, not bearer tokens, so this
|
itself authenticates via session cookies, not bearer tokens, so this
|
||||||
scope is unused today.
|
scope is unused today.
|
||||||
|
|
||||||
|
A setup's own frontend adds its scope on top; see :func:`scopes_with` and
|
||||||
|
``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_with(extra: Iterable[str | None]) -> frozenset[str]:
|
||||||
|
"""The builtin scopes plus every frontend's own; ``None`` entries drop out."""
|
||||||
|
return BUILTIN_SCOPES | {s for s in extra if s}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TokenIdentity:
|
class TokenIdentity:
|
||||||
"""What :meth:`TokenStore.verify` resolves to on success.
|
"""What :meth:`TokenStore.verify` resolves to on success.
|
||||||
|
|
||||||
``token_id`` is the DB row id for persisted tokens, or ``None`` for
|
``token_id`` is the DB row id for persisted tokens, or ``None`` for
|
||||||
an env-bootstrap match (those have no DB row to touch). ``scope``
|
an env-bootstrap match (those have no DB row to touch). ``scope``
|
||||||
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
|
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
|
||||||
bootstrap tokens implicitly get ``"*"``.
|
bootstrap tokens implicitly get ``"*"``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -67,8 +75,8 @@ class TokenIdentity:
|
|||||||
|
|
||||||
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
||||||
Unknown ``required`` values intentionally fall through to a
|
Unknown ``required`` values intentionally fall through to a
|
||||||
strict equality check — callers should pass one of
|
strict equality check — callers should pass a scope the runtime
|
||||||
:data:`VALID_SCOPES`.
|
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
|
||||||
"""
|
"""
|
||||||
return self.scope in ("*", required)
|
return self.scope in ("*", required)
|
||||||
|
|
||||||
@@ -325,11 +333,12 @@ class TokenStore:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"VALID_SCOPES",
|
"BUILTIN_SCOPES",
|
||||||
"TokenIdentity",
|
"TokenIdentity",
|
||||||
"TokenStore",
|
"TokenStore",
|
||||||
"TokenStoreError",
|
"TokenStoreError",
|
||||||
"hash_token",
|
"hash_token",
|
||||||
|
"scopes_with",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ class Settings(BaseSettings):
|
|||||||
"""``Accept-Language`` sent to the Raycast API and the default locale for
|
"""``Accept-Language`` sent to the Raycast API and the default locale for
|
||||||
auto ``UserPreferences``. One value per gateway (one shared Client)."""
|
auto ``UserPreferences``. One value per gateway (one shared Client)."""
|
||||||
|
|
||||||
|
send_file_roots: str = "/mnt/hole/shared"
|
||||||
|
"""Directories the ``send_file`` tool may read, separated by ``:``. A
|
||||||
|
frontend always adds the inbox its own attachments land in."""
|
||||||
|
|
||||||
bootstrap_tokens: str = ""
|
bootstrap_tokens: str = ""
|
||||||
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
|
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
|
||||||
alongside DB-issued tokens; used for first-run setup and recovery."""
|
alongside DB-issued tokens; used for first-run setup and recovery."""
|
||||||
@@ -144,6 +144,11 @@ class InjectQueueItem(SQLModel, table=True):
|
|||||||
priority: str = Field(index=True)
|
priority: str = Field(index=True)
|
||||||
origin: str = Field(default="system")
|
origin: str = Field(default="system")
|
||||||
text: str
|
text: str
|
||||||
|
attachments: list[dict[str, Any]] | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=True),
|
||||||
|
)
|
||||||
|
"""Files that ride with a user message: ``{"path", "media_type"}`` each."""
|
||||||
status: str = Field(default="queued", index=True)
|
status: str = Field(default="queued", index=True)
|
||||||
turn_id: str | None = Field(default=None)
|
turn_id: str | None = Field(default=None)
|
||||||
interrupted_turn: bool | None = Field(default=None)
|
interrupted_turn: bool | None = Field(default=None)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Wikilink index over a vault: which note links to which, refreshed by mtime."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = ["LinkIndex"]
|
||||||
|
|
||||||
|
_WIKILINK = re.compile(r"\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
|
||||||
|
_MD = ".md"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _Note:
|
||||||
|
mtime: float
|
||||||
|
links: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkIndex:
|
||||||
|
"""Forward and backward links between the vault's markdown notes."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path, *, max_age: float = 20.0) -> None:
|
||||||
|
self.root = root.resolve()
|
||||||
|
self.max_age = max_age
|
||||||
|
self._notes: dict[str, _Note] = {}
|
||||||
|
self._raw: dict[str, set[str]] = {}
|
||||||
|
self._scanned_at = 0.0
|
||||||
|
|
||||||
|
def refresh(self, *, force: bool = False) -> None:
|
||||||
|
if not force and time.monotonic() - self._scanned_at < self.max_age:
|
||||||
|
return
|
||||||
|
seen: set[str] = set()
|
||||||
|
for dirpath, dirnames, filenames in os.walk(self.root):
|
||||||
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
|
for name in filenames:
|
||||||
|
if not name.endswith(_MD):
|
||||||
|
continue
|
||||||
|
path = Path(dirpath) / name
|
||||||
|
rel = str(path.relative_to(self.root))
|
||||||
|
seen.add(rel)
|
||||||
|
try:
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
note = self._notes.get(rel)
|
||||||
|
if note is None or note.mtime != mtime:
|
||||||
|
self._notes[rel] = _Note(mtime=mtime)
|
||||||
|
self._raw[rel] = self._targets(path)
|
||||||
|
for rel in list(self._notes):
|
||||||
|
if rel not in seen:
|
||||||
|
self._notes.pop(rel, None)
|
||||||
|
self._raw.pop(rel, None)
|
||||||
|
self._resolve()
|
||||||
|
self._scanned_at = time.monotonic()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _targets(path: Path) -> set[str]:
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return set()
|
||||||
|
return {m.group(1).strip() for m in _WIKILINK.finditer(text)}
|
||||||
|
|
||||||
|
def _resolve(self) -> None:
|
||||||
|
by_stem: dict[str, str] = {}
|
||||||
|
for rel in sorted(self._notes):
|
||||||
|
by_stem.setdefault(Path(rel).stem.casefold(), rel)
|
||||||
|
by_path = {rel.casefold(): rel for rel in self._notes}
|
||||||
|
for rel, targets in self._raw.items():
|
||||||
|
links: set[str] = set()
|
||||||
|
for target in targets:
|
||||||
|
candidate = target if target.endswith(_MD) else f"{target}{_MD}"
|
||||||
|
found = by_path.get(candidate.casefold()) or by_stem.get(
|
||||||
|
Path(target).stem.casefold()
|
||||||
|
)
|
||||||
|
if found and found != rel:
|
||||||
|
links.add(found)
|
||||||
|
self._notes[rel].links = links
|
||||||
|
|
||||||
|
def resolve(self, raw: str) -> str:
|
||||||
|
"""A path as a tool named it (absolute or relative) as an index key."""
|
||||||
|
path = Path(raw)
|
||||||
|
if path.is_absolute():
|
||||||
|
try:
|
||||||
|
return str(path.resolve().relative_to(self.root))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return raw
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def neighbours(self, paths: list[str], *, limit: int = 80) -> dict[str, Any]:
|
||||||
|
"""Nodes for ``paths`` plus every note one link away, and their edges."""
|
||||||
|
self.refresh()
|
||||||
|
touched = [self.resolve(p) for p in paths]
|
||||||
|
backlinks: dict[str, set[str]] = {}
|
||||||
|
for rel, note in self._notes.items():
|
||||||
|
for target in note.links:
|
||||||
|
backlinks.setdefault(target, set()).add(rel)
|
||||||
|
nodes: dict[str, dict[str, Any]] = {}
|
||||||
|
for rel in touched:
|
||||||
|
nodes[rel] = {
|
||||||
|
"path": rel,
|
||||||
|
"title": Path(rel).stem,
|
||||||
|
"touched": True,
|
||||||
|
"exists": rel in self._notes,
|
||||||
|
}
|
||||||
|
for rel in touched:
|
||||||
|
note = self._notes.get(rel)
|
||||||
|
around = (note.links if note else set()) | backlinks.get(rel, set())
|
||||||
|
for other in sorted(around):
|
||||||
|
if len(nodes) >= limit:
|
||||||
|
break
|
||||||
|
nodes.setdefault(
|
||||||
|
other,
|
||||||
|
{
|
||||||
|
"path": other,
|
||||||
|
"title": Path(other).stem,
|
||||||
|
"touched": False,
|
||||||
|
"exists": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
edges: list[dict[str, str]] = []
|
||||||
|
for rel in nodes:
|
||||||
|
note = self._notes.get(rel)
|
||||||
|
if note is None:
|
||||||
|
continue
|
||||||
|
edges.extend(
|
||||||
|
{"from": rel, "to": target}
|
||||||
|
for target in sorted(note.links)
|
||||||
|
if target in nodes
|
||||||
|
)
|
||||||
|
return {"nodes": list(nodes.values()), "edges": edges}
|
||||||
+113
-4
@@ -29,7 +29,7 @@ from beaver_gateway.frontends.api import ApiFrontend
|
|||||||
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||||
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||||
from beaver_gateway.frontends.root import build_root_app
|
from beaver_gateway.frontends.root import build_root_app
|
||||||
from beaver_gateway.storage.models import RateLimit, Usage
|
from beaver_gateway.storage.models import RateLimit, TranscriptEntry, Usage
|
||||||
|
|
||||||
TOKEN = "tok"
|
TOKEN = "tok"
|
||||||
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
|
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
|
||||||
@@ -247,6 +247,7 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
|
|||||||
async def test_limits_report_latest_window_with_gateway_spend(world: World) -> None:
|
async def test_limits_report_latest_window_with_gateway_spend(world: World) -> None:
|
||||||
api = Api(world)
|
api = Api(world)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
resets = int((now + timedelta(hours=4)).timestamp())
|
||||||
async with world.db.session() as session:
|
async with world.db.session() as session:
|
||||||
session.add(
|
session.add(
|
||||||
RateLimit(
|
RateLimit(
|
||||||
@@ -264,7 +265,20 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N
|
|||||||
resets_at=now + timedelta(hours=4),
|
resets_at=now + timedelta(hours=4),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.add(RateLimit(window="seven_day", status="allowed", utilization=0.3))
|
session.add(
|
||||||
|
RateLimit(
|
||||||
|
window="seven_day",
|
||||||
|
status="allowed",
|
||||||
|
utilization=0.3,
|
||||||
|
raw={
|
||||||
|
"unifiedWindows": {
|
||||||
|
"five_hour": {"resetsAt": resets, "utilization": 0.72},
|
||||||
|
"seven_day": {"resetsAt": resets, "utilization": 0.25},
|
||||||
|
"seven_day_fable": {"resetsAt": resets, "utilization": 0.1},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await seed_usage(
|
await seed_usage(
|
||||||
world,
|
world,
|
||||||
@@ -280,12 +294,19 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N
|
|||||||
)
|
)
|
||||||
out = await api.get("/limits")
|
out = await api.get("/limits")
|
||||||
windows = {w["window"]: w for w in out["windows"]}
|
windows = {w["window"]: w for w in out["windows"]}
|
||||||
assert windows["five_hour"]["utilization"] == 0.9
|
assert windows["five_hour"]["utilization"] == 0.72
|
||||||
|
assert windows["five_hour"]["reported_at"] is not None
|
||||||
assert windows["five_hour"]["status"] == "allowed_warning"
|
assert windows["five_hour"]["status"] == "allowed_warning"
|
||||||
assert windows["five_hour"]["gateway"]["output"] == 7
|
assert windows["five_hour"]["gateway"]["output"] == 7
|
||||||
assert windows["five_hour"]["gateway"]["cost_usd"] == 0.25
|
assert windows["five_hour"]["gateway"]["cost_usd"] == 0.25
|
||||||
assert windows["seven_day"]["utilization"] == 0.3
|
assert windows["seven_day"]["utilization"] == 0.3
|
||||||
assert [w["window"] for w in out["windows"]] == ["five_hour", "seven_day"]
|
assert windows["seven_day_fable"]["utilization"] == 0.1
|
||||||
|
assert windows["seven_day_fable"]["status"] == "allowed"
|
||||||
|
assert [w["window"] for w in out["windows"]] == [
|
||||||
|
"five_hour",
|
||||||
|
"seven_day",
|
||||||
|
"seven_day_fable",
|
||||||
|
]
|
||||||
assert len(out["history"]) == 3
|
assert len(out["history"]) == 3
|
||||||
|
|
||||||
|
|
||||||
@@ -525,3 +546,91 @@ async def test_bind_without_a_window_materializes_one(world: World) -> None:
|
|||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
)
|
)
|
||||||
assert unknown.status_code == 400
|
assert unknown.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_finds_conversations_and_memory_files(world: World) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-mem-"))
|
||||||
|
(root / "notes").mkdir()
|
||||||
|
(root / "notes" / "studio.md").write_text("# studio\nthe kiln is warm\n")
|
||||||
|
api = Api(world, memory_root=root)
|
||||||
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
await world.conversations.set_title(conv, "kiln schedule")
|
||||||
|
other = await world.conversations.create(kind="deep", agent="d", origin="test")
|
||||||
|
await world.conversations.post(other, "go")
|
||||||
|
await world.settle(other, 1)
|
||||||
|
other = await world.conversations.get(other.external_id)
|
||||||
|
assert other is not None and other.session_id is not None
|
||||||
|
async with world.db.session() as db:
|
||||||
|
db.add(
|
||||||
|
TranscriptEntry(
|
||||||
|
project_key="p",
|
||||||
|
session_id=other.session_id,
|
||||||
|
seq=0,
|
||||||
|
entry={"type": "assistant", "message": {"content": "warm kiln"}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
found = await api.get("/search", {"q": "kiln"})
|
||||||
|
assert {c["id"] for c in found["conversations"]} == {
|
||||||
|
conv.external_id,
|
||||||
|
other.external_id,
|
||||||
|
}
|
||||||
|
assert found["files"] == [
|
||||||
|
{"path": "notes/studio.md", "line": 2, "snippet": "the kiln is warm"}
|
||||||
|
]
|
||||||
|
short = await api.http.get("/search", params={"q": "k"}, headers=HEADERS)
|
||||||
|
assert short.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_context_endpoint_reports_prompt_and_files(world: World) -> None:
|
||||||
|
api = Api(world)
|
||||||
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
await world.conversations.post(conv, "go")
|
||||||
|
await world.settle(conv, 1)
|
||||||
|
conv = await world.conversations.get(conv.external_id)
|
||||||
|
assert conv is not None and conv.session_id is not None
|
||||||
|
await world.store.append(
|
||||||
|
world.key(conv.session_id),
|
||||||
|
build_entries(
|
||||||
|
[
|
||||||
|
{"role": "user", "content": "go"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "t1",
|
||||||
|
"name": "Read",
|
||||||
|
"input": {"file_path": str(world.root / "note.md")},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "t1", "content": "x"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
session_id=conv.session_id,
|
||||||
|
cwd=str(world.root),
|
||||||
|
model="m",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ctx = await api.get(f"/conversations/{conv.external_id}/context", {"prompt": 1})
|
||||||
|
assert ctx["agent"]["name"] == "a"
|
||||||
|
assert ctx["prompt"]["granules"][0]["bytes"] == len("hi")
|
||||||
|
assert ctx["prompt_text"] == "hi"
|
||||||
|
assert ctx["files"] == [
|
||||||
|
{
|
||||||
|
"path": "note.md",
|
||||||
|
"reads": 1,
|
||||||
|
"writes": 0,
|
||||||
|
"other": 0,
|
||||||
|
"last_at": ctx["files"][0]["last_at"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert ctx["tool_counts"] == {"Read": 1}
|
||||||
|
assert ctx["turns"] == 1
|
||||||
|
graph = await api.http.get("/vault/graph", headers=HEADERS)
|
||||||
|
assert graph.status_code == 404
|
||||||
@@ -86,6 +86,7 @@ class FakeClient:
|
|||||||
self.prompts: list[str] = []
|
self.prompts: list[str] = []
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self.session_id = options.resume or "fresh-session"
|
self.session_id = options.resume or "fresh-session"
|
||||||
|
self.asked = asyncio.Event()
|
||||||
FakeClient.instances.append(self)
|
FakeClient.instances.append(self)
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
@@ -93,6 +94,17 @@ class FakeClient:
|
|||||||
|
|
||||||
async def query(self, prompt: str) -> None:
|
async def query(self, prompt: str) -> None:
|
||||||
self.prompts.append(prompt)
|
self.prompts.append(prompt)
|
||||||
|
self.asked.set()
|
||||||
|
|
||||||
|
async def receive_messages(self):
|
||||||
|
served = 0
|
||||||
|
while True:
|
||||||
|
while len(self.prompts) <= served:
|
||||||
|
self.asked.clear()
|
||||||
|
await self.asked.wait()
|
||||||
|
served += 1
|
||||||
|
async for message in self.receive_response():
|
||||||
|
yield message
|
||||||
|
|
||||||
async def interrupt(self) -> None:
|
async def interrupt(self) -> None:
|
||||||
self.interrupted = True
|
self.interrupted = True
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver_gateway.agents.claude import ClaudeAgent, Prompts
|
||||||
|
from beaver_gateway.conversations.context import (
|
||||||
|
bash_paths,
|
||||||
|
compose,
|
||||||
|
files_touched,
|
||||||
|
granules,
|
||||||
|
)
|
||||||
|
from beaver_gateway.vault.links import LinkIndex
|
||||||
|
|
||||||
|
|
||||||
|
def entry(name: str, tool_input: dict, stamp: str = "2026-09-02T10:00:00Z") -> dict:
|
||||||
|
return {
|
||||||
|
"type": "assistant",
|
||||||
|
"timestamp": stamp,
|
||||||
|
"message": {
|
||||||
|
"content": [{"type": "tool_use", "name": name, "input": tool_input}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_touched_groups_by_path_relative_to_cwd() -> None:
|
||||||
|
cwd = Path("/vault")
|
||||||
|
files, counts = files_touched(
|
||||||
|
[
|
||||||
|
entry("Read", {"file_path": "/vault/a.md"}, "2026-09-02T10:00:00Z"),
|
||||||
|
entry("Edit", {"file_path": "/vault/a.md"}, "2026-09-02T10:05:00Z"),
|
||||||
|
entry("Grep", {"pattern": "x", "path": "/vault"}),
|
||||||
|
entry("Bash", {"command": "ls"}),
|
||||||
|
entry("Read", {"file_path": "/elsewhere/b.md"}, "2026-09-02T09:00:00Z"),
|
||||||
|
],
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
assert [f["path"] for f in files] == ["a.md", "/elsewhere/b.md"]
|
||||||
|
assert files[0] == {
|
||||||
|
"path": "a.md",
|
||||||
|
"reads": 1,
|
||||||
|
"writes": 1,
|
||||||
|
"other": 0,
|
||||||
|
"last_at": "2026-09-02T10:05:00Z",
|
||||||
|
}
|
||||||
|
assert counts == {"Read": 2, "Edit": 1, "Grep": 1, "Bash": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_paths_follow_cd_and_quotes() -> None:
|
||||||
|
paths, here, writes = bash_paths(
|
||||||
|
"cd /vault/мета && cat 'заметка.md' | head; sed -n 1,5p \"📆 доски/работа.md\"",
|
||||||
|
Path("/elsewhere"),
|
||||||
|
)
|
||||||
|
assert paths == ["/vault/мета/заметка.md", "/vault/мета/📆 доски/работа.md"]
|
||||||
|
assert here == Path("/vault/мета")
|
||||||
|
assert writes is False
|
||||||
|
_, _, writes = bash_paths(
|
||||||
|
'python3 - <<PY\np = pathlib.Path("поправки.md")\np.write_text(s)\nPY',
|
||||||
|
Path("/vault"),
|
||||||
|
)
|
||||||
|
assert writes is True
|
||||||
|
assert bash_paths("ls *.md; grep -c x notes.md", None)[0] == ["notes.md"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_touched_reads_shell_commands_with_a_sticky_cwd() -> None:
|
||||||
|
cwd = Path("/vault")
|
||||||
|
files, counts = files_touched(
|
||||||
|
[
|
||||||
|
entry("Bash", {"command": "cd /vault/мета && cat a.md"}, "1"),
|
||||||
|
entry("Bash", {"command": "python3 - <<PY\nopen('b.md','w')\nPY"}, "2"),
|
||||||
|
entry("Bash", {"command": "ls"}, "3"),
|
||||||
|
],
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
assert [(f["path"], f["reads"], f["writes"]) for f in files] == [
|
||||||
|
("мета/b.md", 0, 1),
|
||||||
|
("мета/a.md", 1, 0),
|
||||||
|
]
|
||||||
|
assert counts == {"Bash": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_reads_granules_and_skills() -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-ctx-"))
|
||||||
|
(root / "voice.md").write_text("be brief " * 40)
|
||||||
|
(root / "env.md").write_text("you are in the master thread")
|
||||||
|
skills = root / "skills" / "common"
|
||||||
|
(skills / "ops").mkdir(parents=True)
|
||||||
|
(skills / "ops" / "SKILL.md").write_text(
|
||||||
|
"---\nname: ops\ndescription: servers\n---\n"
|
||||||
|
)
|
||||||
|
agent = ClaudeAgent(
|
||||||
|
name="a",
|
||||||
|
model="m",
|
||||||
|
cwd=root,
|
||||||
|
prompts=Prompts(master=(("voice", root / "voice.md"), root / "env.md")),
|
||||||
|
skill_sets=(skills,),
|
||||||
|
gateway_tools=("say",),
|
||||||
|
)
|
||||||
|
out = compose(
|
||||||
|
agent,
|
||||||
|
"master",
|
||||||
|
[entry("Read", {"file_path": str(root / "env.md")})],
|
||||||
|
context_tokens=50_000,
|
||||||
|
)
|
||||||
|
assert [g["path"] for g in out["prompt"]["granules"]] == ["voice.md", "env.md"]
|
||||||
|
assert out["prompt"]["granules"][0]["tag"] == "voice"
|
||||||
|
assert out["prompt"]["tokens_est"] > 0
|
||||||
|
assert out["skills"] == [
|
||||||
|
{
|
||||||
|
"set": "common",
|
||||||
|
"path": "skills/common",
|
||||||
|
"skills": [
|
||||||
|
{"name": "ops", "description": "servers", "path": "skills/common/ops"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert out["tools"]["gateway"] == ["say"]
|
||||||
|
assert out["files"][0]["path"] == "env.md"
|
||||||
|
assert out["history_tokens_est"] < 50_000
|
||||||
|
assert granules(agent, "deep")["granules"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_index_resolves_stems_and_neighbours() -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-links-"))
|
||||||
|
(root / "people").mkdir()
|
||||||
|
(root / "people" / "Marta.md").write_text("works at [[Studio]] with [[Ilya|him]]")
|
||||||
|
(root / "Studio.md").write_text("see [[people/Marta]] and [[nowhere]]")
|
||||||
|
(root / "Ilya.md").write_text("plain")
|
||||||
|
(root / ".obsidian").mkdir()
|
||||||
|
(root / ".obsidian" / "x.md").write_text("[[Studio]]")
|
||||||
|
index = LinkIndex(root)
|
||||||
|
graph = index.neighbours([str(root / "people" / "Marta.md")])
|
||||||
|
nodes = {n["path"]: n for n in graph["nodes"]}
|
||||||
|
assert nodes["people/Marta.md"]["touched"] is True
|
||||||
|
assert set(nodes) == {"people/Marta.md", "Studio.md", "Ilya.md"}
|
||||||
|
assert {(e["from"], e["to"]) for e in graph["edges"]} == {
|
||||||
|
("people/Marta.md", "Studio.md"),
|
||||||
|
("people/Marta.md", "Ilya.md"),
|
||||||
|
("Studio.md", "people/Marta.md"),
|
||||||
|
}
|
||||||
|
missing = index.neighbours(["gone.md"])
|
||||||
|
assert missing["nodes"] == [
|
||||||
|
{"path": "gone.md", "title": "gone", "touched": True, "exists": False}
|
||||||
|
]
|
||||||
+109
-1
@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from claude_agent_sdk import (
|
from claude_agent_sdk import (
|
||||||
|
TaskNotificationMessage,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
InMemorySessionStore,
|
InMemorySessionStore,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
@@ -88,16 +89,46 @@ class ScriptedClient:
|
|||||||
def __init__(self, options: Any) -> None:
|
def __init__(self, options: Any) -> None:
|
||||||
self.options = options
|
self.options = options
|
||||||
self.prompts: list[str] = []
|
self.prompts: list[str] = []
|
||||||
|
self.contents: list[list[dict[str, Any]]] = []
|
||||||
self.session_id = options.resume or str(uuid.uuid4())
|
self.session_id = options.resume or str(uuid.uuid4())
|
||||||
self.interrupted = False
|
self.interrupted = False
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self.asked = asyncio.Event()
|
||||||
|
self.extra: list[Any] = []
|
||||||
ScriptedClient.instances.append(self)
|
ScriptedClient.instances.append(self)
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
self.connected = True
|
self.connected = True
|
||||||
|
|
||||||
async def query(self, prompt: str) -> None:
|
async def query(self, prompt) -> None:
|
||||||
|
if not isinstance(prompt, str):
|
||||||
|
messages = [m async for m in prompt]
|
||||||
|
content = messages[-1]["message"]["content"]
|
||||||
|
self.contents.append(content)
|
||||||
|
prompt = "\n".join(
|
||||||
|
b.get("text", "") for b in content if b.get("type") == "text"
|
||||||
|
)
|
||||||
self.prompts.append(prompt)
|
self.prompts.append(prompt)
|
||||||
|
self.asked.set()
|
||||||
|
|
||||||
|
async def receive_messages(self):
|
||||||
|
"""One response per prompt, forever; ``extra`` is what the CLI sends
|
||||||
|
on its own between prompts (a subagent reporting back)."""
|
||||||
|
served = 0
|
||||||
|
while True:
|
||||||
|
while len(self.prompts) <= served:
|
||||||
|
self.asked.clear()
|
||||||
|
if self.extra:
|
||||||
|
for message in self.extra:
|
||||||
|
if isinstance(message, asyncio.Event):
|
||||||
|
await message.wait()
|
||||||
|
continue
|
||||||
|
yield message
|
||||||
|
self.extra = []
|
||||||
|
await self.asked.wait()
|
||||||
|
served += 1
|
||||||
|
async for message in self.receive_response():
|
||||||
|
yield message
|
||||||
|
|
||||||
async def receive_response(self):
|
async def receive_response(self):
|
||||||
prompt = self.prompts[-1]
|
prompt = self.prompts[-1]
|
||||||
@@ -850,3 +881,80 @@ async def test_inject_into_a_closed_master_lands_in_the_open_one(world: World) -
|
|||||||
assert item.conversation_id == new.id
|
assert item.conversation_id == new.id
|
||||||
assert await world.statuses(old) == []
|
assert await world.statuses(old) == []
|
||||||
assert await world.statuses(new) == [("normal", "queued")]
|
assert await world.statuses(new) == [("normal", "queued")]
|
||||||
|
|
||||||
|
|
||||||
|
def _self_started(session_id: str, text: str) -> list[Any]:
|
||||||
|
"""What the CLI sends by itself once a background subagent reports back."""
|
||||||
|
return [
|
||||||
|
AssistantMessage(
|
||||||
|
content=[ToolUseBlock(id="tu_dig", name="Grep", input={"pattern": "au"})],
|
||||||
|
model="m",
|
||||||
|
parent_tool_use_id="tu_bg",
|
||||||
|
),
|
||||||
|
TaskNotificationMessage(
|
||||||
|
subtype="task_notification",
|
||||||
|
data={},
|
||||||
|
task_id="t1",
|
||||||
|
status="completed",
|
||||||
|
output_file="",
|
||||||
|
summary='Agent "dig" finished',
|
||||||
|
uuid="n1",
|
||||||
|
session_id=session_id,
|
||||||
|
),
|
||||||
|
AssistantMessage(content=[TextBlock(text=text)], model="m"),
|
||||||
|
ResultMessage(
|
||||||
|
subtype="success",
|
||||||
|
duration_ms=1,
|
||||||
|
duration_api_ms=1,
|
||||||
|
is_error=False,
|
||||||
|
num_turns=1,
|
||||||
|
session_id=session_id,
|
||||||
|
stop_reason="end_turn",
|
||||||
|
total_cost_usd=0.0,
|
||||||
|
usage={"input_tokens": 1, "output_tokens": 1},
|
||||||
|
origin={"kind": "task-notification"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subagent_report_between_turns_is_its_own_turn(world: World) -> None:
|
||||||
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
seen: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def collect() -> None:
|
||||||
|
async for event in world.bus.stream(conversation_id=conv.external_id):
|
||||||
|
seen.append(event)
|
||||||
|
|
||||||
|
task = asyncio.create_task(collect())
|
||||||
|
await world.conversations.post(conv, "go")
|
||||||
|
await world.settle(conv, 1)
|
||||||
|
client = ScriptedClient.instances[0]
|
||||||
|
client.extra = _self_started(client.session_id, "the agent found gold")
|
||||||
|
client.asked.set()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
task.cancel()
|
||||||
|
replies = [e for e in seen if e["type"] == "reply"]
|
||||||
|
assert [r["text"] for r in replies] == ["ok:go", "the agent found gold"]
|
||||||
|
assert replies[1]["item_origin"] == "task"
|
||||||
|
starts = [e for e in seen if e["type"] == "turn.start"]
|
||||||
|
assert [s["origin"] for s in starts] == ["user", "task"]
|
||||||
|
assert starts[1]["text"] == 'Agent "dig" finished (completed)'
|
||||||
|
assert len(client.prompts) == 1
|
||||||
|
first_end = next(i for i, e in enumerate(seen) if e["type"] == "turn.end")
|
||||||
|
later = [e for e in seen[first_end + 1 :] if e["type"] in ("stream", "tool")]
|
||||||
|
assert later and all(e["turn_id"] == starts[0]["turn_id"] for e in later[:1])
|
||||||
|
assert all(e["turn_id"] == starts[1]["turn_id"] for e in later[1:])
|
||||||
|
assert (await world.conversations.get(conv.external_id)).running_turn is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> None:
|
||||||
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
await world.conversations.post(conv, "one")
|
||||||
|
await world.settle(conv, 1)
|
||||||
|
client = ScriptedClient.instances[0]
|
||||||
|
client.extra = _self_started(client.session_id, "late report")
|
||||||
|
await world.conversations.post(conv, "two")
|
||||||
|
await world.settle(conv, 2)
|
||||||
|
assert client.prompts[-1].startswith("two")
|
||||||
|
rows = await world.conversations.queue.recent(conv.id)
|
||||||
|
assert [r.status for r in rows] == ["done", "done"]
|
||||||
@@ -219,3 +219,25 @@ async def test_failing_recall_or_sink_never_blocks_the_turn(world: World) -> Non
|
|||||||
assert prompt.startswith("hello\n\n" + HEADER)
|
assert prompt.startswith("hello\n\n" + HEADER)
|
||||||
assert root.exists()
|
assert root.exists()
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_recall_hook_and_the_sink_see_whose_turn_it_is(world: World) -> None:
|
||||||
|
root, watch = vault()
|
||||||
|
seen: list[RecallContext] = []
|
||||||
|
|
||||||
|
def recall(ctx: RecallContext) -> str | None:
|
||||||
|
seen.append(ctx)
|
||||||
|
return None if ctx.agent == "a" else "👤 указатель"
|
||||||
|
|
||||||
|
noted: list[UserSaid] = []
|
||||||
|
world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=recall) # noqa: SLF001
|
||||||
|
world.conversations._user_sink = noted.append # noqa: SLF001
|
||||||
|
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
append(root / "люди" / "Прохор.md", "новое\n")
|
||||||
|
watch.note(root / "люди" / "Прохор.md")
|
||||||
|
await world.conversations.post(master, "что там у Прохор")
|
||||||
|
await world.settle(master, 1)
|
||||||
|
assert [ctx.agent for ctx in seen] == ["a"]
|
||||||
|
assert [m.agent for m in noted] == ["a"]
|
||||||
|
assert "👤" not in ScriptedClient.instances[0].prompts[0]
|
||||||
|
await asyncio.sleep(0)
|
||||||
@@ -39,6 +39,23 @@ def test_thinking_renders_as_collapsed_callout() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_between_hidden_tool_calls_collapses_to_the_first() -> None:
|
||||||
|
message = _message(
|
||||||
|
ThinkingBlock(type="thinking", thinking="one", signature="s"),
|
||||||
|
ToolUseBlock(type="tool_use", id="t1", name="Read", input={}),
|
||||||
|
ThinkingBlock(type="thinking", thinking="two", signature="s"),
|
||||||
|
ToolUseBlock(type="tool_use", id="t2", name="Read", input={}),
|
||||||
|
ThinkingBlock(type="thinking", thinking="three", signature="s"),
|
||||||
|
TextBlock(type="text", text="answer"),
|
||||||
|
ThinkingBlock(type="thinking", thinking="four", signature="s"),
|
||||||
|
TextBlock(type="text", text="more"),
|
||||||
|
)
|
||||||
|
assert renderer.render_assistant_message(message) == (
|
||||||
|
"### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n"
|
||||||
|
"> [!thinking]-\n> four\n\nmore\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
|
def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
|
||||||
body = renderer.append_to_body("### Assistant:\n\nhi\n", renderer.USER_SCAFFOLD)
|
body = renderer.append_to_body("### Assistant:\n\nhi\n", renderer.USER_SCAFFOLD)
|
||||||
assert body.endswith("\n\n---\n\n### User:\n\n")
|
assert body.endswith("\n\n---\n\n### User:\n\n")
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from beaver_gateway.app import AgentRegistry, McpRegistry
|
||||||
|
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||||
|
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||||
|
from beaver_gateway.frontends.bearer import require_token
|
||||||
|
from beaver_gateway.security.auth import BUILTIN_SCOPES, TokenStore, scopes_with
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
|
ROOT = {"Authorization": "Bearer root-key"}
|
||||||
|
VOICE = {"Authorization": "Bearer voice-key"}
|
||||||
|
OPS = {"Authorization": "Bearer ops-key"}
|
||||||
|
|
||||||
|
|
||||||
|
class Speaker(Frontend):
|
||||||
|
"""A frontend a setup declares, gated by a scope of its own."""
|
||||||
|
|
||||||
|
name = "voice"
|
||||||
|
path = "/voice"
|
||||||
|
scope = "voice"
|
||||||
|
|
||||||
|
def configure(self, runtime: GatewayRuntime) -> None:
|
||||||
|
self.runtime = runtime
|
||||||
|
|
||||||
|
def app(self) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.post("/")
|
||||||
|
async def ask(request: Request) -> dict[str, str]:
|
||||||
|
return {"by": await require_token(request, self.runtime, scope=self.scope)}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def runtime() -> GatewayRuntime:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-scopes-"))
|
||||||
|
db = Database(f"sqlite:///{root / 's.db'}")
|
||||||
|
await db.create_all()
|
||||||
|
speaker = Speaker()
|
||||||
|
rt = GatewayRuntime(
|
||||||
|
agents=AgentRegistry([]),
|
||||||
|
mcps=McpRegistry([]),
|
||||||
|
backends={},
|
||||||
|
token_store=TokenStore(
|
||||||
|
db,
|
||||||
|
bootstrap={"root": "root-key", "voice": "voice-key", "ops": "ops-key"},
|
||||||
|
bootstrap_scopes={"voice": "voice", "ops": "api"},
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
frontends=(speaker,),
|
||||||
|
scopes=scopes_with(fe.scope for fe in (speaker,)),
|
||||||
|
)
|
||||||
|
speaker.configure(rt)
|
||||||
|
yield rt
|
||||||
|
await db.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def api(runtime: GatewayRuntime) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=build_api(runtime)), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frontend(runtime: GatewayRuntime) -> httpx.AsyncClient:
|
||||||
|
fe = runtime.frontends[0]
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=fe.app()), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frontend_scope_joins_the_builtin_vocabulary() -> None:
|
||||||
|
assert "voice" not in BUILTIN_SCOPES
|
||||||
|
assert scopes_with(["voice", None, "api"]) == BUILTIN_SCOPES | {"voice"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_scope_of_a_frontend_is_listed_with_its_name(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
listed = await client.get("/scopes", headers=ROOT)
|
||||||
|
scopes = listed.json()["scopes"]
|
||||||
|
assert {"scope": "voice", "frontend": "voice"} in scopes
|
||||||
|
assert {"scope": "api", "frontend": None} in scopes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tokens_mint_for_a_declared_scope_and_not_for_an_unknown_one(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
minted = await client.post(
|
||||||
|
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
|
||||||
|
)
|
||||||
|
unknown = await client.post(
|
||||||
|
"/tokens", json={"name": "nope", "scope": "kitchen"}, headers=ROOT
|
||||||
|
)
|
||||||
|
assert minted.status_code == 201
|
||||||
|
assert minted.json()["token"]["scope"] == "voice"
|
||||||
|
assert unknown.status_code == 400
|
||||||
|
assert "kitchen" in unknown.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_opens_for_its_own_scope_only(runtime: GatewayRuntime) -> None:
|
||||||
|
async with frontend(runtime) as client:
|
||||||
|
own = await client.post("/", headers=VOICE)
|
||||||
|
other = await client.post("/", headers=OPS)
|
||||||
|
wildcard = await client.post("/", headers=ROOT)
|
||||||
|
anonymous = await client.post("/")
|
||||||
|
assert own.status_code == 200
|
||||||
|
assert own.json()["by"] == "voice"
|
||||||
|
assert other.status_code == 403
|
||||||
|
assert wildcard.status_code == 200
|
||||||
|
assert anonymous.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_minted_token_reaches_the_frontend_it_was_minted_for(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
plaintext = (
|
||||||
|
await client.post(
|
||||||
|
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
|
||||||
|
)
|
||||||
|
).json()["plaintext"]
|
||||||
|
async with frontend(runtime) as client:
|
||||||
|
opened = await client.post("/", headers={"X-Api-Key": plaintext})
|
||||||
|
assert opened.status_code == 200
|
||||||
|
assert opened.json()["by"] == "esp32-hall"
|
||||||
+276
-1
@@ -1,8 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import functools
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
||||||
@@ -12,8 +15,11 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|||||||
|
|
||||||
from beaver_gateway.app import McpRegistry
|
from beaver_gateway.app import McpRegistry
|
||||||
from beaver_gateway.backends.transcript import build_entries
|
from beaver_gateway.backends.transcript import build_entries
|
||||||
|
from beaver_gateway.conversations.tools import _tools
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
|
||||||
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
||||||
|
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||||
from beaver_gateway.frontends.telegram.render import (
|
from beaver_gateway.frontends.telegram.render import (
|
||||||
LIMIT,
|
LIMIT,
|
||||||
chunks,
|
chunks,
|
||||||
@@ -33,6 +39,8 @@ class FakeBot:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.updates: list[dict[str, Any]] = []
|
self.updates: list[dict[str, Any]] = []
|
||||||
self.sent: list[dict[str, Any]] = []
|
self.sent: list[dict[str, Any]] = []
|
||||||
|
self.files: list[dict[str, Any]] = []
|
||||||
|
self.reject_files = False
|
||||||
self.drafts: list[dict[str, Any]] = []
|
self.drafts: list[dict[str, Any]] = []
|
||||||
self.edits: list[dict[str, Any]] = []
|
self.edits: list[dict[str, Any]] = []
|
||||||
self.topics: list[str] = []
|
self.topics: list[str] = []
|
||||||
@@ -129,6 +137,21 @@ class FakeBot:
|
|||||||
async def download(self, file_id, destination=None) -> None:
|
async def download(self, file_id, destination=None) -> None:
|
||||||
Path(destination).write_bytes(b"data")
|
Path(destination).write_bytes(b"data")
|
||||||
|
|
||||||
|
async def _upload(self, method: str, chat_id: int, **kwargs: Any) -> Any:
|
||||||
|
if self.reject_files:
|
||||||
|
raise TelegramBadRequest(
|
||||||
|
method=SendMessage(chat_id=0, text="x"),
|
||||||
|
message="Bad Request: PHOTO_INVALID_DIMENSIONS",
|
||||||
|
)
|
||||||
|
self._message_id += 1
|
||||||
|
self.files.append({"method": method, "chat_id": chat_id, **kwargs})
|
||||||
|
return SimpleNamespace(message_id=self._message_id)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name.startswith("send_"):
|
||||||
|
return functools.partial(self._upload, name.removeprefix("send_"))
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
||||||
# helpers for tests
|
# helpers for tests
|
||||||
def push(self, payload: dict[str, Any]) -> None:
|
def push(self, payload: dict[str, Any]) -> None:
|
||||||
payload["update_id"] = 1000 + len(self.updates)
|
payload["update_id"] = 1000 + len(self.updates)
|
||||||
@@ -717,6 +740,22 @@ def test_chunks_split_blockquote_and_stay_under_limit() -> None:
|
|||||||
assert " ".join(parts).split() == long_line.split()
|
assert " ".join(parts).split() == long_line.split()
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_breaks_a_paragraph_after_a_tool_call() -> None:
|
||||||
|
draft = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="t")
|
||||||
|
draft.append("Checking the balance.")
|
||||||
|
draft.set_status("⏳ Bash")
|
||||||
|
draft.set_status("✍️ writing")
|
||||||
|
draft.append("It is 20 378.")
|
||||||
|
assert draft.text == "Checking the balance.\n\nIt is 20 378."
|
||||||
|
draft.set_status("⏳ Read")
|
||||||
|
draft.append("\n\nAnd a list:")
|
||||||
|
assert draft.text.endswith("It is 20 378.\n\nAnd a list:")
|
||||||
|
fresh = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="u")
|
||||||
|
fresh.set_status("⏳ Bash")
|
||||||
|
fresh.append("First words")
|
||||||
|
assert fresh.text == "First words"
|
||||||
|
|
||||||
|
|
||||||
def test_to_html_tail_inside_fence() -> None:
|
def test_to_html_tail_inside_fence() -> None:
|
||||||
full = (
|
full = (
|
||||||
"intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter"
|
"intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter"
|
||||||
@@ -919,3 +958,239 @@ async def test_gone_topic_unbinds_and_the_next_message_starts_afresh(
|
|||||||
frontend="telegram", external_id=f"{USER}/7"
|
frontend="telegram", external_id=f"{USER}/7"
|
||||||
)
|
)
|
||||||
assert fresh is not None and fresh.id != branch.id and fresh.status == "open"
|
assert fresh is not None and fresh.id != branch.id and fresh.status == "open"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_photo_reaches_the_model_as_an_image_block(stack: Stack) -> None:
|
||||||
|
stack.bot.media(_photo("что на фото"))
|
||||||
|
await stack.until(lambda: stack.sent_with("что на фото"), what="reply")
|
||||||
|
client = next(c for c in ScriptedClient.instances if c.contents)
|
||||||
|
content = client.contents[-1]
|
||||||
|
assert content[0]["type"] == "text" and "что на фото" in content[0]["text"]
|
||||||
|
image = content[1]
|
||||||
|
assert image["type"] == "image"
|
||||||
|
assert image["source"]["media_type"] == "image/jpeg"
|
||||||
|
assert base64.b64decode(image["source"]["data"]) == b"data"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_album_becomes_one_turn(stack: Stack) -> None:
|
||||||
|
stack.tg.album_delay = 0.2
|
||||||
|
stack.bot.media({**_photo("альбом"), "media_group_id": "g1"})
|
||||||
|
second = _photo("")
|
||||||
|
second["photo"][0].update(file_id="f2", file_unique_id="u2")
|
||||||
|
del second["caption"]
|
||||||
|
stack.bot.media({**second, "media_group_id": "g1"})
|
||||||
|
await stack.until(lambda: stack.sent_with("альбом"), what="reply")
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
prompts = [
|
||||||
|
p for c in ScriptedClient.instances for p in c.prompts if "attachment" in p
|
||||||
|
]
|
||||||
|
assert len(prompts) == 1
|
||||||
|
assert prompts[0].count("[attachment: photo") == 2
|
||||||
|
client = next(c for c in ScriptedClient.instances if c.contents)
|
||||||
|
assert [b["type"] for b in client.contents[-1]] == ["text", "image", "image"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stop_button_interrupts_the_turn(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
ScriptedClient.hold = asyncio.Event()
|
||||||
|
stack.bot.message("думай долго")
|
||||||
|
await stack.until(lambda: stack.tg._drafts, what="draft") # noqa: SLF001
|
||||||
|
draft = next(iter(stack.tg._drafts.values())) # noqa: SLF001
|
||||||
|
await stack.until(
|
||||||
|
lambda: any(d.get("draft_id") == draft.draft_id for d in stack.bot.drafts),
|
||||||
|
what="draft pushed",
|
||||||
|
)
|
||||||
|
assert stack.bot.drafts[-1]["can_stop"] is True
|
||||||
|
stack.bot.push(
|
||||||
|
{
|
||||||
|
"stopped_message_generation": {
|
||||||
|
"chat": {"id": USER, "type": "private"},
|
||||||
|
"draft_id": draft.draft_id,
|
||||||
|
"message_thread_id": stack.bot.drafts[-1].get("message_thread_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await stack.until(lambda: stack.sent_with("думай долго"), what="reply after stop")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
items = await stack.world.conversations.queue.recent(master.id, limit=1)
|
||||||
|
assert items[0].status == "interrupted"
|
||||||
|
ScriptedClient.hold = None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_question_survives_a_gateway_restart(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"header": "Цвет",
|
||||||
|
"question": "Какой цвет?",
|
||||||
|
"options": [{"label": "Синий"}, {"label": "Красный"}],
|
||||||
|
"multiSelect": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
asking = asyncio.create_task(
|
||||||
|
stack.world.conversations.ask(master.external_id, payload)
|
||||||
|
)
|
||||||
|
question = await stack.until(
|
||||||
|
lambda: stack.sent_with("Какой цвет?"), what="question"
|
||||||
|
)
|
||||||
|
pending = stack.world.conversations.pending_question(master.external_id)
|
||||||
|
assert pending is not None
|
||||||
|
row = await stack.world.conversations.get(master.external_id)
|
||||||
|
assert row.flags["ask"]["id"] == pending[0]
|
||||||
|
asking.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await asking
|
||||||
|
stack.tg._asks.clear() # noqa: SLF001
|
||||||
|
stack.world.conversations._questions.clear() # noqa: SLF001
|
||||||
|
stack.bot.callback(f"q:{pending[0]}:0:1", question["message_id"])
|
||||||
|
await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
|
||||||
|
row = await stack.world.conversations.get(master.external_id)
|
||||||
|
assert row.flags.get("ask") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _sendable(name: str, body: bytes = b"x") -> tuple[Path, Path]:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-send-")).resolve()
|
||||||
|
file = root / name
|
||||||
|
file.write_bytes(body)
|
||||||
|
return root, file
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_files_takes_only_paths_inside_its_roots() -> None:
|
||||||
|
root, note = _sendable("note.txt")
|
||||||
|
outside = Path(tempfile.mkdtemp(prefix="beaver-out-")).resolve()
|
||||||
|
(outside / "secret.txt").write_bytes(b"y")
|
||||||
|
(root / "escape.txt").symlink_to(outside / "secret.txt")
|
||||||
|
files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
assert files.resolve(f"{root}/../{root.name}/./note.txt") == note
|
||||||
|
|
||||||
|
for bad in (
|
||||||
|
str(outside / "secret.txt"),
|
||||||
|
f"{root}/../{outside.name}/secret.txt",
|
||||||
|
str(root / "escape.txt"),
|
||||||
|
):
|
||||||
|
with pytest.raises(SendFileError, match="outside the allowed roots"):
|
||||||
|
files.resolve(bad)
|
||||||
|
with pytest.raises(SendFileError, match="is not a file"):
|
||||||
|
files.resolve(str(root / "gone.txt"))
|
||||||
|
with pytest.raises(SendFileError, match="outside the allowed roots"):
|
||||||
|
SendFiles().resolve(str(note))
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_files_stops_a_file_over_the_limit() -> None:
|
||||||
|
root, big = _sendable("big.bin", b"0" * (2 * 1024 * 1024))
|
||||||
|
files = SendFiles(roots=(root,), max_bytes=1024 * 1024)
|
||||||
|
with pytest.raises(SendFileError, match="big.bin is 2 MB, over the 1 MB limit"):
|
||||||
|
files.resolve(str(big))
|
||||||
|
assert SendFiles(roots=(root,), max_bytes=4 * 1024 * 1024).resolve(str(big)) == big
|
||||||
|
assert files.note == f"paths under {root}, up to 1 MB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_method_follows_the_extension_unless_it_is_given() -> None:
|
||||||
|
picked = {
|
||||||
|
name: method_for(Path(name))
|
||||||
|
for name in ("a.JPG", "b.gif", "c.mp3", "d.ogg", "e.mov", "f.zip", "g.svg")
|
||||||
|
}
|
||||||
|
assert picked == {
|
||||||
|
"a.JPG": "photo",
|
||||||
|
"b.gif": "animation",
|
||||||
|
"c.mp3": "audio",
|
||||||
|
"d.ogg": "voice",
|
||||||
|
"e.mov": "video",
|
||||||
|
"f.zip": "document",
|
||||||
|
"g.svg": "photo",
|
||||||
|
}
|
||||||
|
assert method_for(Path("a.jpg"), "document") == "document"
|
||||||
|
with pytest.raises(SendFileError, match="unknown method 'sticker'"):
|
||||||
|
method_for(Path("a.jpg"), "sticker")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(stack: Stack, conv, **args: Any) -> dict[str, Any]:
|
||||||
|
tool = next(
|
||||||
|
t
|
||||||
|
for t in _tools(stack.world.conversations, conv.external_id)
|
||||||
|
if t.name == "send_file"
|
||||||
|
)
|
||||||
|
return await tool.handler(args)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_file_lands_in_the_topic_of_the_conversation(stack: Stack) -> None:
|
||||||
|
stack.bot.message("work", thread=11)
|
||||||
|
await stack.until(lambda: stack.sent_with("work"), what="branch reply")
|
||||||
|
branch = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=f"{USER}/11"
|
||||||
|
)
|
||||||
|
root, shot = _sendable("график.png", b"png")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
result = await _send(stack, branch, path=str(shot), caption="**вот**")
|
||||||
|
|
||||||
|
assert result["content"][0]["text"].startswith("sent график.png as photo")
|
||||||
|
(sent,) = stack.bot.files
|
||||||
|
assert sent["method"] == "photo"
|
||||||
|
assert sent["message_thread_id"] == 11
|
||||||
|
assert sent["photo"].path == shot
|
||||||
|
assert sent["caption"] == "<b>вот</b>" and sent["parse_mode"] == "HTML"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_long_caption_follows_the_file_as_a_message(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
root, doc = _sendable("отчёт.pdf")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
await _send(stack, master, path=str(doc), caption="долго " * 300)
|
||||||
|
|
||||||
|
(sent,) = stack.bot.files
|
||||||
|
assert sent["method"] == "document" and "caption" not in sent
|
||||||
|
await stack.until(lambda: stack.sent_with("долго"), what="caption message")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_file_without_a_window_falls_back_to_the_master(
|
||||||
|
stack: Stack,
|
||||||
|
) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
job = await stack.world.conversations.spawn(
|
||||||
|
kind="job", agent="a", seed="brief", text="job", parent=None, origin="test"
|
||||||
|
)
|
||||||
|
root, song = _sendable("Пикник.mp3")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
await _send(stack, job, path=str(song))
|
||||||
|
|
||||||
|
(sent,) = stack.bot.files
|
||||||
|
assert sent["method"] == "audio" and sent["title"] == "Пикник"
|
||||||
|
assert (sent["chat_id"], sent["message_thread_id"]) == (USER, 901)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
root, shot = _sendable("bad.png")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
stack.bot.reject_files = True
|
||||||
|
|
||||||
|
result = await _send(stack, master, path=str(shot))
|
||||||
|
|
||||||
|
assert result["is_error"]
|
||||||
|
assert "Telegram refused bad.png as photo" in result["content"][0]["text"]
|
||||||
|
|
||||||
|
outside = await _send(stack, master, path="/etc/passwd")
|
||||||
|
assert outside["is_error"]
|
||||||
|
assert "outside the allowed roots" in outside["content"][0]["text"]
|
||||||
+12
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "ui",
|
"name": "ui",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
},
|
},
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.0",
|
||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -186,6 +188,8 @@
|
|||||||
|
|
||||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
|
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
||||||
@@ -226,6 +230,14 @@
|
|||||||
|
|
||||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
|
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||||
|
|
||||||
|
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||||
|
|
||||||
|
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"marked": "^18.0.11"
|
"marked": "^18.0.11"
|
||||||
},
|
},
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.0",
|
||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
+15
-12
@@ -10,18 +10,21 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<!--
|
<!--
|
||||||
impeccable direction contract (seed 20df1617, mode operate, brief-pinned world)
|
impeccable direction contract (seed 071a8c77, reroll 1 bolder, mode operate, user-steered fusion)
|
||||||
THESIS: activity first. The console opens on what the agents are doing right now, as a live
|
THESIS: the dispatcher's strip board under a live island. What is alive is a strip in a bay
|
||||||
ledger of turns and tool calls, and refuses the hero-metric dashboard grid of cards.
|
(waiting for you, in motion, quiet); the island at the top of every screen carries the live
|
||||||
OWN-WORLD: msos mauve/pink tokens on Inter; two neutral layers (cooler sidebar, content surface);
|
state and opens into the board. Refuses the sidebar-and-cards admin dashboard.
|
||||||
one accent for selection and the live state; kind hues from the msos status palette; hairline
|
OWN-WORLD: plum signal on a warm rack ground; strips as white/aubergine objects with a state
|
||||||
borders, tabular numerals, rows and rails instead of cards; no cards inside cards.
|
edge (plum pulse = running, amber = waiting), fixed columns, hairline rules, tabular numerals,
|
||||||
STORY: the operator lands, sees what runs and how much quota is left, opens a thread, watches
|
system sans; amber only for what waits for the operator; no cards inside cards, no badges as
|
||||||
tools and subagents stream, answers a question, checks spend, manages tokens and memory.
|
pills, no decorative motion.
|
||||||
FIRST VIEWPORT: sidebar left with the gateway connection dot; main opens with the "now" ledger
|
STORY: the operator lands on the board, answers a question on its strip, watches a running
|
||||||
(running turns as live rows), then quota bars and the 5 h / 7 d spend row, then live sessions.
|
strip, opens a thread where only the thread lives, reads context as a counter, finds anything
|
||||||
Primary action: open a running conversation.
|
with ⌘K, and reaches the text-heavy rooms (memory, usage, system) as wide documents.
|
||||||
FORM: dense operator console, brief-pinned (roll assigned index 7, superseded by the pin).
|
FIRST VIEWPORT: top bar with four section words left, the island centered (pulse, questions,
|
||||||
|
context, quota), System right. Below: the instrument row (context / 5 h / 7 d / spend), then
|
||||||
|
the bays with strips, the living graph of the master's neighbourhood on the right.
|
||||||
|
FORM: strip board × live activity; user-steered over the dealt hand; seed key 071a8c77.
|
||||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
||||||
the verdict, and DESIGN.md
|
the verdict, and DESIGN.md
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AgentsResponse,
|
AgentsResponse,
|
||||||
AuditPage,
|
AuditPage,
|
||||||
BusEvent,
|
BusEvent,
|
||||||
|
ContextResponse,
|
||||||
ConversationInfo,
|
ConversationInfo,
|
||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
EntriesPage,
|
EntriesPage,
|
||||||
@@ -11,10 +12,13 @@ import type {
|
|||||||
LimitsResponse,
|
LimitsResponse,
|
||||||
MemoryFile,
|
MemoryFile,
|
||||||
MemoryTree,
|
MemoryTree,
|
||||||
|
ScopeRow,
|
||||||
|
SearchResponse,
|
||||||
SessionsResponse,
|
SessionsResponse,
|
||||||
TokenRow,
|
TokenRow,
|
||||||
UsageGroup,
|
UsageGroup,
|
||||||
UsageResponse,
|
UsageResponse,
|
||||||
|
VaultGraph,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const TRAILING_SLASHES = /\/+$/;
|
const TRAILING_SLASHES = /\/+$/;
|
||||||
@@ -145,7 +149,8 @@ export class ApiClient {
|
|||||||
async stream(
|
async stream(
|
||||||
path: string,
|
path: string,
|
||||||
onEvent: (event: BusEvent) => void,
|
onEvent: (event: BusEvent) => void,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
onActivity?: () => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(this.url(path), {
|
const response = await fetch(this.url(path), {
|
||||||
headers: this.headers({ Accept: "text/event-stream" }),
|
headers: this.headers({ Accept: "text/event-stream" }),
|
||||||
@@ -159,7 +164,7 @@ export class ApiClient {
|
|||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await readSse(response, onEvent, signal);
|
await readSse(response, onEvent, signal, onActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
agents(): Promise<AgentsResponse> {
|
agents(): Promise<AgentsResponse> {
|
||||||
@@ -182,6 +187,23 @@ export class ApiClient {
|
|||||||
return this.get(`/api/conversations/${id}/history`);
|
return this.get(`/api/conversations/${id}/history`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context(id: string, prompt = false): Promise<ContextResponse> {
|
||||||
|
return this.get(`/api/conversations/${id}/context`, {
|
||||||
|
prompt: prompt ? 1 : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultGraph(paths: string[], limit?: number): Promise<VaultGraph> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const path of paths) {
|
||||||
|
search.append("path", path);
|
||||||
|
}
|
||||||
|
if (limit) {
|
||||||
|
search.set("limit", String(limit));
|
||||||
|
}
|
||||||
|
return this.get(`/api/vault/graph?${search.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
entries(
|
entries(
|
||||||
id: string,
|
id: string,
|
||||||
params?: { subpath?: string; offset?: number; limit?: number }
|
params?: { subpath?: string; offset?: number; limit?: number }
|
||||||
@@ -283,6 +305,10 @@ export class ApiClient {
|
|||||||
return this.post("/api/conversations", body);
|
return this.post("/api/conversations", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
search(q: string, limit?: number): Promise<SearchResponse> {
|
||||||
|
return this.get("/api/search", { limit, q });
|
||||||
|
}
|
||||||
|
|
||||||
sessions(): Promise<SessionsResponse> {
|
sessions(): Promise<SessionsResponse> {
|
||||||
return this.get("/api/sessions");
|
return this.get("/api/sessions");
|
||||||
}
|
}
|
||||||
@@ -326,6 +352,10 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scopes(): Promise<{ scopes: ScopeRow[] }> {
|
||||||
|
return this.get("/api/scopes");
|
||||||
|
}
|
||||||
|
|
||||||
createToken(
|
createToken(
|
||||||
name: string,
|
name: string,
|
||||||
scope: string
|
scope: string
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export type LiveState = "off" | "connecting" | "open" | "retrying" | "failed";
|
|||||||
const BACKOFF_MIN_MS = 1000;
|
const BACKOFF_MIN_MS = 1000;
|
||||||
const BACKOFF_MAX_MS = 30_000;
|
const BACKOFF_MAX_MS = 30_000;
|
||||||
const JITTER_MS = 250;
|
const JITTER_MS = 250;
|
||||||
|
// The server sends a keepalive comment every 15s, so twice that of silence
|
||||||
|
// means the socket is dead even though nothing errored - which is exactly
|
||||||
|
// what a phone brings back from sleep: a stream that reads as open forever.
|
||||||
|
const STALE_AFTER_MS = 40_000;
|
||||||
|
const WATCH_EVERY_MS = 5000;
|
||||||
const FATAL = new Set([401, 403, 404]);
|
const FATAL = new Set([401, 403, 404]);
|
||||||
|
|
||||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
@@ -41,9 +46,13 @@ export class LiveStream {
|
|||||||
state = $state<LiveState>("off");
|
state = $state<LiveState>("off");
|
||||||
detail = $state<string | null>(null);
|
detail = $state<string | null>(null);
|
||||||
private controller: AbortController | null = null;
|
private controller: AbortController | null = null;
|
||||||
|
private inner: AbortController | null = null;
|
||||||
private readonly client: () => ApiClient | null;
|
private readonly client: () => ApiClient | null;
|
||||||
private readonly path: string;
|
private readonly path: string;
|
||||||
private readonly options: LiveOptions;
|
private readonly options: LiveOptions;
|
||||||
|
private seen = 0;
|
||||||
|
private watcher: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private wake: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
client: () => ApiClient | null,
|
client: () => ApiClient | null,
|
||||||
@@ -65,6 +74,7 @@ export class LiveStream {
|
|||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.controller = controller;
|
this.controller = controller;
|
||||||
|
this.watch();
|
||||||
this.loop(controller.signal).catch((error: unknown) => {
|
this.loop(controller.signal).catch((error: unknown) => {
|
||||||
this.state = "failed";
|
this.state = "failed";
|
||||||
this.detail = describe(error);
|
this.detail = describe(error);
|
||||||
@@ -72,31 +82,108 @@ export class LiveStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.unwatch();
|
||||||
this.controller?.abort();
|
this.controller?.abort();
|
||||||
this.controller = null;
|
this.controller = null;
|
||||||
|
this.inner = null;
|
||||||
this.state = "off";
|
this.state = "off";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect now: the operator tapped the dot, or the app came back to the
|
||||||
|
* foreground. A failed stream has no loop left to nudge, so it restarts.
|
||||||
|
*/
|
||||||
|
refresh(): void {
|
||||||
|
if (!this.controller || this.state === "failed") {
|
||||||
|
this.stop();
|
||||||
|
this.start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state = "connecting";
|
||||||
|
this.detail = null;
|
||||||
|
this.inner?.abort();
|
||||||
|
this.wake?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stream that stopped delivering without erroring - the phone slept, the
|
||||||
|
* network changed under it - reads as open forever. Silence past the
|
||||||
|
* server's keepalive is the only tell, so the socket is dropped and the
|
||||||
|
* loop reconnects.
|
||||||
|
*/
|
||||||
|
private watch(): void {
|
||||||
|
if (this.watcher || typeof setInterval === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.watcher = setInterval(() => {
|
||||||
|
if (this.state !== "open" || Date.now() - this.seen < STALE_AFTER_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state = "retrying";
|
||||||
|
this.detail = "stream went quiet";
|
||||||
|
this.inner?.abort();
|
||||||
|
}, WATCH_EVERY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwatch(): void {
|
||||||
|
if (this.watcher) {
|
||||||
|
clearInterval(this.watcher);
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loop(signal: AbortSignal): Promise<void> {
|
private async loop(signal: AbortSignal): Promise<void> {
|
||||||
let delay = BACKOFF_MIN_MS;
|
let delay = BACKOFF_MIN_MS;
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
this.state = delay === BACKOFF_MIN_MS ? "connecting" : "retrying";
|
this.state = delay === BACKOFF_MIN_MS ? "connecting" : "retrying";
|
||||||
|
// Each attempt gets its own controller, so the watchdog (and a manual
|
||||||
|
// refresh) can drop one socket without ending the loop.
|
||||||
|
const inner = new AbortController();
|
||||||
|
this.inner = inner;
|
||||||
|
const relay = () => inner.abort();
|
||||||
|
signal.addEventListener("abort", relay, { once: true });
|
||||||
// biome-ignore lint/performance/noAwaitInLoops: one connection at a time, by design
|
// biome-ignore lint/performance/noAwaitInLoops: one connection at a time, by design
|
||||||
const outcome = await this.connectOnce(signal, () => {
|
const outcome = await this.connectOnce(inner.signal, () => {
|
||||||
delay = BACKOFF_MIN_MS;
|
delay = BACKOFF_MIN_MS;
|
||||||
});
|
});
|
||||||
|
signal.removeEventListener("abort", relay);
|
||||||
|
this.inner = null;
|
||||||
if (signal.aborted || outcome === "fatal") {
|
if (signal.aborted || outcome === "fatal") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sleep(delay + Math.random() * JITTER_MS, signal);
|
// A socket dropped on purpose reconnects at once; a broken one waits.
|
||||||
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
|
const wait = outcome === "now" ? 0 : delay + Math.random() * JITTER_MS;
|
||||||
|
await this.pause(wait, signal);
|
||||||
|
delay =
|
||||||
|
outcome === "now"
|
||||||
|
? BACKOFF_MIN_MS
|
||||||
|
: Math.min(delay * 2, BACKOFF_MAX_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Backoff that a manual refresh can cut short. */
|
||||||
|
private pause(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const done = () => {
|
||||||
|
this.wake = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
this.wake = done;
|
||||||
|
sleep(ms, signal).then(() => {
|
||||||
|
if (this.wake === done) {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}, done);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async connectOnce(
|
private async connectOnce(
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
): Promise<"retry" | "fatal"> {
|
): Promise<"retry" | "fatal" | "now"> {
|
||||||
try {
|
try {
|
||||||
const client = this.client();
|
const client = this.client();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -104,8 +191,9 @@ export class LiveStream {
|
|||||||
}
|
}
|
||||||
await this.options.prepare?.();
|
await this.options.prepare?.();
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return "fatal";
|
return this.controller ? "now" : "fatal";
|
||||||
}
|
}
|
||||||
|
this.seen = Date.now();
|
||||||
await client.stream(
|
await client.stream(
|
||||||
this.path,
|
this.path,
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -117,19 +205,25 @@ export class LiveStream {
|
|||||||
}
|
}
|
||||||
this.options.onEvent(event);
|
this.options.onEvent(event);
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
() => {
|
||||||
|
this.seen = Date.now();
|
||||||
|
}
|
||||||
);
|
);
|
||||||
this.state = "retrying";
|
this.state = "retrying";
|
||||||
this.detail = "stream closed";
|
this.detail = "stream closed";
|
||||||
return "retry";
|
return "retry";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return "fatal";
|
// Aborted while the session lives on: this was the watchdog or a
|
||||||
|
// manual refresh, so reconnect without waiting out the backoff.
|
||||||
|
return this.controller ? "now" : "fatal";
|
||||||
}
|
}
|
||||||
if (error instanceof ApiError && FATAL.has(error.status)) {
|
if (error instanceof ApiError && FATAL.has(error.status)) {
|
||||||
this.state = "failed";
|
this.state = "failed";
|
||||||
this.detail = `${error.status}: ${error.message}`;
|
this.detail = `${error.status}: ${error.message}`;
|
||||||
this.controller = null;
|
this.controller = null;
|
||||||
|
this.unwatch();
|
||||||
return "fatal";
|
return "fatal";
|
||||||
}
|
}
|
||||||
this.state = "retrying";
|
this.state = "retrying";
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ function drain(buffer: string, onEvent: (event: BusEvent) => void): string {
|
|||||||
export async function readSse(
|
export async function readSse(
|
||||||
response: Response,
|
response: Response,
|
||||||
onEvent: (event: BusEvent) => void,
|
onEvent: (event: BusEvent) => void,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
// Every chunk the socket delivers, keepalive comments included: proof the
|
||||||
|
// connection is still alive on a phone that just came back from sleep.
|
||||||
|
onActivity?: () => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { body } = response;
|
const { body } = response;
|
||||||
if (!body) {
|
if (!body) {
|
||||||
@@ -58,6 +61,7 @@ export async function readSse(
|
|||||||
if (done) {
|
if (done) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
onActivity?.();
|
||||||
buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent);
|
buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface QueueItem {
|
|||||||
|
|
||||||
export interface ConversationSummary {
|
export interface ConversationSummary {
|
||||||
agent: string;
|
agent: string;
|
||||||
|
context_tokens?: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
flags: Record<string, unknown>;
|
flags: Record<string, unknown>;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -62,6 +63,7 @@ export interface ConversationSummary {
|
|||||||
last_item?: QueueItem | null;
|
last_item?: QueueItem | null;
|
||||||
last_user_activity_at: string | null;
|
last_user_activity_at: string | null;
|
||||||
origin: string;
|
origin: string;
|
||||||
|
parent?: string | null;
|
||||||
parent_row: number | null;
|
parent_row: number | null;
|
||||||
pending_question: boolean;
|
pending_question: boolean;
|
||||||
running_turn: string | null;
|
running_turn: string | null;
|
||||||
@@ -104,6 +106,8 @@ export interface ContentBlock {
|
|||||||
export interface HistoryMessage {
|
export interface HistoryMessage {
|
||||||
content: string | ContentBlock[];
|
content: string | ContentBlock[];
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
|
// When the first block of this message was written; absent for old rows.
|
||||||
|
ts?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntriesPage {
|
export interface EntriesPage {
|
||||||
@@ -128,6 +132,7 @@ export interface FrontendInfo {
|
|||||||
kinds: Kind[];
|
kinds: Kind[];
|
||||||
name: string;
|
name: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
scope: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
@@ -209,6 +214,10 @@ export interface LimitRecord {
|
|||||||
|
|
||||||
export interface LimitWindow extends LimitRecord {
|
export interface LimitWindow extends LimitRecord {
|
||||||
gateway: UsageTotals & { since: string };
|
gateway: UsageTotals & { since: string };
|
||||||
|
// The last figure reported inside this window, if any: a lower bound.
|
||||||
|
last_known: LimitRecord | null;
|
||||||
|
// When the figure came from another event's unified report.
|
||||||
|
reported_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LimitsResponse {
|
export interface LimitsResponse {
|
||||||
@@ -237,6 +246,11 @@ export interface MemoryFile {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScopeRow {
|
||||||
|
frontend: string | null;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenRow {
|
export interface TokenRow {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -310,3 +324,76 @@ export interface TurnUsage {
|
|||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchFile {
|
||||||
|
line: number;
|
||||||
|
path: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
files: SearchFile[];
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Granule {
|
||||||
|
bytes: number;
|
||||||
|
path: string | null;
|
||||||
|
tag: string | null;
|
||||||
|
tokens_est: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillInfo {
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSetInfo {
|
||||||
|
path: string;
|
||||||
|
set: string;
|
||||||
|
skills: SkillInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TouchedFile {
|
||||||
|
last_at: string;
|
||||||
|
other: number;
|
||||||
|
path: string;
|
||||||
|
reads: number;
|
||||||
|
writes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextResponse {
|
||||||
|
agent: { effort: string | null; model: string; name: string };
|
||||||
|
context_tokens: number;
|
||||||
|
files: TouchedFile[];
|
||||||
|
history_tokens_est: number;
|
||||||
|
id: string;
|
||||||
|
kind: Kind;
|
||||||
|
prompt: { bytes: number; granules: Granule[]; tokens_est: number };
|
||||||
|
prompt_text?: string;
|
||||||
|
skills: SkillSetInfo[];
|
||||||
|
skills_tokens_est: number;
|
||||||
|
tool_counts: Record<string, number>;
|
||||||
|
tools: {
|
||||||
|
allowed: string[] | null;
|
||||||
|
disallowed: string[];
|
||||||
|
gateway: string[];
|
||||||
|
mcps: string[];
|
||||||
|
};
|
||||||
|
turns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultNode {
|
||||||
|
exists: boolean;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
touched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultGraph {
|
||||||
|
edges: { from: string; to: string }[];
|
||||||
|
nodes: VaultNode[];
|
||||||
|
root: string;
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
<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";
|
|
||||||
import LiveDot from "$lib/components/live-dot.svelte";
|
|
||||||
import ThemeToggle from "$lib/components/theme-toggle.svelte";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
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();
|
|
||||||
await goto(`${base}/login`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
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={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 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(
|
|
||||||
"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" />
|
|
||||||
{#if open}
|
|
||||||
<span class="flex-1">{item.label}</span>
|
|
||||||
{/if}
|
|
||||||
{#if item.href === "/" && running > 0}
|
|
||||||
{#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={cn(
|
|
||||||
"flex items-center gap-1 border-t",
|
|
||||||
open ? "px-3 py-2" : "flex-col px-1 py-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class={cn(open && "mr-auto")}
|
|
||||||
title="event stream from the gateway (SSE)"
|
|
||||||
>
|
|
||||||
<LiveDot
|
|
||||||
detail={gateway.live.detail}
|
|
||||||
label={open}
|
|
||||||
state={gateway.live.state}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<ThemeToggle />
|
|
||||||
<Button
|
|
||||||
aria-label="Sign out"
|
|
||||||
onclick={signOut}
|
|
||||||
size="icon-sm"
|
|
||||||
title="Sign out"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<LogOutIcon class="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
|
|
||||||
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,79 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
|
import ActivityIcon from "@lucide/svelte/icons/activity";
|
||||||
import { goto } from "$app/navigation";
|
import BrainIcon from "@lucide/svelte/icons/brain";
|
||||||
|
import GaugeIcon from "@lucide/svelte/icons/gauge";
|
||||||
|
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
|
||||||
|
import SlidersHorizontalIcon from "@lucide/svelte/icons/sliders-horizontal";
|
||||||
|
import type { Component } from "svelte";
|
||||||
import { base } from "$app/paths";
|
import { base } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import LiveDot from "$lib/components/live-dot.svelte";
|
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
|
||||||
import ThemeToggle from "$lib/components/theme-toggle.svelte";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import * as Sheet from "$lib/components/ui/sheet";
|
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
|
||||||
import { isActive, NAV } from "$lib/nav";
|
|
||||||
import { session } from "$lib/session.svelte";
|
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
let moreOpen = $state(false);
|
const ICONS: Record<string, Component<{ class?: string }>> = {
|
||||||
const primary = NAV.filter((item) => item.mobile);
|
"/": ActivityIcon,
|
||||||
const secondary = NAV.filter((item) => !item.mobile);
|
"/conversations": MessagesSquareIcon,
|
||||||
|
"/memory": BrainIcon,
|
||||||
async function signOut() {
|
"/system": SlidersHorizontalIcon,
|
||||||
moreOpen = false;
|
"/usage": GaugeIcon,
|
||||||
await session.logout();
|
};
|
||||||
await goto(`${base}/login`);
|
const items = [...SECTIONS, SYSTEM];
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Sections"
|
aria-label="Sections"
|
||||||
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden"
|
class="flex shrink-0 items-stretch border-t bg-rack/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md sm:hidden"
|
||||||
>
|
>
|
||||||
{#each primary as item (item.href)}
|
{#each items as item (item.href)}
|
||||||
{@const active = isActive(page.url.pathname, base, item.href)}
|
{@const active = isSectionActive(page.url.pathname, base, item.href)}
|
||||||
|
{@const Icon = ICONS[item.href]}
|
||||||
<a
|
<a
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]",
|
"flex h-13 flex-1 flex-col items-center justify-center gap-0.5 text-[10px] transition-colors",
|
||||||
active ? "text-link" : "text-muted-foreground"
|
active ? "text-primary" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
href="{base}{item.href}"
|
href="{base}{item.href}"
|
||||||
>
|
>
|
||||||
<item.icon class="size-5" />
|
<Icon class="size-5" />
|
||||||
{item.label}
|
{item.label.split(" ")[0]}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
|
||||||
class="flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px] text-muted-foreground"
|
|
||||||
onclick={() => {
|
|
||||||
moreOpen = true;
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon class="size-5" />
|
|
||||||
More
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Sheet.Root bind:open={moreOpen}>
|
|
||||||
<Sheet.Content class="flex flex-col gap-1 pt-10" side="bottom">
|
|
||||||
<Sheet.Title class="px-2 pb-2">More</Sheet.Title>
|
|
||||||
{#each secondary as item (item.href)}
|
|
||||||
<a
|
|
||||||
class="flex h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-muted"
|
|
||||||
href="{base}{item.href}"
|
|
||||||
onclick={() => {
|
|
||||||
moreOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<item.icon class="size-4 text-icon" />
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
<div class="mt-2 flex items-center justify-between border-t px-3 pt-3">
|
|
||||||
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<ThemeToggle />
|
|
||||||
<Button onclick={signOut} size="sm" variant="ghost">Sign out</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Sheet.Content>
|
|
||||||
</Sheet.Root>
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<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";
|
|
||||||
import { base } from "$app/paths";
|
|
||||||
import type { AgentInfo, ConversationSummary } from "$lib/api/types";
|
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
|
||||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
|
||||||
import StatusPill from "$lib/components/status-pill.svelte";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import { Input } from "$lib/components/ui/input";
|
|
||||||
import { Label } from "$lib/components/ui/label";
|
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
|
||||||
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();
|
|
||||||
|
|
||||||
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
|
|
||||||
const STATUSES = ["open", "all", "merged", "closed", "archived"];
|
|
||||||
const TITLE_MAX = 60;
|
|
||||||
const PREVIEW_MAX = 90;
|
|
||||||
|
|
||||||
let kind = $state("all");
|
|
||||||
let statusFilter = $state("open");
|
|
||||||
let search = $state("");
|
|
||||||
let createOpen = $state(false);
|
|
||||||
let agents = $state<AgentInfo[]>([]);
|
|
||||||
let form = $state({
|
|
||||||
agent: "",
|
|
||||||
kind: "deep",
|
|
||||||
seed: "clean",
|
|
||||||
text: "",
|
|
||||||
title: "",
|
|
||||||
});
|
|
||||||
let busy = $state(false);
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
const needle = search.trim().toLowerCase();
|
|
||||||
return gateway.conversations
|
|
||||||
.filter((row) => kind === "all" || row.kind === kind)
|
|
||||||
.filter((row) => statusFilter === "all" || row.status === statusFilter)
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
!needle ||
|
|
||||||
(row.title ?? "").toLowerCase().includes(needle) ||
|
|
||||||
row.id.startsWith(needle) ||
|
|
||||||
row.agent.toLowerCase().includes(needle) ||
|
|
||||||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
|
||||||
)
|
|
||||||
.sort(byActivity);
|
|
||||||
});
|
|
||||||
|
|
||||||
function activityOf(row: ConversationSummary): string {
|
|
||||||
return row.last_activity_at ?? row.created_at ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function byActivity(a: ConversationSummary, b: ConversationSummary) {
|
|
||||||
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
|
|
||||||
return a.running_turn ? -1 : 1;
|
|
||||||
}
|
|
||||||
return activityOf(b).localeCompare(activityOf(a));
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleOf(row: ConversationSummary): string {
|
|
||||||
if (row.title) {
|
|
||||||
return clip(row.title, TITLE_MAX);
|
|
||||||
}
|
|
||||||
if (row.kind === "master") {
|
|
||||||
return `Master · ${shortId(row.id)}`;
|
|
||||||
}
|
|
||||||
return `${row.kind} · ${shortId(row.id)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentsForKind = $derived(
|
|
||||||
agents.filter((a) =>
|
|
||||||
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
async function openCreate() {
|
|
||||||
createOpen = true;
|
|
||||||
if (agents.length === 0 && session.client) {
|
|
||||||
({ agents } = await session.client.agents());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create() {
|
|
||||||
if (!session.client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
const created = await session.client.spawn({
|
|
||||||
agent: form.agent || undefined,
|
|
||||||
kind: form.kind,
|
|
||||||
seed: form.seed,
|
|
||||||
text: form.text || undefined,
|
|
||||||
title: form.title || undefined,
|
|
||||||
});
|
|
||||||
createOpen = false;
|
|
||||||
await goto(`${base}/conversations/${created.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
|
||||||
<Select.Root
|
|
||||||
onValueChange={(value) => {
|
|
||||||
kind = value;
|
|
||||||
}}
|
|
||||||
type="single"
|
|
||||||
value={kind}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="h-8 text-xs" size="sm">
|
|
||||||
{kind === "all" ? "any kind" : kind}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each KINDS as option (option)}
|
|
||||||
<Select.Item
|
|
||||||
label={option === "all" ? "any kind" : option}
|
|
||||||
value={option}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<Select.Root
|
|
||||||
onValueChange={(value) => {
|
|
||||||
statusFilter = value;
|
|
||||||
}}
|
|
||||||
type="single"
|
|
||||||
value={statusFilter}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="h-8 text-xs" size="sm">
|
|
||||||
{statusFilter === "all" ? "any status" : statusFilter}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each STATUSES as option (option)}
|
|
||||||
<Select.Item
|
|
||||||
label={option === "all" ? "any status" : option}
|
|
||||||
value={option}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<Input
|
|
||||||
aria-label="Search conversations"
|
|
||||||
class="h-8 min-w-24 flex-1 text-xs"
|
|
||||||
placeholder="search"
|
|
||||||
bind:value={search}
|
|
||||||
/>
|
|
||||||
<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"}
|
|
||||||
<div class="p-3">
|
|
||||||
<ErrorNote
|
|
||||||
message={gateway.live.detail ?? "event stream failed"}
|
|
||||||
retry={() => gateway.start()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if !gateway.loaded}
|
|
||||||
<div class="flex flex-col gap-2 p-3">
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
</div>
|
|
||||||
{:else if rows.length === 0}
|
|
||||||
<div class="p-3">
|
|
||||||
<EmptyState
|
|
||||||
hint="Change the filters, or start one - a deep chat, a branch off the master, a headless job."
|
|
||||||
title="No conversations match"
|
|
||||||
>
|
|
||||||
<Button onclick={openCreate} size="sm" variant="outline">
|
|
||||||
New conversation
|
|
||||||
</Button>
|
|
||||||
</EmptyState>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="flex flex-col">
|
|
||||||
{#each rows as row (row.id)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
aria-current={selected === row.id ? "page" : undefined}
|
|
||||||
class={cn(
|
|
||||||
"row-hover flex flex-col gap-1 border-b px-3 py-2 text-sm",
|
|
||||||
selected === row.id && "bg-sidebar-accent"
|
|
||||||
)}
|
|
||||||
href="{base}/conversations/{row.id}"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<KindBadge kind={row.kind} />
|
|
||||||
<span class="min-w-0 flex-1 truncate font-medium">
|
|
||||||
{titleOf(row)}
|
|
||||||
</span>
|
|
||||||
<span class="tabular shrink-0 text-muted-foreground text-xs">
|
|
||||||
{fmtRelative(activityOf(row))}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-2 text-xs">
|
|
||||||
<StatusPill
|
|
||||||
status={row.running_turn ? "running" : row.status}
|
|
||||||
/>
|
|
||||||
<span class="text-muted-foreground">{row.agent}</span>
|
|
||||||
{#if row.pending_question}
|
|
||||||
<span class="font-medium text-link">question</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if row.last_item}
|
|
||||||
<span class="truncate text-muted-foreground text-xs">
|
|
||||||
<span class="text-foreground/70"
|
|
||||||
>{row.last_item.origin}:</span
|
|
||||||
>
|
|
||||||
{clip(row.last_item.text, PREVIEW_MAX)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Root bind:open={createOpen}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>New conversation</Dialog.Title>
|
|
||||||
<Dialog.Description>
|
|
||||||
It opens in the home window of its kind (a vault file, a Telegram topic)
|
|
||||||
and stays silent until someone speaks.
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<Label>Kind</Label>
|
|
||||||
<Select.Root
|
|
||||||
onValueChange={(value) => {
|
|
||||||
form.kind = value;
|
|
||||||
form.agent = "";
|
|
||||||
}}
|
|
||||||
type="single"
|
|
||||||
value={form.kind}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each ["deep", "branch", "master", "job"] as option (option)}
|
|
||||||
<Select.Item label={option} value={option} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<Label>Agent</Label>
|
|
||||||
<Select.Root
|
|
||||||
onValueChange={(value) => {
|
|
||||||
form.agent = value === "default" ? "" : value;
|
|
||||||
}}
|
|
||||||
type="single"
|
|
||||||
value={form.agent || "default"}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{form.agent || "frontend default"}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item label="frontend default" value="default" />
|
|
||||||
{#each agentsForKind as agent (agent.name)}
|
|
||||||
<Select.Item label={agent.name} value={agent.name} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex flex-col gap-1.5">
|
|
||||||
<Label for="new-title">Title</Label>
|
|
||||||
<Input id="new-title" placeholder="optional" bind:value={form.title} />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex flex-col gap-1.5">
|
|
||||||
<Label for="new-text">First message</Label>
|
|
||||||
<textarea
|
|
||||||
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
|
||||||
id="new-text"
|
|
||||||
placeholder="optional - without it the window waits"
|
|
||||||
bind:value={form.text}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
createOpen = false;
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button disabled={busy} onclick={create}>Create</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
@@ -117,7 +117,17 @@
|
|||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
|
endpoints: base
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
hint: fe.scope
|
||||||
|
? `POST the frontend's own payload; bearer with scope ${fe.scope}`
|
||||||
|
: "",
|
||||||
|
label: "base",
|
||||||
|
url: base,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
frontend: fe,
|
frontend: fe,
|
||||||
note: "",
|
note: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import LiveDot from "$lib/components/live-dot.svelte";
|
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
@@ -17,7 +15,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2 sm:px-6"
|
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 px-4 pt-4 pb-1 sm:px-6"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-baseline gap-2">
|
<div class="flex min-w-0 items-baseline gap-2">
|
||||||
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
|
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
|
||||||
@@ -30,15 +28,9 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ml-auto flex items-center gap-2">
|
{#if actions}
|
||||||
{#if actions}
|
<div class="ml-auto flex items-center gap-2">
|
||||||
{@render actions()}
|
{@render actions()}
|
||||||
{/if}
|
</div>
|
||||||
<LiveDot
|
{/if}
|
||||||
class="sm:hidden"
|
|
||||||
detail={gateway.live.detail}
|
|
||||||
label={false}
|
|
||||||
state={gateway.live.state}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
+8
-10
@@ -123,19 +123,17 @@ export function fmtRelative(
|
|||||||
if (!date) {
|
if (!date) {
|
||||||
return "–";
|
return "–";
|
||||||
}
|
}
|
||||||
const diff = now - date.getTime();
|
const diff = Math.max(0, now - date.getTime());
|
||||||
const abs = Math.abs(diff);
|
if (diff < MINUTE_MS) {
|
||||||
const suffix = diff >= 0 ? "ago" : "from now";
|
return "just now";
|
||||||
if (abs < MINUTE_MS) {
|
|
||||||
return diff >= 0 ? "just now" : "in <1m";
|
|
||||||
}
|
}
|
||||||
if (abs < HOUR_MS) {
|
if (diff < HOUR_MS) {
|
||||||
return `${Math.round(abs / MINUTE_MS)}m ${suffix}`;
|
return `${Math.round(diff / MINUTE_MS)}m ago`;
|
||||||
}
|
}
|
||||||
if (abs < DAY_MS) {
|
if (diff < DAY_MS) {
|
||||||
return `${Math.round(abs / HOUR_MS)}h ${suffix}`;
|
return `${Math.round(diff / HOUR_MS)}h ago`;
|
||||||
}
|
}
|
||||||
return `${Math.round(abs / DAY_MS)}d ${suffix}`;
|
return `${Math.round(diff / DAY_MS)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtCountdown(iso: string | null | undefined, now = Date.now()) {
|
export function fmtCountdown(iso: string | null | undefined, now = Date.now()) {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
|
import type {
|
||||||
|
BusEvent,
|
||||||
|
JobsResponse,
|
||||||
|
LimitsResponse,
|
||||||
|
LimitWindow,
|
||||||
|
} from "./api/types";
|
||||||
|
import { panelCache } from "./panel/cache";
|
||||||
|
import { browserHost } from "./panel/host";
|
||||||
import { ConversationIndex } from "./panel/index.svelte";
|
import { ConversationIndex } from "./panel/index.svelte";
|
||||||
import { session } from "./session.svelte";
|
import { session } from "./session.svelte";
|
||||||
|
|
||||||
const TAPE_SIZE = 120;
|
const TAPE_SIZE = 120;
|
||||||
const QUIET = new Set(["stream", "hello"]);
|
const QUIET = new Set(["stream", "hello"]);
|
||||||
|
const JOBS_KEY = "jobs";
|
||||||
|
const cache = panelCache("beaver.admin");
|
||||||
|
|
||||||
// Gateway-wide live state for the admin: the conversation index, the last
|
// Gateway-wide live state for the admin: the conversation index, the last
|
||||||
// non-chatty events as a tape, and the quota windows updated the moment
|
// non-chatty events as a tape, and the quota windows updated the moment
|
||||||
@@ -11,9 +20,20 @@ const QUIET = new Set(["stream", "hello"]);
|
|||||||
class Gateway extends ConversationIndex {
|
class Gateway extends ConversationIndex {
|
||||||
tape = $state<BusEvent[]>([]);
|
tape = $state<BusEvent[]>([]);
|
||||||
limits = $state<LimitsResponse | null>(null);
|
limits = $state<LimitsResponse | null>(null);
|
||||||
|
// The scheduler snapshot: what was last seen, shown at once, then refreshed.
|
||||||
|
jobs = $state<JobsResponse | null>(cache.get<JobsResponse>(JOBS_KEY) ?? null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(() => session.client);
|
super(() => session.client, browserHost().cache ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshJobs(): Promise<void> {
|
||||||
|
const client = this.client();
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.jobs = await client.jobs();
|
||||||
|
cache.set(JOBS_KEY, this.jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async load(): Promise<void> {
|
override async load(): Promise<void> {
|
||||||
@@ -28,6 +48,7 @@ class Gateway extends ConversationIndex {
|
|||||||
if (limits) {
|
if (limits) {
|
||||||
this.limits = limits;
|
this.limits = limits;
|
||||||
}
|
}
|
||||||
|
this.refreshJobs().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshLimits(): Promise<void> {
|
async refreshLimits(): Promise<void> {
|
||||||
@@ -48,6 +69,9 @@ class Gateway extends ConversationIndex {
|
|||||||
this.applyLimit(event);
|
this.applyLimit(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type.startsWith("job.") || event.type.startsWith("schedule.")) {
|
||||||
|
this.refreshJobs().catch(() => undefined);
|
||||||
|
}
|
||||||
super.apply(event);
|
super.apply(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -6,6 +6,27 @@ export const LIMIT_LABELS: Record<string, string> = {
|
|||||||
seven_day_sonnet: "7-day · Sonnet",
|
seven_day_sonnet: "7-day · Sonnet",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEVEN_DAY = "seven_day_";
|
||||||
|
|
||||||
export function limitLabel(window: string): string {
|
export function limitLabel(window: string): string {
|
||||||
return LIMIT_LABELS[window] ?? window;
|
const known = LIMIT_LABELS[window];
|
||||||
|
if (known) {
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
if (window.startsWith(SEVEN_DAY)) {
|
||||||
|
const model = window.slice(SEVEN_DAY.length);
|
||||||
|
return `7-day · ${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
||||||
|
}
|
||||||
|
return window.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status as a word, for the windows the API never puts a figure on.
|
||||||
|
export const LIMIT_WORDS: Record<string, string> = {
|
||||||
|
allowed: "clear",
|
||||||
|
allowed_warning: "near the limit",
|
||||||
|
rejected: "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function limitWord(status: string): string {
|
||||||
|
return LIMIT_WORDS[status] ?? status.replace("_", " ");
|
||||||
}
|
}
|
||||||
+35
-25
@@ -1,35 +1,45 @@
|
|||||||
import ActivityIcon from "@lucide/svelte/icons/activity";
|
|
||||||
import BrainIcon from "@lucide/svelte/icons/brain";
|
|
||||||
import ClockIcon from "@lucide/svelte/icons/clock";
|
|
||||||
import GaugeIcon from "@lucide/svelte/icons/gauge";
|
|
||||||
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
|
|
||||||
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
|
|
||||||
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
|
// Lit only on its own path, not on its children (an index page).
|
||||||
|
exact?: boolean;
|
||||||
href: string;
|
href: string;
|
||||||
icon: Component<{ class?: string }>;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
mobile: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV: NavItem[] = [
|
export const SECTIONS: NavItem[] = [
|
||||||
{ href: "/", icon: ActivityIcon, label: "Now", mobile: true },
|
{ href: "/", key: "1", label: "Now" },
|
||||||
{
|
{ href: "/conversations", key: "2", label: "Conversations" },
|
||||||
href: "/conversations",
|
{ href: "/memory", key: "3", label: "Memory" },
|
||||||
icon: MessagesSquareIcon,
|
{ href: "/usage", key: "4", label: "Usage" },
|
||||||
label: "Conversations",
|
|
||||||
mobile: true,
|
|
||||||
},
|
|
||||||
{ href: "/usage", icon: GaugeIcon, label: "Usage", mobile: true },
|
|
||||||
{ href: "/memory", icon: BrainIcon, label: "Memory", mobile: true },
|
|
||||||
{ href: "/tokens", icon: KeyRoundIcon, label: "Tokens", mobile: false },
|
|
||||||
{ href: "/jobs", icon: ClockIcon, label: "Jobs", mobile: false },
|
|
||||||
{ href: "/audit", icon: ScrollTextIcon, label: "Audit", mobile: false },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isActive(pathname: string, base: string, href: string) {
|
export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
|
||||||
|
|
||||||
|
export const SYSTEM_PAGES: NavItem[] = [
|
||||||
|
{ exact: true, href: "/system", key: "", label: "Sessions" },
|
||||||
|
{ href: "/system/agents", key: "", label: "Agents & endpoints" },
|
||||||
|
{ href: "/system/jobs", key: "", label: "Jobs" },
|
||||||
|
{ href: "/system/tokens", key: "", label: "Tokens" },
|
||||||
|
{ href: "/system/audit", key: "", label: "Audit" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isActive(
|
||||||
|
pathname: string,
|
||||||
|
base: string,
|
||||||
|
href: string,
|
||||||
|
exact = false
|
||||||
|
) {
|
||||||
|
const path = pathname.startsWith(base)
|
||||||
|
? pathname.slice(base.length)
|
||||||
|
: pathname;
|
||||||
|
const current = path || "/";
|
||||||
|
if (href === "/" || exact) {
|
||||||
|
return current === href;
|
||||||
|
}
|
||||||
|
return current === href || current.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSectionActive(pathname: string, base: string, href: string) {
|
||||||
const path = pathname.startsWith(base)
|
const path = pathname.startsWith(base)
|
||||||
? pathname.slice(base.length)
|
? pathname.slice(base.length)
|
||||||
: pathname;
|
: pathname;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (model.running) {
|
if (model.live.length > 0) {
|
||||||
now = Date.now();
|
now = Date.now();
|
||||||
}
|
}
|
||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface Turn {
|
|||||||
itemOrigin: string | null;
|
itemOrigin: string | null;
|
||||||
nodes: Record<string, ToolNode>;
|
nodes: Record<string, ToolNode>;
|
||||||
origin: string;
|
origin: string;
|
||||||
|
// A tool call came between two pieces of text; the next piece starts a
|
||||||
|
// paragraph, as the final message will show it.
|
||||||
|
paused: boolean;
|
||||||
resultSubtype: string | null;
|
resultSubtype: string | null;
|
||||||
roots: string[];
|
roots: string[];
|
||||||
says: string[];
|
says: string[];
|
||||||
@@ -51,6 +54,20 @@ function str(value: unknown): string | null {
|
|||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function joinText(head: string, tail: string, paused: boolean): string {
|
||||||
|
if (paused && head.trim() && !head.endsWith("\n")) {
|
||||||
|
return `${head.trimEnd()}\n\n${tail.trimStart()}`;
|
||||||
|
}
|
||||||
|
return head + tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLive(turn: Turn): boolean {
|
||||||
|
return (
|
||||||
|
turn.status === "running" ||
|
||||||
|
Object.values(turn.nodes).some((node) => node.status === "running")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
|
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
|
||||||
if (stop === "end_turn") {
|
if (stop === "end_turn") {
|
||||||
return turn.status === "error" ? "error" : "done";
|
return turn.status === "error" ? "error" : "done";
|
||||||
@@ -65,6 +82,9 @@ export class ActivityModel {
|
|||||||
turns = $state<Turn[]>([]);
|
turns = $state<Turn[]>([]);
|
||||||
question = $state<PendingQuestion | null>(null);
|
question = $state<PendingQuestion | null>(null);
|
||||||
|
|
||||||
|
// A subagent still working after its turn ended: nothing on this page
|
||||||
|
// knows the turn, so its tool calls hang under the launching Agent node
|
||||||
|
// of a turn named after that node.
|
||||||
private readonly handlers: Record<string, (c: Cursor) => void> = {
|
private readonly handlers: Record<string, (c: Cursor) => void> = {
|
||||||
"conversation.created": (c) => this.onConversation(c),
|
"conversation.created": (c) => this.onConversation(c),
|
||||||
"conversation.updated": (c) => this.onConversation(c),
|
"conversation.updated": (c) => this.onConversation(c),
|
||||||
@@ -89,6 +109,12 @@ export class ActivityModel {
|
|||||||
return this.turns.find((turn) => turn.status === "running") ?? null;
|
return this.turns.find((turn) => turn.status === "running") ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A turn is live while it runs or while a subagent it launched still
|
||||||
|
// works: the CLI reports subagent tool calls after the turn's own end.
|
||||||
|
get live(): Turn[] {
|
||||||
|
return this.turns.filter((turn) => isLive(turn));
|
||||||
|
}
|
||||||
|
|
||||||
setConversation(info: ConversationInfo): void {
|
setConversation(info: ConversationInfo): void {
|
||||||
this.conversation = info;
|
this.conversation = info;
|
||||||
this.question = info.question;
|
this.question = info.question;
|
||||||
@@ -151,6 +177,42 @@ export class ActivityModel {
|
|||||||
turn.startedAt = ts;
|
turn.startedAt = ts;
|
||||||
turn.itemOrigin = str(event.item_origin);
|
turn.itemOrigin = str(event.item_origin);
|
||||||
turn.userText = str(event.text) ?? turn.userText;
|
turn.userText = str(event.text) ?? turn.userText;
|
||||||
|
if (turn.origin === "task") {
|
||||||
|
this.settleAgents(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The report is in: whatever Agent node still runs has finished.
|
||||||
|
private settleAgents(ts: string): void {
|
||||||
|
for (const turn of this.turns) {
|
||||||
|
if (turn.origin === "agent" && turn.status === "running") {
|
||||||
|
turn.status = "done";
|
||||||
|
turn.endedAt = ts;
|
||||||
|
}
|
||||||
|
for (const node of Object.values(turn.nodes)) {
|
||||||
|
const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?";
|
||||||
|
if (node.status === "running" && node.parent === null && agent) {
|
||||||
|
node.status = "done";
|
||||||
|
node.endedAt = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subagent traffic belongs to the turn that launched the subagent, whatever
|
||||||
|
// turn the gateway was in when it arrived.
|
||||||
|
private turnFor(turnId: string, ts: string, parent: string | null): Turn {
|
||||||
|
if (parent) {
|
||||||
|
const owner = this.turns.find((turn) => parent in turn.nodes);
|
||||||
|
if (owner) {
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
const current = this.turns.find((turn) => turn.id === turnId);
|
||||||
|
if (current?.status !== "running") {
|
||||||
|
return this.ensureTurn(`agent:${parent}`, ts, "agent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.ensureTurn(turnId, ts, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onStream({ event, ts, turnId, parent }: Cursor): void {
|
private onStream({ event, ts, turnId, parent }: Cursor): void {
|
||||||
@@ -158,7 +220,7 @@ export class ActivityModel {
|
|||||||
if (!(turnId && raw && typeof raw === "object")) {
|
if (!(turnId && raw && typeof raw === "object")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const turn = this.ensureTurn(turnId, ts, null);
|
const turn = this.turnFor(turnId, ts, parent);
|
||||||
const sdk = raw as Record<string, unknown>;
|
const sdk = raw as Record<string, unknown>;
|
||||||
if (sdk.type === "content_block_start") {
|
if (sdk.type === "content_block_start") {
|
||||||
const block = sdk.content_block as Record<string, unknown> | undefined;
|
const block = sdk.content_block as Record<string, unknown> | undefined;
|
||||||
@@ -172,7 +234,8 @@ export class ActivityModel {
|
|||||||
if (sdk.type === "content_block_delta" && parent === null) {
|
if (sdk.type === "content_block_delta" && parent === null) {
|
||||||
const delta = sdk.delta as Record<string, unknown> | undefined;
|
const delta = sdk.delta as Record<string, unknown> | undefined;
|
||||||
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
||||||
turn.text += delta.text;
|
turn.text = joinText(turn.text, delta.text, turn.paused);
|
||||||
|
turn.paused = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +244,10 @@ export class ActivityModel {
|
|||||||
if (!(turnId && typeof event.tool_use_id === "string")) {
|
if (!(turnId && typeof event.tool_use_id === "string")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const turn = this.ensureTurn(turnId, ts, null);
|
const turn = this.turnFor(turnId, ts, parent);
|
||||||
|
if (parent === null) {
|
||||||
|
turn.paused = true;
|
||||||
|
}
|
||||||
const node = this.ensureNode(
|
const node = this.ensureNode(
|
||||||
turn,
|
turn,
|
||||||
event.tool_use_id,
|
event.tool_use_id,
|
||||||
@@ -196,7 +262,7 @@ export class ActivityModel {
|
|||||||
if (!(turnId && typeof event.tool_use_id === "string")) {
|
if (!(turnId && typeof event.tool_use_id === "string")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const turn = this.ensureTurn(turnId, ts, null);
|
const turn = this.turnFor(turnId, ts, parent);
|
||||||
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
|
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
|
||||||
node.status = event.is_error ? "error" : "done";
|
node.status = event.is_error ? "error" : "done";
|
||||||
node.endedAt = ts;
|
node.endedAt = ts;
|
||||||
@@ -266,11 +332,13 @@ export class ActivityModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The turn's own tool calls die with it; a subagent's keep going until
|
||||||
|
// their own results arrive.
|
||||||
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
|
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
|
||||||
turn.status = status;
|
turn.status = status;
|
||||||
turn.endedAt = ts;
|
turn.endedAt = ts;
|
||||||
for (const node of Object.values(turn.nodes)) {
|
for (const node of Object.values(turn.nodes)) {
|
||||||
if (node.status === "running") {
|
if (node.status === "running" && node.parent === null) {
|
||||||
node.status = "aborted";
|
node.status = "aborted";
|
||||||
node.endedAt = ts;
|
node.endedAt = ts;
|
||||||
}
|
}
|
||||||
@@ -291,6 +359,7 @@ export class ActivityModel {
|
|||||||
itemOrigin: null,
|
itemOrigin: null,
|
||||||
nodes: {},
|
nodes: {},
|
||||||
origin: origin ?? "?",
|
origin: origin ?? "?",
|
||||||
|
paused: false,
|
||||||
resultSubtype: null,
|
resultSubtype: null,
|
||||||
roots: [],
|
roots: [],
|
||||||
says: [],
|
says: [],
|
||||||
@@ -394,3 +463,11 @@ export function summarizeInput(name: string, input: unknown): string {
|
|||||||
export function toolLabel(name: string): string {
|
export function toolLabel(name: string): string {
|
||||||
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
|
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How a turn was started, as a word for the header.
|
||||||
|
export const ORIGIN_LABELS: Record<string, string> = {
|
||||||
|
agent: "subagent at work",
|
||||||
|
inject: "inject",
|
||||||
|
task: "subagent report",
|
||||||
|
user: "user",
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// A small keyed cache behind the panel: the conversation index and the
|
||||||
|
// last threads land here so the next open paints before the network
|
||||||
|
// answers. localStorage when it exists, memory otherwise.
|
||||||
|
export interface PanelCache {
|
||||||
|
get: <T>(key: string) => T | null;
|
||||||
|
set: (key: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memory = new Map<string, string>();
|
||||||
|
|
||||||
|
function storage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
return localStorage;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* sandboxed */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
getItem: (key) => memory.get(key) ?? null,
|
||||||
|
removeItem: (key) => {
|
||||||
|
memory.delete(key);
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
memory.set(key, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelCache(prefix: string): PanelCache {
|
||||||
|
const store = storage();
|
||||||
|
return {
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = store.getItem(`${prefix}:${key}`);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(key: string, value: unknown): void {
|
||||||
|
try {
|
||||||
|
store.setItem(`${prefix}:${key}`, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
store.removeItem(`${prefix}:${key}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
import { clip, fmtDateTime, fmtTime } from "$lib/format";
|
||||||
import { Switch } from "$lib/components/ui/switch";
|
|
||||||
import { clip } from "$lib/format";
|
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import type { ActivityModel } from "./activity.svelte";
|
import type { ActivityModel } from "./activity.svelte";
|
||||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
|
||||||
|
import { cacheableHistory, historyKey } from "./history-cache";
|
||||||
|
import { usePanelHost } from "./host";
|
||||||
import Markdown from "./markdown.svelte";
|
import Markdown from "./markdown.svelte";
|
||||||
import QuestionCard from "./question-card.svelte";
|
import QuestionCard from "./question-card.svelte";
|
||||||
import TurnCard from "./turn-card.svelte";
|
import TurnCard from "./turn-card.svelte";
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const RESULT_CLIP = 600;
|
const RESULT_CLIP = 600;
|
||||||
|
// Markdown for the last few screens renders at once; the rest waits
|
||||||
|
// until scrolled near, so a long thread paints as fast as a short one.
|
||||||
|
const EAGER_MARKDOWN = 40;
|
||||||
|
const host = usePanelHost();
|
||||||
const NEAR_BOTTOM_PX = 120;
|
const NEAR_BOTTOM_PX = 120;
|
||||||
const TICK_MS = 1000;
|
const TICK_MS = 1000;
|
||||||
|
|
||||||
@@ -36,12 +40,15 @@
|
|||||||
let loadedAt = $state(new Date(0).toISOString());
|
let loadedAt = $state(new Date(0).toISOString());
|
||||||
let scroller = $state<HTMLDivElement | null>(null);
|
let scroller = $state<HTMLDivElement | null>(null);
|
||||||
let body = $state<HTMLDivElement | null>(null);
|
let body = $state<HTMLDivElement | null>(null);
|
||||||
|
const SETTLE_MS = 2500;
|
||||||
|
const SETTLE_STEP_MS = 120;
|
||||||
|
let settleUntil = 0;
|
||||||
let now = $state(Date.now());
|
let now = $state(Date.now());
|
||||||
let pinned = true;
|
let pinned = true;
|
||||||
|
|
||||||
const tail = $derived(
|
const tail = $derived(
|
||||||
model.turns
|
model.turns
|
||||||
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
|
.filter((turn) => isLive(turn) || turn.startedAt > loadedAt)
|
||||||
.reverse()
|
.reverse()
|
||||||
);
|
);
|
||||||
const tailSize = $derived(
|
const tailSize = $derived(
|
||||||
@@ -53,34 +60,69 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
failure = null;
|
failure = null;
|
||||||
|
if (messages === null) {
|
||||||
|
const cached = host.cache?.get<HistoryMessage[]>(
|
||||||
|
historyKey(conversationId)
|
||||||
|
);
|
||||||
|
if (cached) {
|
||||||
|
messages = cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
({ messages } = await client.history(conversationId));
|
({ messages } = await client.history(conversationId));
|
||||||
loadedAt = new Date().toISOString();
|
loadedAt = new Date().toISOString();
|
||||||
|
settle();
|
||||||
|
host.cache?.set(historyKey(conversationId), cacheableHistory(messages));
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
failure = cause instanceof Error ? cause.message : String(cause);
|
failure = cause instanceof Error ? cause.message : String(cause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The scroller is column-reverse: its origin is the bottom, scrollTop is
|
||||||
|
// zero there and goes negative upward, and the browser keeps the view at
|
||||||
|
// the bottom while content grows - no matter how late markdown lands.
|
||||||
function nearBottom(): boolean {
|
function nearBottom(): boolean {
|
||||||
if (!scroller) {
|
return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX;
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
function toBottom() {
|
||||||
|
if (scroller) {
|
||||||
|
scroller.scrollTop = 0;
|
||||||
}
|
}
|
||||||
const left =
|
|
||||||
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
|
||||||
return left < NEAR_BOTTOM_PX;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToBottom() {
|
async function scrollToBottom() {
|
||||||
await tick();
|
await tick();
|
||||||
if (scroller) {
|
toBottom();
|
||||||
scroller.scrollTop = scroller.scrollHeight;
|
}
|
||||||
}
|
|
||||||
|
// A fresh thread keeps growing after it is on screen: markdown renders
|
||||||
|
// late, embeds load, fonts land. Hold the bottom for a while after a load.
|
||||||
|
function settle() {
|
||||||
|
settleUntil = Date.now() + SETTLE_MS;
|
||||||
|
const step = () => {
|
||||||
|
if (!pinned || Date.now() > settleUntil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toBottom();
|
||||||
|
setTimeout(step, SETTLE_STEP_MS);
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The date changes rarely; say it once, then only the time.
|
||||||
|
function dayOf(iso: string | null | undefined): string {
|
||||||
|
return iso ? iso.slice(0, 10) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function minuteOf(iso: string | null | undefined): string {
|
||||||
|
return iso ? fmtTime(iso).replace(SECONDS, "") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (refreshKey >= 0) {
|
if (refreshKey >= 0) {
|
||||||
pinned = true;
|
pinned = true;
|
||||||
load();
|
untrack(() => load());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,13 +150,45 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (model.running) {
|
if (model.live.length > 0) {
|
||||||
now = Date.now();
|
now = Date.now();
|
||||||
}
|
}
|
||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Text the gateway or the CLI put in the user's seat: an inject header,
|
||||||
|
// an envelope, a subagent's report.
|
||||||
|
const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
|
||||||
|
const SECONDS = /:\d{2}$/;
|
||||||
|
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
|
||||||
|
let openSystem = $state<Set<number>>(new Set());
|
||||||
|
|
||||||
|
function systemText(message: HistoryMessage): string | null {
|
||||||
|
if (message.role !== "user" || typeof message.content !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return SYSTEM_HEAD.test(message.content.trimStart())
|
||||||
|
? message.content
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSystem(index: number) {
|
||||||
|
const next = new Set(openSystem);
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index);
|
||||||
|
} else {
|
||||||
|
next.add(index);
|
||||||
|
}
|
||||||
|
openSystem = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys count from the end: when the network copy grows the head past
|
||||||
|
// what the cache held, the tail keeps its nodes and its rendered markdown.
|
||||||
|
function keyOf(message: HistoryMessage, index: number, total: number) {
|
||||||
|
return `${index - total}:${message.role}:${message.ts ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
function blocks(message: HistoryMessage): ContentBlock[] {
|
function blocks(message: HistoryMessage): ContentBlock[] {
|
||||||
return typeof message.content === "string"
|
return typeof message.content === "string"
|
||||||
? [{ text: message.content, type: "text" }]
|
? [{ text: message.content, type: "text" }]
|
||||||
@@ -140,58 +214,114 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6"
|
||||||
onscroll={() => {
|
onscroll={() => {
|
||||||
pinned = nearBottom();
|
if (Date.now() > settleUntil) {
|
||||||
|
pinned = nearBottom();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
bind:this={scroller}
|
bind:this={scroller}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3" bind:this={body}>
|
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
|
||||||
<span
|
<button
|
||||||
class="flex items-center gap-2 self-end text-muted-foreground text-xs"
|
class="rule-word self-end text-xs"
|
||||||
|
data-active={showResults}
|
||||||
|
onclick={() => {
|
||||||
|
showResults = !showResults;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
{showResults ? "hide tool results" : "show tool results"}
|
||||||
show tool results
|
</button>
|
||||||
</span>
|
|
||||||
{#if failure}
|
{#if failure}
|
||||||
<ErrorNote message={failure} retry={load} />
|
<ErrorNote message={failure} retry={load} />
|
||||||
{:else if messages === null}
|
{:else if messages === null}
|
||||||
<div class="flex flex-col gap-2">
|
<p class="py-6 text-center text-muted-foreground text-xs">
|
||||||
<Skeleton class="h-10 w-2/3" />
|
Loading the thread…
|
||||||
<Skeleton class="h-16 w-full" />
|
</p>
|
||||||
<Skeleton class="h-10 w-1/2" />
|
|
||||||
</div>
|
|
||||||
{:else if messages.length === 0 && tail.length === 0}
|
{:else if messages.length === 0 && tail.length === 0}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
hint="Nothing has been said here yet. Write below, or wait for the first inject."
|
hint="Nothing has been said here yet. Write below, or wait for the first inject."
|
||||||
title="Empty conversation"
|
title="Empty conversation"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#each messages as message, index (index)}
|
{#each messages as message, index (keyOf(message, index, messages.length))}
|
||||||
{@const parts = blocks(message)}
|
{@const parts = blocks(message)}
|
||||||
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
||||||
{#if !(isResultOnly && !showResults)}
|
{@const system = systemText(message)}
|
||||||
|
{@const newDay =
|
||||||
|
message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)}
|
||||||
|
{@const newMinute =
|
||||||
|
message.ts &&
|
||||||
|
minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)}
|
||||||
|
{@const spoken =
|
||||||
|
message.role === "user" ||
|
||||||
|
parts.some((b) => b.type === "text" && Boolean(b.text))}
|
||||||
|
{#if newDay}
|
||||||
|
<p class="label-quiet self-center pt-2">
|
||||||
|
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if system}
|
||||||
|
<div class="flex min-w-0 items-start justify-center gap-2">
|
||||||
|
{#if message.ts}
|
||||||
|
<span
|
||||||
|
class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60"
|
||||||
|
title={fmtDateTime(message.ts)}
|
||||||
|
>
|
||||||
|
{minuteOf(message.ts)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
aria-expanded={openSystem.has(index)}
|
||||||
|
class="@md:max-w-[75ch] min-w-0 max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
|
||||||
|
onclick={() => toggleSystem(index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if openSystem.has(index)}
|
||||||
|
<span class="whitespace-pre-wrap break-words">{system}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="block truncate"
|
||||||
|
>{clip(system.split("\n")[0], 120)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if !(isResultOnly && !showResults)}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex flex-col gap-1.5",
|
"group flex min-w-0 max-w-full flex-col gap-1.5",
|
||||||
message.role === "user" && !isResultOnly && "items-end"
|
message.role === "user" && !isResultOnly && "items-end"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{#if message.ts && (newMinute || spoken) && !isResultOnly}
|
||||||
|
<span
|
||||||
|
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
title={fmtDateTime(message.ts)}
|
||||||
|
>
|
||||||
|
{minuteOf(message.ts)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#each parts as block, blockIndex (blockIndex)}
|
{#each parts as block, blockIndex (blockIndex)}
|
||||||
{#if block.type === "text" && block.text}
|
{#if block.type === "text" && block.text}
|
||||||
<Markdown
|
<Markdown
|
||||||
class={cn(
|
class={cn(
|
||||||
"max-w-[75ch] rounded-lg px-3 py-2",
|
"min-w-0 @md:max-w-[75ch] max-w-full",
|
||||||
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
|
message.role === "user"
|
||||||
)}
|
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
|
||||||
|
: "px-1 py-1"
|
||||||
|
)}
|
||||||
|
eager={index >= messages.length - EAGER_MARKDOWN}
|
||||||
text={block.text}
|
text={block.text}
|
||||||
/>
|
/>
|
||||||
{:else if block.type === "tool_use"}
|
{:else if block.type === "tool_use"}
|
||||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
<p
|
||||||
<span class="font-medium"
|
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 font-medium"
|
||||||
>{toolLabel(block.name ?? "?")}</span
|
>{toolLabel(block.name ?? "?")}</span
|
||||||
>
|
>
|
||||||
<span class="truncate text-muted-foreground">
|
<span class="min-w-0 truncate text-muted-foreground">
|
||||||
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
|
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { cn } from "$lib/utils";
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
client,
|
client,
|
||||||
@@ -17,28 +16,31 @@
|
|||||||
|
|
||||||
type Mode = "message" | "inject" | "urgent";
|
type Mode = "message" | "inject" | "urgent";
|
||||||
const MODES: { value: Mode; label: string; hint: string }[] = [
|
const MODES: { value: Mode; label: string; hint: string }[] = [
|
||||||
|
{ hint: "as you, mirrored to the window", label: "Say", value: "message" },
|
||||||
{
|
{
|
||||||
hint: "as the user, mirrored to Telegram",
|
hint: "a system note for the next turn",
|
||||||
label: "Message",
|
|
||||||
value: "message",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hint: "system note, rides with the next turn",
|
|
||||||
label: "Inject",
|
label: "Inject",
|
||||||
value: "inject",
|
value: "inject",
|
||||||
},
|
},
|
||||||
{
|
{ hint: "interrupts the running turn", label: "Urgent", value: "urgent" },
|
||||||
hint: "interrupts the running turn",
|
|
||||||
label: "Urgent inject",
|
|
||||||
value: "urgent",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
const MAX_ROWS = 10;
|
||||||
|
const LINE_PX = 22;
|
||||||
|
|
||||||
let mode = $state<Mode>("message");
|
let mode = $state<Mode>("message");
|
||||||
let text = $state("");
|
let text = $state("");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let area = $state<HTMLTextAreaElement | null>(null);
|
||||||
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
|
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
|
||||||
|
|
||||||
|
function grow() {
|
||||||
|
if (!area) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
area.style.height = "auto";
|
||||||
|
area.style.height = `${Math.min(area.scrollHeight, LINE_PX * MAX_ROWS)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const body = text.trim();
|
const body = text.trim();
|
||||||
if (!body || busy) {
|
if (!body || busy) {
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
text = "";
|
text = "";
|
||||||
|
queueMicrotask(grow);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,57 +75,60 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
|
class="flex flex-col gap-1.5 px-3 pt-2 pb-2 @md:px-6"
|
||||||
onsubmit={(event) => {
|
onsubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
send();
|
send();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<div
|
||||||
aria-label="Message"
|
class={cn(
|
||||||
class="min-h-16 w-full resize-y rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
"flex items-end gap-2 rounded-xl bg-strip px-3 py-2 shadow-lift ring-1 ring-border focus-within:ring-ring",
|
||||||
{disabled}
|
disabled && "opacity-60"
|
||||||
onkeydown={onKeydown}
|
)}
|
||||||
placeholder={mode === "message"
|
>
|
||||||
? "Say something to the agent…"
|
<textarea
|
||||||
: "Text of the inject…"}
|
aria-label="Message"
|
||||||
rows="2"
|
class="max-h-56 min-h-6 w-full resize-none bg-transparent py-0.5 text-sm leading-[22px] outline-none placeholder:text-muted-foreground"
|
||||||
bind:value={text}
|
{disabled}
|
||||||
></textarea>
|
oninput={grow}
|
||||||
<div class="flex items-center gap-2">
|
onkeydown={onKeydown}
|
||||||
<Select.Root
|
placeholder={disabled
|
||||||
onValueChange={(value) => {
|
? "This conversation is closed."
|
||||||
mode = value as Mode;
|
: `${current.label} something… (⌘↩)`}
|
||||||
}}
|
rows="1"
|
||||||
type="single"
|
bind:this={area}
|
||||||
value={mode}
|
bind:value={text}
|
||||||
>
|
></textarea>
|
||||||
<Select.Trigger class="h-8 text-xs" size="sm"
|
<button
|
||||||
>{current.label}</Select.Trigger
|
aria-label="Send"
|
||||||
>
|
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
|
||||||
<Select.Content>
|
|
||||||
{#each MODES as option (option.value)}
|
|
||||||
<Select.Item label={option.label} value={option.value}>
|
|
||||||
<span class="flex flex-col">
|
|
||||||
<span>{option.label}</span>
|
|
||||||
<span class="text-muted-foreground text-xs">{option.hint}</span>
|
|
||||||
</span>
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<span class="hidden text-muted-foreground text-xs @md:inline"
|
|
||||||
>{current.hint}</span
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
class="ml-auto"
|
|
||||||
disabled={disabled || busy || !text.trim()}
|
disabled={disabled || busy || !text.trim()}
|
||||||
size="sm"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<SendHorizontalIcon class="size-4" />
|
<ArrowUpIcon class="size-4" />
|
||||||
Send
|
</button>
|
||||||
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
|
</div>
|
||||||
</Button>
|
<div class="flex items-center gap-3 px-1">
|
||||||
|
{#each MODES as option (option.value)}
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"rule-word text-xs",
|
||||||
|
mode === option.value && "text-foreground",
|
||||||
|
option.value === "urgent" && mode === option.value && "text-destructive"
|
||||||
|
)}
|
||||||
|
data-active={mode === option.value}
|
||||||
|
onclick={() => {
|
||||||
|
mode = option.value;
|
||||||
|
}}
|
||||||
|
title={option.hint}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<span class="hidden truncate text-muted-foreground text-xs @md:inline">
|
||||||
|
{current.hint}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { ApiClient } from "$lib/api/client";
|
||||||
|
import type { ContextResponse, VaultGraph } from "$lib/api/types";
|
||||||
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
|
import { fmtRelative, fmtTokens } from "$lib/format";
|
||||||
|
import Graph, {
|
||||||
|
type GraphEdge,
|
||||||
|
type GraphNode,
|
||||||
|
} from "$lib/shell/graph.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { usePanelHost } from "./host";
|
||||||
|
|
||||||
|
let {
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
window = 1_000_000,
|
||||||
|
}: {
|
||||||
|
client: ApiClient;
|
||||||
|
conversationId: string;
|
||||||
|
window?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const HARNESS_EST = 15_000;
|
||||||
|
const FILES_SHOWN = 12;
|
||||||
|
const MD_SUFFIX = /\.md$/;
|
||||||
|
|
||||||
|
const host = usePanelHost();
|
||||||
|
let data = $state<ContextResponse | null>(null);
|
||||||
|
let graph = $state<VaultGraph | null>(null);
|
||||||
|
let graphError = $state<string | null>(null);
|
||||||
|
let failure = $state<string | null>(null);
|
||||||
|
let promptOpen = $state(false);
|
||||||
|
let promptText = $state<string | null>(null);
|
||||||
|
let filesOpen = $state(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
failure = null;
|
||||||
|
try {
|
||||||
|
data = await client.context(conversationId);
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paths = data.files.map((f) => f.path);
|
||||||
|
if (paths.length === 0) {
|
||||||
|
graph = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
graph = await client.vaultGraph(paths.slice(0, 40));
|
||||||
|
graphError = null;
|
||||||
|
} catch (cause) {
|
||||||
|
graph = null;
|
||||||
|
graphError = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPrompt() {
|
||||||
|
promptOpen = !promptOpen;
|
||||||
|
if (promptOpen && promptText === null) {
|
||||||
|
try {
|
||||||
|
const full = await client.context(conversationId, true);
|
||||||
|
promptText = full.prompt_text ?? "";
|
||||||
|
} catch (cause) {
|
||||||
|
promptText = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
interface Layer {
|
||||||
|
label: string;
|
||||||
|
tokens: number;
|
||||||
|
tone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = $derived.by((): Layer[] => {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const prompt = data.prompt.tokens_est;
|
||||||
|
const skills = data.skills_tokens_est;
|
||||||
|
const harness = Math.min(
|
||||||
|
HARNESS_EST,
|
||||||
|
Math.max(0, data.context_tokens - prompt - skills)
|
||||||
|
);
|
||||||
|
const history = Math.max(
|
||||||
|
0,
|
||||||
|
data.context_tokens - prompt - skills - harness
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
{ label: "harness", tokens: harness, tone: "bg-muted-foreground/40" },
|
||||||
|
{ label: "prompt", tokens: prompt, tone: "bg-primary" },
|
||||||
|
{ label: "skills", tokens: skills, tone: "bg-kind-deep" },
|
||||||
|
{ label: "history & tools", tokens: history, tone: "bg-kind-branch" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const total = $derived(data?.context_tokens ?? 0);
|
||||||
|
const share = (tokens: number) =>
|
||||||
|
total > 0 ? `${Math.max(0.5, (tokens / total) * 100)}%` : "0%";
|
||||||
|
|
||||||
|
const graphNodes = $derived.by((): GraphNode[] => {
|
||||||
|
if (!graph) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const single = graph.nodes.filter((n) => n.touched).length === 1;
|
||||||
|
return graph.nodes.map((n) => ({
|
||||||
|
href: n.exists ? n.path : undefined,
|
||||||
|
id: n.path,
|
||||||
|
kind: "file",
|
||||||
|
label: n.title,
|
||||||
|
ring: ringOf(n.touched, single),
|
||||||
|
state: stateOf(n.touched, n.exists),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const graphEdges = $derived.by((): GraphEdge[] =>
|
||||||
|
graph ? graph.edges.map((e) => ({ from: e.from, to: e.to })) : []
|
||||||
|
);
|
||||||
|
const files = $derived(
|
||||||
|
filesOpen ? (data?.files ?? []) : (data?.files ?? []).slice(0, FILES_SHOWN)
|
||||||
|
);
|
||||||
|
|
||||||
|
function ringOf(touched: boolean, single: boolean): number {
|
||||||
|
if (!touched) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return single ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateOf(touched: boolean, exists: boolean): GraphNode["state"] {
|
||||||
|
if (!touched) {
|
||||||
|
return "ghost";
|
||||||
|
}
|
||||||
|
return exists ? "quiet" : "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNote(path: string) {
|
||||||
|
host.openLink(path.replace(MD_SUFFIX, ""), "internal");
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(file: {
|
||||||
|
reads: number;
|
||||||
|
writes: number;
|
||||||
|
other: number;
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (file.reads) {
|
||||||
|
parts.push(`read ×${file.reads}`);
|
||||||
|
}
|
||||||
|
if (file.writes) {
|
||||||
|
parts.push(`wrote ×${file.writes}`);
|
||||||
|
}
|
||||||
|
if (file.other) {
|
||||||
|
parts.push(`touched ×${file.other}`);
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="@container flex max-w-5xl flex-col gap-8">
|
||||||
|
{#if failure}
|
||||||
|
<ErrorNote message={failure} retry={load} />
|
||||||
|
{:else if !data}
|
||||||
|
<p class="text-muted-foreground text-sm">Reading the context…</p>
|
||||||
|
{:else}
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-baseline gap-3">
|
||||||
|
<span
|
||||||
|
class="tabular font-semibold text-3xl leading-none tracking-tight"
|
||||||
|
>
|
||||||
|
{fmtTokens(total)}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground text-sm">
|
||||||
|
of {fmtTokens(window)} · {data.turns}
|
||||||
|
{data.turns === 1 ? "turn" : "turns"}
|
||||||
|
· {data.agent.model}
|
||||||
|
{data.agent.effort
|
||||||
|
? ` · ${data.agent.effort}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
{#each layers as layer (layer.label)}
|
||||||
|
{#if layer.tokens > 0}
|
||||||
|
<span
|
||||||
|
class={cn("h-full", layer.tone)}
|
||||||
|
style="width: {share(layer.tokens)}"
|
||||||
|
title="{layer.label}: {fmtTokens(layer.tokens)}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-5 gap-y-1 text-xs">
|
||||||
|
{#each layers as layer (layer.label)}
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class={cn("size-2 rounded-full", layer.tone)}></span>
|
||||||
|
<span class="text-muted-foreground">{layer.label}</span>
|
||||||
|
<span class="tabular">{fmtTokens(layer.tokens)}</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<span class="text-muted-foreground/70"
|
||||||
|
>estimates, except the total</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-8 @3xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<div class="flex min-w-0 flex-col gap-8">
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-baseline gap-3">
|
||||||
|
<h2 class="font-medium text-sm">System prompt</h2>
|
||||||
|
<span class="tabular text-muted-foreground text-xs">
|
||||||
|
{fmtTokens(data.prompt.tokens_est)}
|
||||||
|
· {data.prompt.granules.length} granules
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rule-word ml-auto text-xs"
|
||||||
|
data-active={promptOpen}
|
||||||
|
onclick={showPrompt}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{promptOpen ? "hide text" : "read it"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bay-rack">
|
||||||
|
{#each data.prompt.granules as g, index (index)}
|
||||||
|
<div class="strip text-sm">
|
||||||
|
<span
|
||||||
|
class="size-2 justify-self-center rounded-full bg-primary"
|
||||||
|
></span>
|
||||||
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
|
<button
|
||||||
|
class="truncate text-left font-medium hover:underline"
|
||||||
|
onclick={() => g.path && openNote(g.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{g.path ?? "verbatim prompt"}
|
||||||
|
</button>
|
||||||
|
{#if g.tag}
|
||||||
|
<span
|
||||||
|
class="truncate font-mono text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<{g.tag}>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="tabular text-muted-foreground text-xs">
|
||||||
|
{fmtTokens(g.tokens_est)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if promptOpen}
|
||||||
|
<pre
|
||||||
|
class="max-h-[32rem] overflow-auto rounded-lg border bg-strip p-3 text-xs whitespace-pre-wrap break-words"
|
||||||
|
>{promptText ?? "…"}</pre>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-baseline gap-3">
|
||||||
|
<h2 class="font-medium text-sm">Skills</h2>
|
||||||
|
<span class="tabular text-muted-foreground text-xs">
|
||||||
|
{data.skills.reduce((n, s) => n + s.skills.length, 0)}
|
||||||
|
loaded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if data.skills.length === 0}
|
||||||
|
<p class="text-muted-foreground text-sm">None for this kind.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="bay-rack">
|
||||||
|
{#each data.skills as set (set.path)}
|
||||||
|
{#each set.skills as skill (skill.path)}
|
||||||
|
<div class="strip text-sm">
|
||||||
|
<span
|
||||||
|
class="size-2 justify-self-center rounded-full bg-kind-deep"
|
||||||
|
></span>
|
||||||
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
|
<span class="truncate font-medium">{skill.name}</span>
|
||||||
|
{#if skill.description}
|
||||||
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
|
{skill.description}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground text-xs">{set.set}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h2 class="font-medium text-sm">Tools</h2>
|
||||||
|
<dl
|
||||||
|
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
|
||||||
|
>
|
||||||
|
{#if Object.keys(data.tool_counts).length > 0}
|
||||||
|
<dt class="text-muted-foreground">used</dt>
|
||||||
|
<dd class="tabular">
|
||||||
|
{Object.entries(data.tool_counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([name, n]) => `${name.replace("mcp__", "")} ×${n}`)
|
||||||
|
.join(" ")}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
{#if data.tools.gateway.length}
|
||||||
|
<dt class="text-muted-foreground">gateway</dt>
|
||||||
|
<dd>{data.tools.gateway.join(", ")}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if data.tools.mcps.length}
|
||||||
|
<dt class="text-muted-foreground">mcp</dt>
|
||||||
|
<dd>{data.tools.mcps.join(", ")}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if data.tools.allowed}
|
||||||
|
<dt class="text-muted-foreground">allowed</dt>
|
||||||
|
<dd>{data.tools.allowed.join(", ")}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if data.tools.disallowed.length}
|
||||||
|
<dt class="text-muted-foreground">off</dt>
|
||||||
|
<dd class="text-muted-foreground">
|
||||||
|
{data.tools.disallowed.join(", ")}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-8">
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-baseline gap-3">
|
||||||
|
<h2 class="font-medium text-sm">Notes it reached</h2>
|
||||||
|
<span class="tabular text-muted-foreground text-xs"
|
||||||
|
>{data.files.length}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if graph && graph.nodes.length > 0}
|
||||||
|
<div class="h-72 overflow-hidden rounded-lg border bg-strip/60">
|
||||||
|
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Solid: read or written by this thread. Hollow: one link away, not
|
||||||
|
reached.
|
||||||
|
</p>
|
||||||
|
{:else if graphError}
|
||||||
|
<p class="text-muted-foreground text-xs">{graphError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.files.length === 0}
|
||||||
|
<p class="text-muted-foreground text-sm">No files touched yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="bay-rack">
|
||||||
|
{#each files as file (file.path)}
|
||||||
|
<button
|
||||||
|
class="strip w-full text-left text-sm"
|
||||||
|
onclick={() => openNote(file.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"size-2 justify-self-center rounded-full",
|
||||||
|
file.writes ? "bg-primary" : "bg-kind-branch"
|
||||||
|
)}
|
||||||
|
></span>
|
||||||
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
|
<span class="truncate font-medium">{file.path}</span>
|
||||||
|
<span class="truncate text-muted-foreground text-xs"
|
||||||
|
>{touch(file)}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="tabular text-muted-foreground text-xs">
|
||||||
|
{fmtRelative(file.last_at)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if data.files.length > FILES_SHOWN}
|
||||||
|
<button
|
||||||
|
class="rule-word self-start text-xs"
|
||||||
|
onclick={() => {
|
||||||
|
filesOpen = !filesOpen;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{filesOpen ? "fewer" : `all ${data.files.length}`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import type { LiveState } from "$lib/api/live.svelte";
|
import type { LiveState } from "$lib/api/live.svelte";
|
||||||
import type { ConversationInfo } from "$lib/api/types";
|
import type { ConversationInfo } from "$lib/api/types";
|
||||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
import { fmtRelative, fmtTokens, shortId } from "$lib/format";
|
||||||
import LiveDot from "$lib/components/live-dot.svelte";
|
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||||
import StatusPill from "$lib/components/status-pill.svelte";
|
import { cn } from "$lib/utils";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import BindingsList from "./bindings-list.svelte";
|
||||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
|
||||||
import BranchDialog from "./branch-dialog.svelte";
|
import BranchDialog from "./branch-dialog.svelte";
|
||||||
import ConversationMenu from "./conversation-menu.svelte";
|
import ConversationMenu from "./conversation-menu.svelte";
|
||||||
|
import QueueList from "./queue-list.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
client,
|
client,
|
||||||
@@ -20,63 +21,119 @@
|
|||||||
onChanged,
|
onChanged,
|
||||||
onOpen,
|
onOpen,
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
|
view = $bindable("chat"),
|
||||||
|
onContext,
|
||||||
}: {
|
}: {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
info: ConversationInfo;
|
info: ConversationInfo;
|
||||||
liveState: LiveState;
|
liveState: LiveState;
|
||||||
liveDetail?: string | null;
|
liveDetail?: string | null;
|
||||||
// Where another conversation lives as a link; without it, parents open in place.
|
|
||||||
href?: ((id: string) => string) | null;
|
href?: ((id: string) => string) | null;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
view?: string;
|
||||||
|
onContext?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let branchOpen = $state(false);
|
const VIEWS = [
|
||||||
|
{ label: "Thread", value: "chat" },
|
||||||
|
{ label: "Activity", value: "activity" },
|
||||||
|
{ label: "Raw", value: "raw" },
|
||||||
|
{ label: "Context", value: "context" },
|
||||||
|
];
|
||||||
|
|
||||||
const WINDOW_MAX = 56;
|
let branchOpen = $state(false);
|
||||||
const windows = $derived(info.bindings.filter((b) => b.visible));
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
|
const mood = $derived.by(() => {
|
||||||
|
if (info.running_turn) {
|
||||||
|
return {
|
||||||
|
dot: "bg-signal animate-pulse-dot",
|
||||||
|
text: "in motion",
|
||||||
|
tone: "text-signal",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (info.pending_question) {
|
||||||
|
return {
|
||||||
|
dot: "bg-attention",
|
||||||
|
text: "waiting for you",
|
||||||
|
tone: "text-attention-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (info.status !== "open") {
|
||||||
|
return {
|
||||||
|
dot: "bg-muted-foreground/50",
|
||||||
|
text: info.status,
|
||||||
|
tone: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dot: "bg-muted-foreground/50",
|
||||||
|
text: `quiet · ${fmtRelative(info.last_activity_at)}`,
|
||||||
|
tone: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
});
|
||||||
const queued = $derived(
|
const queued = $derived(
|
||||||
info.queue.filter((item) => item.status === "queued").length
|
info.queue.filter((item) => item.status === "queued").length
|
||||||
);
|
);
|
||||||
|
const connection = $derived(
|
||||||
|
liveState === "open" ? null : (liveDetail ?? liveState)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="flex flex-col gap-1.5 border-b px-3 py-2.5 @md:px-6">
|
<header class="flex flex-col border-b bg-background/80 backdrop-blur-md">
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div class="flex items-center gap-2 px-3 py-2 @md:px-6">
|
||||||
{#if showTitle}
|
{#if showTitle}
|
||||||
<KindBadge kind={info.kind} />
|
<KindMark kind={info.kind} />
|
||||||
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
|
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
|
||||||
{info.title || `${info.kind} ${shortId(info.id)}`}
|
{info.title || `${info.kind} ${shortId(info.id)}`}
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
<StatusPill status={info.running_turn ? "running" : info.status} />
|
<span class={cn("flex shrink-0 items-center gap-1.5 text-xs", mood.tone)}>
|
||||||
{#if info.pending_question}
|
<span class={cn("size-1.5 rounded-full", mood.dot)}></span>
|
||||||
<span class="font-medium text-link text-xs">question pending</span>
|
<span class="hidden @sm:inline">{mood.text}</span>
|
||||||
|
</span>
|
||||||
|
{#if queued > 0}
|
||||||
|
<span
|
||||||
|
class="tabular text-note text-xs"
|
||||||
|
title="messages waiting for the next turn"
|
||||||
|
>
|
||||||
|
+{queued}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ml-auto flex items-center gap-1">
|
{#if connection}
|
||||||
<LiveDot
|
<span class="truncate text-warn text-xs" title={liveDetail ?? undefined}>
|
||||||
class="hidden @md:inline-flex"
|
{connection}
|
||||||
detail={liveDetail}
|
</span>
|
||||||
state={liveState}
|
{/if}
|
||||||
/>
|
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||||
<LiveDot
|
{#if info.context_tokens}
|
||||||
class="@md:hidden"
|
<button
|
||||||
detail={liveDetail}
|
class="rule-word tabular inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent"
|
||||||
label={false}
|
onclick={() => {
|
||||||
state={liveState}
|
view = "context";
|
||||||
/>
|
onContext?.();
|
||||||
<Button
|
}}
|
||||||
|
title="context of the last turn - what the model is holding"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{fmtTokens(info.context_tokens)}
|
||||||
|
<span class="text-muted-foreground/70">/ 1M</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
aria-label="Branch off this conversation"
|
aria-label="Branch off this conversation"
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
|
||||||
disabled={info.status !== "open"}
|
disabled={info.status !== "open"}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
branchOpen = true;
|
branchOpen = true;
|
||||||
}}
|
}}
|
||||||
size="sm"
|
title="Branch off"
|
||||||
variant="outline"
|
type="button"
|
||||||
>
|
>
|
||||||
<GitBranchIcon class="size-4" />
|
<GitBranchIcon class="size-4" />
|
||||||
<span class="hidden @md:inline">Branch</span>
|
</button>
|
||||||
</Button>
|
|
||||||
<ConversationMenu
|
<ConversationMenu
|
||||||
{client}
|
{client}
|
||||||
current
|
current
|
||||||
@@ -86,59 +143,99 @@
|
|||||||
{onChanged}
|
{onChanged}
|
||||||
{onOpen}
|
{onOpen}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
aria-expanded={detailsOpen}
|
||||||
|
aria-label="Details"
|
||||||
|
class={cn(
|
||||||
|
"rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent",
|
||||||
|
detailsOpen && "bg-accent text-foreground"
|
||||||
|
)}
|
||||||
|
onclick={() => {
|
||||||
|
detailsOpen = !detailsOpen;
|
||||||
|
}}
|
||||||
|
title="Details"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
class={cn("size-4 transition-transform", detailsOpen && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. -->
|
<div class="flex items-center gap-4 px-3 pb-1.5 @md:px-6">
|
||||||
<div
|
{#each VIEWS as item (item.value)}
|
||||||
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
|
<button
|
||||||
>
|
class="rule-word text-xs"
|
||||||
<span class="hidden @md:inline">{info.agent}</span>
|
data-active={view === item.value}
|
||||||
<span class="hidden @md:inline">via {info.origin}</span>
|
onclick={() => {
|
||||||
{#if info.parent}
|
view = item.value;
|
||||||
{#if href}
|
}}
|
||||||
<a class="text-link hover:underline" href={href(info.parent)}>
|
type="button"
|
||||||
parent {shortId(info.parent)}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="text-link hover:underline"
|
|
||||||
onclick={() => info.parent && onOpen(info.parent)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
parent {shortId(info.parent)}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<span class="tabular hidden @md:inline" title={info.id}>
|
|
||||||
{shortId(info.id)}
|
|
||||||
</span>
|
|
||||||
<span class="tabular hidden @md:inline">
|
|
||||||
{info.live ? "session live" : "no live session"}
|
|
||||||
{info.busy ? " · busy" : ""}
|
|
||||||
</span>
|
|
||||||
<span class="tabular">
|
|
||||||
last activity {fmtRelative(info.last_activity_at)}
|
|
||||||
</span>
|
|
||||||
{#if queued > 0}
|
|
||||||
<span
|
|
||||||
class="tabular font-medium text-note"
|
|
||||||
title="messages waiting for the next turn"
|
|
||||||
>
|
>
|
||||||
{queued}
|
{item.label}
|
||||||
queued
|
</button>
|
||||||
</span>
|
{/each}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{#if windows.length > 0}
|
{#if detailsOpen}
|
||||||
<div
|
<div
|
||||||
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
|
class="animate-fade-in grid gap-x-6 gap-y-3 border-t px-3 py-3 text-xs @md:grid-cols-2 @md:px-6"
|
||||||
>
|
>
|
||||||
{#each windows as window (window.frontend + window.external_id)}
|
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
|
||||||
<span class="truncate" title="{window.frontend}: {window.external_id}">
|
<dt class="text-muted-foreground">agent</dt>
|
||||||
<span class="text-foreground/70">{window.frontend}</span>
|
<dd>{info.agent}</dd>
|
||||||
{clip(window.external_id, WINDOW_MAX)}
|
<dt class="text-muted-foreground">opened via</dt>
|
||||||
</span>
|
<dd>{info.origin}</dd>
|
||||||
{/each}
|
{#if info.parent}
|
||||||
|
<dt class="text-muted-foreground">parent</dt>
|
||||||
|
<dd>
|
||||||
|
{#if href}
|
||||||
|
<a class="text-link hover:underline" href={href(info.parent)}>
|
||||||
|
{shortId(info.parent)}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="text-link hover:underline"
|
||||||
|
onclick={() => info.parent && onOpen(info.parent)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{shortId(info.parent)}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
<dt class="text-muted-foreground">id</dt>
|
||||||
|
<dd class="tabular break-all">{info.id}</dd>
|
||||||
|
<dt class="text-muted-foreground">session</dt>
|
||||||
|
<dd class="tabular break-all">
|
||||||
|
{info.session_id ?? "none yet"}
|
||||||
|
{info.live ? " · process alive" : ""}
|
||||||
|
</dd>
|
||||||
|
{#if Object.keys(info.flags).length > 0}
|
||||||
|
<dt class="text-muted-foreground">flags</dt>
|
||||||
|
<dd class="tabular">
|
||||||
|
{Object.entries(info.flags)
|
||||||
|
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
||||||
|
.join(" ")}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">windows</span>
|
||||||
|
<BindingsList
|
||||||
|
bindings={info.bindings}
|
||||||
|
{client}
|
||||||
|
conversationId={info.id}
|
||||||
|
{onChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if info.queue.length > 0}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">queue</span>
|
||||||
|
<QueueList items={info.queue} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
onChanged,
|
onChanged,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
children,
|
children,
|
||||||
|
trigger,
|
||||||
}: {
|
}: {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +36,9 @@
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
// Render the trigger yourself with these props spread on it - the strip
|
||||||
|
// stays a strip instead of sitting inside a wrapper div.
|
||||||
|
trigger?: Snippet<[Record<string, unknown>]>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const host = usePanelHost();
|
const host = usePanelHost();
|
||||||
@@ -93,9 +97,17 @@
|
|||||||
|
|
||||||
{#if mode === "context"}
|
{#if mode === "context"}
|
||||||
<ContextMenu.Root bind:open>
|
<ContextMenu.Root bind:open>
|
||||||
<ContextMenu.Trigger class={className}>
|
{#if trigger}
|
||||||
{@render children?.()}
|
<ContextMenu.Trigger>
|
||||||
</ContextMenu.Trigger>
|
{#snippet child({ props })}
|
||||||
|
{@render trigger(props)}
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
{:else}
|
||||||
|
<ContextMenu.Trigger class={className}>
|
||||||
|
{@render children?.()}
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
{/if}
|
||||||
<ContextMenu.Content class="min-w-52">
|
<ContextMenu.Content class="min-w-52">
|
||||||
{@render body(ContextMenu)}
|
{@render body(ContextMenu)}
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CheckIcon from "@lucide/svelte/icons/check";
|
|
||||||
import Link2Icon from "@lucide/svelte/icons/link-2";
|
|
||||||
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
|
|
||||||
import type { ApiClient } from "$lib/api/client";
|
|
||||||
import type { ConversationSummary, Kind } from "$lib/api/types";
|
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import { Input } from "$lib/components/ui/input";
|
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
|
||||||
import ConversationRow from "./conversation-row.svelte";
|
|
||||||
import type { ConversationIndex } from "./index.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
client,
|
|
||||||
index,
|
|
||||||
selected = null,
|
|
||||||
onOpen,
|
|
||||||
follow = $bindable(false),
|
|
||||||
showFollow = false,
|
|
||||||
autofocus = false,
|
|
||||||
}: {
|
|
||||||
client: ApiClient;
|
|
||||||
index: ConversationIndex;
|
|
||||||
selected?: string | null;
|
|
||||||
onOpen: (id: string) => void;
|
|
||||||
follow?: boolean;
|
|
||||||
showFollow?: boolean;
|
|
||||||
autofocus?: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const GROUPS: { kind: Kind; label: string }[] = [
|
|
||||||
{ kind: "master", label: "Master" },
|
|
||||||
{ kind: "branch", label: "Branches" },
|
|
||||||
{ kind: "deep", label: "Deep chats" },
|
|
||||||
{ kind: "job", label: "Jobs" },
|
|
||||||
{ kind: "fork", label: "Forks" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let withClosed = $state(false);
|
|
||||||
let list = $state<HTMLDivElement | null>(null);
|
|
||||||
let searchInput = $state<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
function activityOf(row: ConversationSummary): string {
|
|
||||||
return row.last_activity_at ?? row.created_at ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function byActivity(a: ConversationSummary, b: ConversationSummary) {
|
|
||||||
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
|
|
||||||
return a.running_turn ? -1 : 1;
|
|
||||||
}
|
|
||||||
return activityOf(b).localeCompare(activityOf(a));
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
const needle = search.trim().toLowerCase();
|
|
||||||
return index.conversations
|
|
||||||
.filter(
|
|
||||||
(row) => withClosed || row.status === "open" || row.id === selected
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
!needle ||
|
|
||||||
(row.title ?? "").toLowerCase().includes(needle) ||
|
|
||||||
row.id.startsWith(needle) ||
|
|
||||||
row.agent.toLowerCase().includes(needle) ||
|
|
||||||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
|
||||||
)
|
|
||||||
.sort(byActivity);
|
|
||||||
});
|
|
||||||
const groups = $derived(
|
|
||||||
GROUPS.map((group) => ({
|
|
||||||
...group,
|
|
||||||
rows: rows.filter((row) => row.kind === group.kind),
|
|
||||||
})).filter((group) => group.rows.length > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
index.load().catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ↑/↓ walk the rows, / jumps to the search box, Enter is the button's own.
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "/" && event.target !== searchInput) {
|
|
||||||
event.preventDefault();
|
|
||||||
searchInput?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const buttons = [
|
|
||||||
...(list?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []),
|
|
||||||
];
|
|
||||||
if (buttons.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
||||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
||||||
const next = at < 0 ? 0 : (at + step + buttons.length) % buttons.length;
|
|
||||||
buttons[next].focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (autofocus) {
|
|
||||||
searchInput?.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<div
|
|
||||||
aria-label="Conversations"
|
|
||||||
class="flex h-full min-h-0 flex-col"
|
|
||||||
onkeydown={onKeydown}
|
|
||||||
role="listbox"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
|
||||||
<Input
|
|
||||||
aria-label="Search conversations"
|
|
||||||
class="h-8 min-w-0 flex-1 text-xs"
|
|
||||||
placeholder="search"
|
|
||||||
bind:ref={searchInput}
|
|
||||||
bind:value={search}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
aria-pressed={withClosed}
|
|
||||||
class="text-xs"
|
|
||||||
onclick={() => {
|
|
||||||
withClosed = !withClosed;
|
|
||||||
}}
|
|
||||||
size="xs"
|
|
||||||
title={withClosed
|
|
||||||
? "Hide closed conversations"
|
|
||||||
: "Show closed conversations"}
|
|
||||||
variant={withClosed ? "secondary" : "ghost"}
|
|
||||||
>
|
|
||||||
{#if withClosed}
|
|
||||||
<CheckIcon class="size-3" />
|
|
||||||
{/if}
|
|
||||||
closed
|
|
||||||
</Button>
|
|
||||||
{#if showFollow}
|
|
||||||
<Button
|
|
||||||
aria-label="Follow the active note"
|
|
||||||
aria-pressed={follow}
|
|
||||||
onclick={() => {
|
|
||||||
follow = !follow;
|
|
||||||
}}
|
|
||||||
size="icon-sm"
|
|
||||||
title={follow
|
|
||||||
? "Following the active note - click to pin this conversation"
|
|
||||||
: "Pinned - click to follow the active note"}
|
|
||||||
variant={follow ? "secondary" : "ghost"}
|
|
||||||
>
|
|
||||||
{#if follow}
|
|
||||||
<Link2Icon class="size-4" />
|
|
||||||
{:else}
|
|
||||||
<Link2OffIcon class="size-4" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={list}>
|
|
||||||
{#if index.live.state === "failed"}
|
|
||||||
<div class="p-3">
|
|
||||||
<ErrorNote
|
|
||||||
message={index.live.detail ?? "event stream failed"}
|
|
||||||
retry={() => index.start()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if !index.loaded}
|
|
||||||
<div class="flex flex-col gap-2 p-3">
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
<Skeleton class="h-12 w-full" />
|
|
||||||
</div>
|
|
||||||
{:else if rows.length === 0}
|
|
||||||
<div class="p-3">
|
|
||||||
<EmptyState
|
|
||||||
hint={search
|
|
||||||
? "Nothing matches. Try fewer letters, or include closed ones."
|
|
||||||
: "Nothing is open. Say something to the master in Telegram, or send a note from Obsidian."}
|
|
||||||
title="No conversations"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each groups as group (group.kind)}
|
|
||||||
<section class="flex flex-col">
|
|
||||||
<h2
|
|
||||||
class="sticky top-0 z-10 bg-background/95 px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{group.label}
|
|
||||||
<span class="tabular ml-1 normal-case tracking-normal">
|
|
||||||
{group.rows.length}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<ul class="flex flex-col">
|
|
||||||
{#each group.rows as row (row.id)}
|
|
||||||
<li class="border-b last:border-b-0">
|
|
||||||
<ConversationRow
|
|
||||||
{client}
|
|
||||||
onChanged={refresh}
|
|
||||||
{onOpen}
|
|
||||||
{row}
|
|
||||||
selected={selected === row.id}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ApiClient } from "$lib/api/client";
|
|
||||||
import type { ConversationSummary } from "$lib/api/types";
|
|
||||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
|
||||||
import StatusPill from "$lib/components/status-pill.svelte";
|
|
||||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import ConversationMenu from "./conversation-menu.svelte";
|
|
||||||
import { usePanelHost } from "./host";
|
|
||||||
|
|
||||||
let {
|
|
||||||
client,
|
|
||||||
row,
|
|
||||||
selected = false,
|
|
||||||
onOpen,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
client: ApiClient;
|
|
||||||
row: ConversationSummary;
|
|
||||||
selected?: boolean;
|
|
||||||
onOpen: (id: string) => void;
|
|
||||||
onChanged: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const host = usePanelHost();
|
|
||||||
const TITLE_MAX = 60;
|
|
||||||
const PREVIEW_MAX = 90;
|
|
||||||
|
|
||||||
const title = $derived(
|
|
||||||
row.title
|
|
||||||
? clip(row.title, TITLE_MAX)
|
|
||||||
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
|
|
||||||
);
|
|
||||||
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ConversationMenu
|
|
||||||
class={cn("group/row block", selected && "bg-sidebar-accent")}
|
|
||||||
{client}
|
|
||||||
current={selected}
|
|
||||||
id={row.id}
|
|
||||||
mode="context"
|
|
||||||
{onChanged}
|
|
||||||
{onOpen}
|
|
||||||
>
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
aria-current={selected ? "true" : undefined}
|
|
||||||
class="row-hover flex w-full flex-col items-stretch justify-start gap-1 px-3 py-2 text-left text-sm"
|
|
||||||
data-row={row.id}
|
|
||||||
onclick={() => onOpen(row.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span class="flex w-full items-center gap-2">
|
|
||||||
<KindBadge kind={row.kind} />
|
|
||||||
<span class="min-w-0 flex-1 truncate font-medium">{title}</span>
|
|
||||||
<span class="tabular shrink-0 text-muted-foreground text-xs">
|
|
||||||
{fmtRelative(when)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex w-full items-center gap-2 pr-7 text-xs">
|
|
||||||
<StatusPill status={row.running_turn ? "running" : row.status} />
|
|
||||||
<span class="truncate text-muted-foreground">{row.agent}</span>
|
|
||||||
{#if row.pending_question}
|
|
||||||
<span class="shrink-0 font-medium text-link">question</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if row.last_item}
|
|
||||||
<span
|
|
||||||
class="block w-full min-w-0 truncate text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
<span class="text-foreground/70">{row.last_item.origin}:</span>
|
|
||||||
{clip(row.last_item.text, PREVIEW_MAX)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<ConversationMenu
|
|
||||||
class={cn(
|
|
||||||
"absolute right-1.5 bottom-1 size-7 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/row:opacity-100 aria-expanded:opacity-100",
|
|
||||||
host.touch && "opacity-100",
|
|
||||||
row.last_item && "bottom-6"
|
|
||||||
)}
|
|
||||||
{client}
|
|
||||||
current={selected}
|
|
||||||
id={row.id}
|
|
||||||
mode="button"
|
|
||||||
{onChanged}
|
|
||||||
{onOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ConversationMenu>
|
|
||||||
@@ -2,15 +2,13 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
|
||||||
import * as Tabs from "$lib/components/ui/tabs";
|
|
||||||
import ActivityFeed from "./activity-feed.svelte";
|
import ActivityFeed from "./activity-feed.svelte";
|
||||||
import BindingsList from "./bindings-list.svelte";
|
|
||||||
import ChatView from "./chat-view.svelte";
|
import ChatView from "./chat-view.svelte";
|
||||||
import Composer from "./composer.svelte";
|
import Composer from "./composer.svelte";
|
||||||
|
import ContextView from "./context-view.svelte";
|
||||||
import { ConversationFeed } from "./conversation.svelte";
|
import { ConversationFeed } from "./conversation.svelte";
|
||||||
import ConversationHeader from "./conversation-header.svelte";
|
import ConversationHeader from "./conversation-header.svelte";
|
||||||
import QueueList from "./queue-list.svelte";
|
import { usePanelHost } from "./host";
|
||||||
import RawEntries from "./raw-entries.svelte";
|
import RawEntries from "./raw-entries.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -19,6 +17,8 @@
|
|||||||
href = null,
|
href = null,
|
||||||
onOpen,
|
onOpen,
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
|
initialView = "chat",
|
||||||
|
onViewChange,
|
||||||
}: {
|
}: {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,13 +26,19 @@
|
|||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
// Off when a switcher above the thread already names it.
|
// Off when a switcher above the thread already names it.
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
// "activity" beside a deep chat's own note: the file is the thread.
|
||||||
|
initialView?: string;
|
||||||
|
// The page keeps the open tab in the URL so it survives a reload.
|
||||||
|
onViewChange?: (view: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const host = usePanelHost();
|
||||||
const feed = new ConversationFeed(
|
const feed = new ConversationFeed(
|
||||||
() => client,
|
() => client,
|
||||||
untrack(() => id)
|
untrack(() => id),
|
||||||
|
host.cache ?? null
|
||||||
);
|
);
|
||||||
let tab = $state("chat");
|
let view = $state(untrack(() => initialView));
|
||||||
let historyKey = $state(0);
|
let historyKey = $state(0);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -47,6 +53,10 @@
|
|||||||
historyKey += 1;
|
historyKey += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
onViewChange?.(view);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { running } = feed.model;
|
const { running } = feed.model;
|
||||||
if (!running) {
|
if (!running) {
|
||||||
@@ -61,9 +71,10 @@
|
|||||||
{#if feed.error && !info}
|
{#if feed.error && !info}
|
||||||
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
|
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
|
||||||
{:else if !info}
|
{:else if !info}
|
||||||
<div class="flex flex-col gap-3 p-4">
|
<div class="flex flex-1 items-center justify-center p-4">
|
||||||
<Skeleton class="h-8 w-1/2" />
|
<span
|
||||||
<Skeleton class="h-24 w-full" />
|
class="size-2 animate-pulse-dot rounded-full bg-muted-foreground/40"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ConversationHeader
|
<ConversationHeader
|
||||||
@@ -75,49 +86,36 @@
|
|||||||
onChanged={refresh}
|
onChanged={refresh}
|
||||||
{onOpen}
|
{onOpen}
|
||||||
{showTitle}
|
{showTitle}
|
||||||
|
bind:view
|
||||||
/>
|
/>
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<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}>
|
{#if view === "chat"}
|
||||||
<div class="border-b px-3 py-2 @md:px-6">
|
<ChatView
|
||||||
<Tabs.List class="w-fit max-w-full">
|
{client}
|
||||||
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
|
conversationId={id}
|
||||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
model={feed.model}
|
||||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
refreshKey={historyKey}
|
||||||
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
|
/>
|
||||||
</Tabs.List>
|
{:else if view === "activity"}
|
||||||
</div>
|
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
|
||||||
<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-3 py-3 @md:px-6"
|
|
||||||
value="activity"
|
|
||||||
>
|
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
{client}
|
{client}
|
||||||
connected={feed.live.state === "open"}
|
connected={feed.live.state === "open"}
|
||||||
conversationId={id}
|
conversationId={id}
|
||||||
model={feed.model}
|
model={feed.model}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</div>
|
||||||
<Tabs.Content
|
{:else if view === "context"}
|
||||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-4 @md:px-6">
|
||||||
value="raw"
|
{#key historyKey}
|
||||||
>
|
<ContextView {client} conversationId={id} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
|
||||||
<RawEntries {client} conversationId={id} />
|
<RawEntries {client} conversationId={id} />
|
||||||
</Tabs.Content>
|
</div>
|
||||||
<Tabs.Content
|
{/if}
|
||||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
|
||||||
value="meta"
|
|
||||||
>
|
|
||||||
{@render meta()}
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs.Root>
|
|
||||||
<Composer
|
<Composer
|
||||||
{client}
|
{client}
|
||||||
conversationId={id}
|
conversationId={id}
|
||||||
@@ -126,60 +124,3 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#snippet meta()}
|
|
||||||
{#if info}
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Queue
|
|
||||||
</h2>
|
|
||||||
<QueueList items={info.queue} />
|
|
||||||
</section>
|
|
||||||
<section class="flex flex-col gap-1.5">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Windows
|
|
||||||
</h2>
|
|
||||||
<BindingsList
|
|
||||||
bindings={info.bindings}
|
|
||||||
{client}
|
|
||||||
conversationId={id}
|
|
||||||
onChanged={refresh}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section class="flex flex-col gap-1.5">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Flags
|
|
||||||
</h2>
|
|
||||||
{#if Object.keys(info.flags).length === 0}
|
|
||||||
<p class="text-muted-foreground text-xs">No flags set.</p>
|
|
||||||
{:else}
|
|
||||||
<dl
|
|
||||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
|
|
||||||
>
|
|
||||||
{#each Object.entries(info.flags) as [key, value] (key)}
|
|
||||||
<dt class="text-muted-foreground">{key}</dt>
|
|
||||||
<dd class="truncate">{JSON.stringify(value)}</dd>
|
|
||||||
{/each}
|
|
||||||
</dl>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
<section class="flex flex-col gap-1.5">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Session
|
|
||||||
</h2>
|
|
||||||
<p class="tabular break-all text-muted-foreground text-xs">
|
|
||||||
{info.session_id ?? "no session yet"}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import { LiveStream } from "$lib/api/live.svelte";
|
import { LiveStream } from "$lib/api/live.svelte";
|
||||||
|
import type { ConversationInfo } from "$lib/api/types";
|
||||||
import { ActivityModel } from "./activity.svelte";
|
import { ActivityModel } from "./activity.svelte";
|
||||||
|
import type { PanelCache } from "./cache";
|
||||||
|
import { infoKey } from "./history-cache";
|
||||||
|
|
||||||
// One conversation, live: the snapshot from ``GET /api/conversations/{id}``
|
// One conversation, live: the snapshot from ``GET /api/conversations/{id}``
|
||||||
// (bindings, queue, the turn in flight) plus its SSE stream folded into an
|
// (bindings, queue, the turn in flight) plus its SSE stream folded into an
|
||||||
@@ -12,10 +15,20 @@ export class ConversationFeed {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly live: LiveStream;
|
readonly live: LiveStream;
|
||||||
private readonly client: () => ApiClient | null;
|
private readonly client: () => ApiClient | null;
|
||||||
|
private readonly cache: PanelCache | null;
|
||||||
|
|
||||||
constructor(client: () => ApiClient | null, id: string) {
|
constructor(
|
||||||
|
client: () => ApiClient | null,
|
||||||
|
id: string,
|
||||||
|
cache: PanelCache | null = null
|
||||||
|
) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
this.cache = cache;
|
||||||
|
const known = cache?.get<ConversationInfo>(infoKey(id));
|
||||||
|
if (known) {
|
||||||
|
this.model.setConversation(known);
|
||||||
|
}
|
||||||
this.live = new LiveStream(client, `/api/conversations/${id}/events`, {
|
this.live = new LiveStream(client, `/api/conversations/${id}/events`, {
|
||||||
onEvent: (event) => this.model.apply(event),
|
onEvent: (event) => this.model.apply(event),
|
||||||
prepare: () => this.refresh(),
|
prepare: () => this.refresh(),
|
||||||
@@ -28,7 +41,9 @@ export class ConversationFeed {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.model.setConversation(await client.conversation(this.id));
|
const info = await client.conversation(this.id);
|
||||||
|
this.model.setConversation(info);
|
||||||
|
this.cache?.set(infoKey(this.id), info);
|
||||||
this.error = null;
|
this.error = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error instanceof Error ? error.message : String(error);
|
this.error = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||||
|
|
||||||
|
const MAX_BYTES = 250_000;
|
||||||
|
const TAIL = 200;
|
||||||
|
|
||||||
|
function slim(message: HistoryMessage): HistoryMessage {
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const content: ContentBlock[] = message.content.map((block) =>
|
||||||
|
block.type === "tool_result" ? { ...block, content: "" } : block
|
||||||
|
);
|
||||||
|
return { ...message, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tail of a thread as it is worth keeping between opens: tool results
|
||||||
|
// dropped (hidden by default, and the bulk of the bytes), then trimmed from
|
||||||
|
// the head until it fits a slice of localStorage.
|
||||||
|
export function cacheableHistory(messages: HistoryMessage[]): HistoryMessage[] {
|
||||||
|
let tail = messages.slice(-TAIL).map(slim);
|
||||||
|
while (tail.length > 1 && JSON.stringify(tail).length > MAX_BYTES) {
|
||||||
|
tail = tail.slice(Math.ceil(tail.length / 4));
|
||||||
|
}
|
||||||
|
return tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const historyKey = (id: string) => `history:${id}`;
|
||||||
|
export const infoKey = (id: string) => `info:${id}`;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getContext, setContext } from "svelte";
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { type PanelCache, panelCache } from "./cache";
|
||||||
|
|
||||||
export type LinkKind = "internal" | "external";
|
export type LinkKind = "internal" | "external";
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ export type LinkKind = "internal" | "external";
|
|||||||
// Obsidian plugin renders through MarkdownRenderer and opens notes in
|
// Obsidian plugin renders through MarkdownRenderer and opens notes in
|
||||||
// place. Anything the host leaves undefined falls back to the browser way.
|
// place. Anything the host leaves undefined falls back to the browser way.
|
||||||
export interface PanelHost {
|
export interface PanelHost {
|
||||||
|
// Where the index and the last threads persist between opens.
|
||||||
|
cache?: PanelCache;
|
||||||
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
|
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
|
||||||
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
|
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
|
||||||
name: "browser" | "obsidian";
|
name: "browser" | "obsidian";
|
||||||
@@ -35,6 +38,7 @@ export function browserHost(): PanelHost {
|
|||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
browser = {
|
browser = {
|
||||||
|
cache: panelCache("beaver.panel"),
|
||||||
name: "browser",
|
name: "browser",
|
||||||
openLink(target, kind) {
|
openLink(target, kind) {
|
||||||
if (kind === "internal") {
|
if (kind === "internal") {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import { LiveStream } from "$lib/api/live.svelte";
|
import { LiveStream } from "$lib/api/live.svelte";
|
||||||
import type { BusEvent, ConversationSummary } from "$lib/api/types";
|
import type { BusEvent, ConversationSummary } from "$lib/api/types";
|
||||||
|
import type { PanelCache } from "./cache";
|
||||||
|
import { cacheableHistory, historyKey, infoKey } from "./history-cache";
|
||||||
|
|
||||||
const RELOAD_DEBOUNCE_MS = 1500;
|
const RELOAD_DEBOUNCE_MS = 1500;
|
||||||
const PAGE = 500;
|
const PAGE = 500;
|
||||||
|
const CACHED_ROWS = 200;
|
||||||
|
const INDEX_KEY = "index";
|
||||||
|
const WARM_TTL_MS = 60_000;
|
||||||
|
const WARM_GAP_MS = 120;
|
||||||
|
const WARM_AHEAD = 8;
|
||||||
|
const QUIET_KINDS = new Set(["job", "fork"]);
|
||||||
|
|
||||||
// The conversation index kept fresh by ``/api/events``: rows land as
|
// The conversation index kept fresh by ``/api/events``: rows land as
|
||||||
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
|
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
|
||||||
@@ -14,10 +22,19 @@ export class ConversationIndex {
|
|||||||
loaded = $state(false);
|
loaded = $state(false);
|
||||||
readonly live: LiveStream;
|
readonly live: LiveStream;
|
||||||
protected readonly client: () => ApiClient | null;
|
protected readonly client: () => ApiClient | null;
|
||||||
|
private readonly cache: PanelCache | null;
|
||||||
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
|
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private readonly warmed = new Map<string, number>();
|
||||||
|
private warming: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(client: () => ApiClient | null) {
|
constructor(client: () => ApiClient | null, cache: PanelCache | null = null) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
this.cache = cache;
|
||||||
|
const cached = cache?.get<ConversationSummary[]>(INDEX_KEY);
|
||||||
|
if (cached && cached.length > 0) {
|
||||||
|
this.conversations = cached;
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
this.live = new LiveStream(client, "/api/events", {
|
this.live = new LiveStream(client, "/api/events", {
|
||||||
onEvent: (event) => this.apply(event),
|
onEvent: (event) => this.apply(event),
|
||||||
prepare: () => this.load(),
|
prepare: () => this.load(),
|
||||||
@@ -44,6 +61,64 @@ export class ConversationIndex {
|
|||||||
const list = await client.conversations({ limit: PAGE });
|
const list = await client.conversations({ limit: PAGE });
|
||||||
this.conversations = list.conversations;
|
this.conversations = list.conversations;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS));
|
||||||
|
this.prefetch(this.likely());
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the operator opens next: anything alive, then the freshest strips.
|
||||||
|
private likely(): string[] {
|
||||||
|
const rows = this.conversations.filter(
|
||||||
|
(row) => !QUIET_KINDS.has(row.kind) && row.status === "open"
|
||||||
|
);
|
||||||
|
const alive = rows.filter(
|
||||||
|
(row) => row.running_turn || row.pending_question
|
||||||
|
);
|
||||||
|
const rest = rows
|
||||||
|
.filter((row) => !(row.running_turn || row.pending_question))
|
||||||
|
.sort((a, b) =>
|
||||||
|
(b.last_activity_at ?? b.created_at ?? "").localeCompare(
|
||||||
|
a.last_activity_at ?? a.created_at ?? ""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return [...alive, ...rest].slice(0, WARM_AHEAD).map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the cache for these threads, one at a time, in the background.
|
||||||
|
prefetch(ids: string[]): void {
|
||||||
|
if (!this.cache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const fresh = ids.filter(
|
||||||
|
(id) => now - (this.warmed.get(id) ?? 0) > WARM_TTL_MS
|
||||||
|
);
|
||||||
|
if (fresh.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const id of fresh) {
|
||||||
|
this.warmed.set(id, now);
|
||||||
|
}
|
||||||
|
const run = async () => {
|
||||||
|
for (const id of fresh) {
|
||||||
|
const client = this.client();
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// biome-ignore lint/performance/noAwaitInLoops: one thread at a time, on purpose - background warmth, not a race
|
||||||
|
const [history, info] = await Promise.all([
|
||||||
|
client.history(id),
|
||||||
|
client.conversation(id),
|
||||||
|
]);
|
||||||
|
this.cache?.set(historyKey(id), cacheableHistory(history.messages));
|
||||||
|
this.cache?.set(infoKey(id), info);
|
||||||
|
} catch {
|
||||||
|
this.warmed.delete(id);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, WARM_GAP_MS));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.warming = (this.warming ?? Promise.resolve()).then(run, run);
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(event: BusEvent): void {
|
apply(event: BusEvent): void {
|
||||||
@@ -85,6 +160,11 @@ export class ConversationIndex {
|
|||||||
this.live.stop();
|
this.live.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dot was tapped: drop the socket, reload the rows, reconnect. */
|
||||||
|
reconnect(): void {
|
||||||
|
this.live.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
private upsert(row: ConversationSummary): void {
|
private upsert(row: ConversationSummary): void {
|
||||||
const index = this.conversations.findIndex((c) => c.id === row.id);
|
const index = this.conversations.findIndex((c) => c.id === row.id);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
import { usePanelHost } from "./host";
|
import { usePanelHost } from "./host";
|
||||||
import { linkOf, renderMarkdown } from "./markdown";
|
import { linkOf, renderMarkdown } from "./markdown";
|
||||||
|
|
||||||
let { text, class: className = "" }: { text: string; class?: string } =
|
let {
|
||||||
$props();
|
text,
|
||||||
|
class: className = "",
|
||||||
|
eager = true,
|
||||||
|
}: { text: string; class?: string; eager?: boolean } = $props();
|
||||||
|
|
||||||
|
const NEAR = "1200px";
|
||||||
|
|
||||||
const host = usePanelHost();
|
const host = usePanelHost();
|
||||||
const html = $derived(host.markdown ? "" : renderMarkdown(text));
|
const html = $derived(host.markdown ? "" : renderMarkdown(text));
|
||||||
@@ -36,15 +41,52 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The host renders when asked; far up the thread that is when the
|
||||||
|
// node comes near the viewport, with the plain text holding its place.
|
||||||
function render(node: HTMLElement, value: string) {
|
function render(node: HTMLElement, value: string) {
|
||||||
let dispose = host.markdown?.(node, value);
|
let dispose: (() => void) | undefined;
|
||||||
|
let watcher: IntersectionObserver | null = null;
|
||||||
|
let pending = value;
|
||||||
|
const now = (next: string) => {
|
||||||
|
dispose?.();
|
||||||
|
node.classList.remove("whitespace-pre-wrap");
|
||||||
|
dispose = host.markdown?.(node, next);
|
||||||
|
};
|
||||||
|
const later = (next: string) => {
|
||||||
|
pending = next;
|
||||||
|
node.textContent = next;
|
||||||
|
node.classList.add("whitespace-pre-wrap");
|
||||||
|
if (watcher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
watcher = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
watcher?.disconnect();
|
||||||
|
watcher = null;
|
||||||
|
now(pending);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: NEAR }
|
||||||
|
);
|
||||||
|
watcher.observe(node);
|
||||||
|
};
|
||||||
|
if (eager || typeof IntersectionObserver === "undefined") {
|
||||||
|
now(value);
|
||||||
|
} else {
|
||||||
|
later(value);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
|
watcher?.disconnect();
|
||||||
dispose?.();
|
dispose?.();
|
||||||
},
|
},
|
||||||
update(next: string) {
|
update(next: string) {
|
||||||
dispose?.();
|
if (watcher) {
|
||||||
dispose = host.markdown?.(node, next);
|
later(next);
|
||||||
|
} else {
|
||||||
|
now(next);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
import Link2Icon from "@lucide/svelte/icons/link-2";
|
import Link2Icon from "@lucide/svelte/icons/link-2";
|
||||||
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
|
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
|
||||||
import LiveDot from "$lib/components/live-dot.svelte";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import * as Popover from "$lib/components/ui/popover";
|
import * as Popover from "$lib/components/ui/popover";
|
||||||
import { clip, shortId } from "$lib/format";
|
import { clip, shortId } from "$lib/format";
|
||||||
import ConversationPicker from "./conversation-picker.svelte";
|
import Rail from "$lib/rail/rail.svelte";
|
||||||
|
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
import type { ConversationIndex } from "./index.svelte";
|
import type { ConversationIndex } from "./index.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -16,6 +15,7 @@
|
|||||||
index,
|
index,
|
||||||
selected = null,
|
selected = null,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onOpenFile,
|
||||||
follow = $bindable(false),
|
follow = $bindable(false),
|
||||||
showFollow = false,
|
showFollow = false,
|
||||||
}: {
|
}: {
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
index: ConversationIndex;
|
index: ConversationIndex;
|
||||||
selected?: string | null;
|
selected?: string | null;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
follow?: boolean;
|
follow?: boolean;
|
||||||
showFollow?: boolean;
|
showFollow?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -30,10 +31,9 @@
|
|||||||
const TITLE_MAX = 48;
|
const TITLE_MAX = 48;
|
||||||
let switcherOpen = $state(false);
|
let switcherOpen = $state(false);
|
||||||
const current = $derived(selected ? index.byId(selected) : undefined);
|
const current = $derived(selected ? index.byId(selected) : undefined);
|
||||||
const running = $derived(index.running.length);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 border-b px-2 py-1.5">
|
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||||
<Popover.Root bind:open={switcherOpen}>
|
<Popover.Root bind:open={switcherOpen}>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<KindBadge kind={current.kind} />
|
<KindMark kind={current.kind} />
|
||||||
<span class="min-w-0 flex-1 truncate font-medium">
|
<span class="min-w-0 flex-1 truncate font-medium">
|
||||||
{current.title
|
{current.title
|
||||||
? clip(current.title, TITLE_MAX)
|
? clip(current.title, TITLE_MAX)
|
||||||
@@ -59,14 +59,6 @@
|
|||||||
Pick a conversation
|
Pick a conversation
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if running > 0 && !current?.running_turn}
|
|
||||||
<span
|
|
||||||
class="tabular shrink-0 rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
|
||||||
title="{running} running elsewhere"
|
|
||||||
>
|
|
||||||
{running}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
|
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -76,44 +68,44 @@
|
|||||||
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
|
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
>
|
>
|
||||||
<div class="flex max-h-[min(70vh,32rem)] flex-col">
|
<div class="flex h-[min(70vh,32rem)] flex-col">
|
||||||
<ConversationPicker
|
<Rail
|
||||||
autofocus
|
|
||||||
{client}
|
{client}
|
||||||
{index}
|
{index}
|
||||||
onOpen={(id) => {
|
onOpen={(id) => {
|
||||||
switcherOpen = false;
|
switcherOpen = false;
|
||||||
onOpen(id);
|
onOpen(id);
|
||||||
}}
|
}}
|
||||||
|
onOpenFile={(path) => {
|
||||||
|
switcherOpen = false;
|
||||||
|
onOpenFile?.(path);
|
||||||
|
}}
|
||||||
{selected}
|
{selected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
<LiveDot
|
|
||||||
class="px-1"
|
|
||||||
detail={index.live.detail}
|
|
||||||
label={false}
|
|
||||||
state={index.live.state}
|
|
||||||
/>
|
|
||||||
{#if showFollow}
|
{#if showFollow}
|
||||||
<Button
|
<button
|
||||||
aria-label="Follow the active note"
|
aria-label="Follow the active note"
|
||||||
aria-pressed={follow}
|
aria-pressed={follow}
|
||||||
|
class={cn(
|
||||||
|
"rule-word inline-flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||||
|
follow && "bg-accent text-foreground"
|
||||||
|
)}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
follow = !follow;
|
follow = !follow;
|
||||||
}}
|
}}
|
||||||
size="icon-sm"
|
|
||||||
title={follow
|
title={follow
|
||||||
? "Following the active note - click to pin this conversation"
|
? "Following the active note - click to pin this conversation"
|
||||||
: "Pinned - click to follow the active note"}
|
: "Pinned - click to follow the active note"}
|
||||||
variant={follow ? "secondary" : "ghost"}
|
type="button"
|
||||||
>
|
>
|
||||||
{#if follow}
|
{#if follow}
|
||||||
<Link2Icon class="size-4" />
|
<Link2Icon class="size-4" />
|
||||||
{:else}
|
{:else}
|
||||||
<Link2OffIcon class="size-4" />
|
<Link2OffIcon class="size-4" />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import type { ApiClient } from "$lib/api/client";
|
import type { ApiClient } from "$lib/api/client";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import Rail from "$lib/rail/rail.svelte";
|
||||||
import ConversationPicker from "./conversation-picker.svelte";
|
|
||||||
import ConversationView from "./conversation-view.svelte";
|
import ConversationView from "./conversation-view.svelte";
|
||||||
import type { ConversationIndex } from "./index.svelte";
|
import type { ConversationIndex } from "./index.svelte";
|
||||||
import PanelBar from "./panel-bar.svelte";
|
import PanelBar from "./panel-bar.svelte";
|
||||||
@@ -13,12 +12,21 @@
|
|||||||
selected = $bindable(null),
|
selected = $bindable(null),
|
||||||
follow = $bindable(false),
|
follow = $bindable(false),
|
||||||
showFollow = false,
|
showFollow = false,
|
||||||
|
companion = false,
|
||||||
|
onOpenFile,
|
||||||
|
fallbackToMaster = false,
|
||||||
}: {
|
}: {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
index: ConversationIndex;
|
index: ConversationIndex;
|
||||||
selected?: string | null;
|
selected?: string | null;
|
||||||
follow?: boolean;
|
follow?: boolean;
|
||||||
showFollow?: boolean;
|
showFollow?: boolean;
|
||||||
|
// The thread lives in the note beside this panel: open on activity.
|
||||||
|
companion?: boolean;
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
// Nothing picked yet: land on the freshest master rather than on a prompt.
|
||||||
|
// What the phone wants - the panel opens where the day is happening.
|
||||||
|
fallbackToMaster?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// 48rem of panel: below it the switcher names the thread, above it the rail does.
|
// 48rem of panel: below it the switcher names the thread, above it the rail does.
|
||||||
@@ -31,25 +39,51 @@
|
|||||||
return () => index.stop();
|
return () => index.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The latest master that is still open, by its own activity.
|
||||||
|
const latestMaster = $derived.by(() => {
|
||||||
|
const masters = index.conversations.filter(
|
||||||
|
(row) => row.kind === "master" && row.status === "open"
|
||||||
|
);
|
||||||
|
let best: (typeof masters)[number] | undefined;
|
||||||
|
for (const row of masters) {
|
||||||
|
const when = row.last_activity_at ?? row.created_at ?? "";
|
||||||
|
const top = best ? (best.last_activity_at ?? best.created_at ?? "") : "";
|
||||||
|
if (!best || when > top) {
|
||||||
|
best = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!fallbackToMaster || selected || !index.loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const master = latestMaster;
|
||||||
|
if (master) {
|
||||||
|
untrack(() => {
|
||||||
|
selected = master.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function open(id: string) {
|
function open(id: string) {
|
||||||
selected = id;
|
selected = id;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
|
impeccable direction contract (seed 071a8c77, mode operate, strip board × live activity, in Obsidian's skin)
|
||||||
THESIS: the agent's terminal, not a chat app. The thread is always on screen; picking a
|
THESIS: the agent's front inside the vault, not a website in a pane. The rail is the day's
|
||||||
conversation is a header switch, never a screen of its own. Refuses the messenger's list-first
|
strips, the thread owns its column; what is alive shows on the strips themselves.
|
||||||
layout and the admin's chrome.
|
OWN-WORLD: Obsidian's own variables through .beaver-root; strips with a state edge, hairline
|
||||||
OWN-WORLD: Obsidian's own variables through .beaver-root - its font, radii, accent, and status
|
rules, tabular numerals; amber only for a question waiting; nothing decorative.
|
||||||
colors; hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
|
STORY: the operator opens the tab, picks today's strip, answers a question,
|
||||||
STORY: the operator opens a note, the panel follows to its conversation, they watch tools and
|
watches tools stream; beside a deep chat the sidedock shows the agent's activity, never a
|
||||||
subagents stream, answer a question, branch off with a chosen seed, send the branch back to
|
second copy of the note.
|
||||||
Telegram, and close a deep chat with memory on or off - all from the sidedock.
|
FIRST VIEWPORT: narrow: the switcher over the thread and the composer pinned low. Wide:
|
||||||
FIRST VIEWPORT: narrow column: the switcher (kind badge, title, live dot, follow toggle) over
|
the rail on the left, the thread to the right.
|
||||||
the thread header, tabs, the live thread and the composer pinned at the bottom. Wide tab:
|
FORM: strip board × live activity; user-steered; seed key 071a8c77.
|
||||||
the same with the grouped conversation rail on the left. Primary action: write to the agent.
|
|
||||||
FORM: two-pane operator console; pinned by the brief, no roll.
|
|
||||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
||||||
the verdict, and DESIGN.md
|
the verdict, and DESIGN.md
|
||||||
-->
|
-->
|
||||||
@@ -59,15 +93,8 @@ the verdict, and DESIGN.md
|
|||||||
>
|
>
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
{#if wide}
|
{#if wide}
|
||||||
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40">
|
<aside class="flex w-80 shrink-0 flex-col border-r">
|
||||||
<ConversationPicker
|
<Rail {client} {index} onOpen={open} {onOpenFile} {selected} />
|
||||||
{client}
|
|
||||||
{index}
|
|
||||||
onOpen={open}
|
|
||||||
{selected}
|
|
||||||
{showFollow}
|
|
||||||
bind:follow
|
|
||||||
/>
|
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
<section class="flex min-w-0 flex-1 flex-col">
|
<section class="flex min-w-0 flex-1 flex-col">
|
||||||
@@ -76,6 +103,7 @@ the verdict, and DESIGN.md
|
|||||||
{client}
|
{client}
|
||||||
{index}
|
{index}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
|
{onOpenFile}
|
||||||
{selected}
|
{selected}
|
||||||
{showFollow}
|
{showFollow}
|
||||||
bind:follow
|
bind:follow
|
||||||
@@ -86,19 +114,18 @@ the verdict, and DESIGN.md
|
|||||||
<ConversationView
|
<ConversationView
|
||||||
{client}
|
{client}
|
||||||
id={selected}
|
id={selected}
|
||||||
|
initialView={companion ? "activity" : "chat"}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
showTitle={wide}
|
showTitle={wide}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 items-start justify-center p-4 @md:p-6">
|
<div class="flex flex-1 items-start justify-center p-4 @md:p-6">
|
||||||
<EmptyState
|
<p class="max-w-md text-muted-foreground text-sm">
|
||||||
class="w-full max-w-md"
|
{showFollow
|
||||||
hint={showFollow
|
? "Open a note with a conversation in its frontmatter, or pick a strip."
|
||||||
? "Open a note with conversation_id in its frontmatter, or pick one from the list."
|
: "Pick a strip: the master, a branch, a deep chat."}
|
||||||
: "Pick one from the list: the master, a branch, a deep chat."}
|
</p>
|
||||||
title="No conversation on screen"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -68,3 +68,197 @@
|
|||||||
column-gap: 0.75rem;
|
column-gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Strips: the one row vocabulary of the console. A strip is a physical
|
||||||
|
object in a bay: fixed columns, a state edge, lifts on hover, cocked
|
||||||
|
(pushed out) while it waits for the operator. */
|
||||||
|
@layer components {
|
||||||
|
.strip {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5rem minmax(0, 1fr) auto;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.375rem 0.875rem 0.375rem 0.625rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--strip);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition:
|
||||||
|
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
background-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
.strip::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.375rem;
|
||||||
|
bottom: 0.375rem;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
content: "";
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
transition: background-color 200ms ease-out;
|
||||||
|
}
|
||||||
|
.strip:first-child {
|
||||||
|
border-top-left-radius: var(--radius-md);
|
||||||
|
border-top-right-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.strip:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
border-bottom-left-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.strip:hover,
|
||||||
|
.strip:focus-visible {
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.strip[data-state="running"]::before {
|
||||||
|
background: var(--signal);
|
||||||
|
animation: pulse-edge 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
.strip[data-state="waiting"] {
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
transform: translateX(0.5rem);
|
||||||
|
}
|
||||||
|
.strip[data-state="waiting"]::before {
|
||||||
|
background: var(--attention);
|
||||||
|
}
|
||||||
|
.strip[data-state="waiting"]:hover {
|
||||||
|
transform: translateX(0.5rem) translateY(-1px);
|
||||||
|
}
|
||||||
|
.strip[aria-current="true"] {
|
||||||
|
background: color-mix(in oklab, var(--strip) 85%, var(--primary) 15%);
|
||||||
|
}
|
||||||
|
.strip[data-state="closed"] {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.strip-open {
|
||||||
|
display: block;
|
||||||
|
padding: 0.25rem 0.875rem 0.875rem 3rem;
|
||||||
|
background: var(--strip);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.strip-open:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
border-bottom-left-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.bay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.bay-rack {
|
||||||
|
background: color-mix(in oklab, var(--rack) 70%, var(--strip) 30%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius-md) + 1px);
|
||||||
|
box-shadow: inset 0 1px 0
|
||||||
|
color-mix(in oklab, var(--foreground) 4%, transparent);
|
||||||
|
}
|
||||||
|
.island {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
height: 2.125rem;
|
||||||
|
padding: 0 0.875rem 0 0.75rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--strip);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 1px 2px oklch(0.2 0.06 340 / 0.06);
|
||||||
|
transition:
|
||||||
|
transform 220ms cubic-bezier(0.2, 0.9, 0.25, 1.1),
|
||||||
|
box-shadow 220ms ease-out,
|
||||||
|
background-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
.island:hover,
|
||||||
|
.island[aria-expanded="true"] {
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
.island-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 1rem;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.doc {
|
||||||
|
max-width: 78ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-edge {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.strip-child {
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
}
|
||||||
|
.strip-child::before {
|
||||||
|
left: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menus and selects: one quiet sheet, rows that stay inside it. Unlayered
|
||||||
|
on purpose - the generated components carry pill utilities of their own. */
|
||||||
|
[data-slot="dropdown-menu-content"],
|
||||||
|
[data-slot="context-menu-content"],
|
||||||
|
[data-slot="select-content"] {
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
}
|
||||||
|
[data-slot="dropdown-menu-content"]::before,
|
||||||
|
[data-slot="context-menu-content"]::before,
|
||||||
|
[data-slot="select-content"]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-slot="dropdown-menu-item"],
|
||||||
|
[data-slot="dropdown-menu-checkbox-item"],
|
||||||
|
[data-slot="context-menu-item"],
|
||||||
|
[data-slot="context-menu-checkbox-item"],
|
||||||
|
[data-slot="select-item"] {
|
||||||
|
min-height: 1.75rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
[data-slot="select-item"] {
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
[data-slot="dropdown-menu-checkbox-item"],
|
||||||
|
[data-slot="context-menu-checkbox-item"] {
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
[data-slot$="-item"]:focus,
|
||||||
|
[data-slot$="-item"][data-highlighted] {
|
||||||
|
color: var(--accent-foreground);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
[data-slot$="-item"][data-variant="destructive"]:focus,
|
||||||
|
[data-slot$="-item"][data-variant="destructive"][data-highlighted] {
|
||||||
|
color: var(--destructive);
|
||||||
|
background: color-mix(in oklab, var(--destructive) 12%, transparent);
|
||||||
|
}
|
||||||
|
[data-slot="dropdown-menu-separator"],
|
||||||
|
[data-slot="context-menu-separator"] {
|
||||||
|
margin: 0.25rem -0.25rem;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
|
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
|
||||||
<span class="flex min-w-0 items-baseline gap-2">
|
<span class="flex min-w-0 items-baseline gap-2">
|
||||||
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
|
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
|
||||||
{toolLabel(node.name)}
|
{subagent || node.name === "?" ? "Agent" : toolLabel(node.name)}
|
||||||
</span>
|
</span>
|
||||||
{#if summary}
|
{#if summary}
|
||||||
<span class="truncate text-muted-foreground">{summary}</span>
|
<span class="truncate text-muted-foreground">{summary}</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
fmtTokens,
|
fmtTokens,
|
||||||
} from "$lib/format";
|
} from "$lib/format";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import type { Turn } from "./activity.svelte";
|
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
|
||||||
import Markdown from "./markdown.svelte";
|
import Markdown from "./markdown.svelte";
|
||||||
import ToolNodeView from "./tool-node.svelte";
|
import ToolNodeView from "./tool-node.svelte";
|
||||||
|
|
||||||
@@ -18,26 +18,35 @@
|
|||||||
const duration = $derived(
|
const duration = $derived(
|
||||||
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
|
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
|
||||||
);
|
);
|
||||||
|
const origin = $derived(ORIGIN_LABELS[turn.origin] ?? turn.origin);
|
||||||
const originLabel = $derived(
|
const originLabel = $derived(
|
||||||
turn.itemOrigin && turn.itemOrigin !== turn.origin
|
turn.itemOrigin && turn.itemOrigin !== turn.origin
|
||||||
? `${turn.origin} · ${turn.itemOrigin}`
|
? `${origin} · ${turn.itemOrigin}`
|
||||||
: turn.origin
|
: origin
|
||||||
|
);
|
||||||
|
const pill = $derived(
|
||||||
|
turn.status !== "running" && isLive(turn) ? "running" : turn.status
|
||||||
);
|
);
|
||||||
const toolCount = $derived(Object.keys(turn.nodes).length);
|
const toolCount = $derived(Object.keys(turn.nodes).length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex animate-rise flex-col gap-2 border-b py-3",
|
"flex flex-col gap-2 border-b py-3",
|
||||||
turn.status === "running" && "bg-signal/[0.03]"
|
pill === "running" && "bg-signal/[0.03]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
|
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
|
||||||
<StatusPill status={turn.status} />
|
{#if turn.origin !== "agent"}
|
||||||
|
<StatusPill status={pill} />
|
||||||
|
{/if}
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"font-medium",
|
"font-medium",
|
||||||
turn.origin === "inject" ? "text-note" : "text-foreground"
|
turn.origin === "inject" && "text-note",
|
||||||
|
turn.origin === "agent" || turn.origin === "task"
|
||||||
|
? "text-kind-deep"
|
||||||
|
: "text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{originLabel}
|
{originLabel}
|
||||||
@@ -65,7 +74,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
{#if turn.userText}
|
{#if turn.userText && turn.origin === "task"}
|
||||||
|
<p class="mx-1 px-1 text-muted-foreground text-xs">{turn.userText}</p>
|
||||||
|
{:else if turn.userText}
|
||||||
<Markdown
|
<Markdown
|
||||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
|
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
|
||||||
text={turn.userText}
|
text={turn.userText}
|
||||||
@@ -88,7 +99,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#if turn.text}
|
{#if turn.text}
|
||||||
<Markdown class="mx-1 px-1" text={turn.text} />
|
<Markdown class="mx-1 px-1" text={turn.text} />
|
||||||
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
|
{:else if turn.status === "running" && turn.origin !== "agent" && turn.roots.length === 0 && turn.thinking === 0}
|
||||||
<p class="mx-1 px-1 text-muted-foreground text-sm">
|
<p class="mx-1 px-1 text-muted-foreground text-sm">
|
||||||
Waiting for the model…
|
Waiting for the model…
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { ConversationSummary } from "$lib/api/types";
|
||||||
|
import { parseDate } from "$lib/format";
|
||||||
|
import { byActivity } from "$lib/shell/state";
|
||||||
|
|
||||||
|
export interface DayGroup {
|
||||||
|
branches: ConversationSummary[];
|
||||||
|
day: string;
|
||||||
|
deep: ConversationSummary[];
|
||||||
|
forks: ConversationSummary[];
|
||||||
|
jobs: ConversationSummary[];
|
||||||
|
label: string;
|
||||||
|
live: boolean;
|
||||||
|
master: ConversationSummary | null;
|
||||||
|
others: ConversationSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
function localDay(iso: string | null | undefined): string {
|
||||||
|
const date = parseDate(iso) ?? new Date();
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayLabel(day: string, now = new Date()): string {
|
||||||
|
const today = localDay(now.toISOString());
|
||||||
|
const yesterday = localDay(new Date(now.getTime() - DAY_MS).toISOString());
|
||||||
|
if (day === today) {
|
||||||
|
return "Today";
|
||||||
|
}
|
||||||
|
if (day === yesterday) {
|
||||||
|
return "Yesterday";
|
||||||
|
}
|
||||||
|
const date = new Date(`${day}T12:00:00`);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
weekday: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityOf(row: ConversationSummary): string {
|
||||||
|
return row.last_activity_at ?? row.created_at ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days as the operator remembers them: the master that began that day,
|
||||||
|
// and under it everything that last moved that day - branches, deep
|
||||||
|
// chats, forks, job runs. Open branches follow the master through the
|
||||||
|
// rotation, so their parent says nothing about when they were alive;
|
||||||
|
// the day of their last word does.
|
||||||
|
export function groupByDay(
|
||||||
|
rows: ConversationSummary[],
|
||||||
|
now = new Date()
|
||||||
|
): DayGroup[] {
|
||||||
|
const groups = new Map<string, DayGroup>();
|
||||||
|
const group = (day: string): DayGroup => {
|
||||||
|
let found = groups.get(day);
|
||||||
|
if (!found) {
|
||||||
|
found = {
|
||||||
|
branches: [],
|
||||||
|
day,
|
||||||
|
deep: [],
|
||||||
|
forks: [],
|
||||||
|
jobs: [],
|
||||||
|
label: dayLabel(day, now),
|
||||||
|
live: false,
|
||||||
|
master: null,
|
||||||
|
others: [],
|
||||||
|
};
|
||||||
|
groups.set(day, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
const masters = rows
|
||||||
|
.filter((row) => row.kind === "master")
|
||||||
|
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
|
||||||
|
for (const master of masters) {
|
||||||
|
const g = group(localDay(master.created_at));
|
||||||
|
if (!g.master || master.status === "open") {
|
||||||
|
g.master = master;
|
||||||
|
} else {
|
||||||
|
g.others.push(master);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.kind === "master") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const g = group(localDay(activityOf(row)));
|
||||||
|
if (row.kind === "branch") {
|
||||||
|
g.branches.push(row);
|
||||||
|
} else if (row.kind === "deep") {
|
||||||
|
g.deep.push(row);
|
||||||
|
} else if (row.kind === "fork") {
|
||||||
|
g.forks.push(row);
|
||||||
|
} else if (row.kind === "job") {
|
||||||
|
g.jobs.push(row);
|
||||||
|
} else {
|
||||||
|
g.others.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [...groups.values()];
|
||||||
|
for (const g of out) {
|
||||||
|
g.branches.sort(byActivity);
|
||||||
|
g.deep.sort(byActivity);
|
||||||
|
g.forks.sort(byActivity);
|
||||||
|
g.jobs.sort(byActivity);
|
||||||
|
g.others.sort(byActivity);
|
||||||
|
g.live = [
|
||||||
|
g.master,
|
||||||
|
...g.branches,
|
||||||
|
...g.deep,
|
||||||
|
...g.forks,
|
||||||
|
...g.jobs,
|
||||||
|
...g.others,
|
||||||
|
].some((row) => row && (row.running_turn || row.pending_question));
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.day.localeCompare(a.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One phrase for the day header: what the day held, and whether its
|
||||||
|
// master is still the live one.
|
||||||
|
export function daySummary(group: DayGroup, today: boolean): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (group.master?.status === "open" && !today) {
|
||||||
|
parts.push("master still open");
|
||||||
|
}
|
||||||
|
const count = (n: number, one: string, many = `${one}s`) =>
|
||||||
|
`${n} ${n === 1 ? one : many}`;
|
||||||
|
if (group.branches.length) {
|
||||||
|
parts.push(count(group.branches.length, "branch", "branches"));
|
||||||
|
}
|
||||||
|
if (group.deep.length) {
|
||||||
|
parts.push(count(group.deep.length, "deep", "deep"));
|
||||||
|
}
|
||||||
|
if (group.forks.length) {
|
||||||
|
parts.push(count(group.forks.length, "digest"));
|
||||||
|
}
|
||||||
|
if (group.jobs.length) {
|
||||||
|
parts.push(count(group.jobs.length, "job run"));
|
||||||
|
}
|
||||||
|
if (group.others.length) {
|
||||||
|
parts.push(count(group.others.length, "more", "more"));
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import type { ApiClient } from "$lib/api/client";
|
||||||
|
import type { AgentInfo } from "$lib/api/types";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import * as Select from "$lib/components/ui/select";
|
||||||
|
|
||||||
|
let {
|
||||||
|
client,
|
||||||
|
open = $bindable(false),
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
client: ApiClient;
|
||||||
|
open?: boolean;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const KINDS = ["deep", "branch", "master", "job"];
|
||||||
|
let agents = $state<AgentInfo[]>([]);
|
||||||
|
let form = $state({ agent: "", kind: "deep", text: "", title: "" });
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && agents.length === 0) {
|
||||||
|
client
|
||||||
|
.agents()
|
||||||
|
.then((result) => {
|
||||||
|
({ agents } = result);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentsForKind = $derived(
|
||||||
|
agents.filter((a) =>
|
||||||
|
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const created = await client.spawn({
|
||||||
|
agent: form.agent || undefined,
|
||||||
|
kind: form.kind,
|
||||||
|
seed: "clean",
|
||||||
|
text: form.text || undefined,
|
||||||
|
title: form.title || undefined,
|
||||||
|
});
|
||||||
|
open = false;
|
||||||
|
form = { agent: "", kind: "deep", text: "", title: "" };
|
||||||
|
onCreated(created.id);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>New conversation</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
It opens in the home window of its kind (a vault file, a Telegram topic)
|
||||||
|
and stays silent until someone speaks.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Label>Kind</Label>
|
||||||
|
<Select.Root
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.kind = value;
|
||||||
|
form.agent = "";
|
||||||
|
}}
|
||||||
|
type="single"
|
||||||
|
value={form.kind}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each KINDS as option (option)}
|
||||||
|
<Select.Item label={option} value={option} />
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Label>Agent</Label>
|
||||||
|
<Select.Root
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.agent = value === "default" ? "" : value;
|
||||||
|
}}
|
||||||
|
type="single"
|
||||||
|
value={form.agent || "default"}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{form.agent || "frontend default"}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item label="frontend default" value="default" />
|
||||||
|
{#each agentsForKind as agent (agent.name)}
|
||||||
|
<Select.Item label={agent.name} value={agent.name} />
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex flex-col gap-1.5">
|
||||||
|
<Label for="new-title">Title</Label>
|
||||||
|
<Input id="new-title" placeholder="optional" bind:value={form.title} />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex flex-col gap-1.5">
|
||||||
|
<Label for="new-text">First message</Label>
|
||||||
|
<textarea
|
||||||
|
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||||
|
id="new-text"
|
||||||
|
placeholder="optional - without it the window waits"
|
||||||
|
bind:value={form.text}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} onclick={create}>Create</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||||
|
import SearchIcon from "@lucide/svelte/icons/search";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { ApiClient } from "$lib/api/client";
|
||||||
|
import type { LiveState } from "$lib/api/live.svelte";
|
||||||
|
import type {
|
||||||
|
ConversationSummary,
|
||||||
|
SearchFile,
|
||||||
|
SearchResponse,
|
||||||
|
} from "$lib/api/types";
|
||||||
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
|
import { clip } from "$lib/format";
|
||||||
|
import type { ConversationIndex } from "$lib/panel/index.svelte";
|
||||||
|
import Strip from "$lib/shell/strip.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { type DayGroup, daySummary, groupByDay } from "./days";
|
||||||
|
import NewConversation from "./new-conversation.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
client,
|
||||||
|
index,
|
||||||
|
selected = null,
|
||||||
|
href,
|
||||||
|
onOpen,
|
||||||
|
onOpenFile,
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
client: ApiClient;
|
||||||
|
index: ConversationIndex;
|
||||||
|
selected?: string | null;
|
||||||
|
href?: (id: string) => string;
|
||||||
|
onOpen?: (id: string) => void;
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const SEARCH_MIN = 2;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
const DAYS_SHOWN = 14;
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
|
||||||
|
// The connection, said in one dot beside the search box: the panel can sit
|
||||||
|
// untouched for hours on a phone, and this is where it admits it went quiet.
|
||||||
|
const LIVE_DOT: Record<LiveState, string> = {
|
||||||
|
connecting: "bg-warn",
|
||||||
|
failed: "bg-destructive",
|
||||||
|
off: "bg-muted-foreground/40",
|
||||||
|
open: "bg-ok",
|
||||||
|
retrying: "bg-warn animate-pulse-dot",
|
||||||
|
};
|
||||||
|
const LIVE_LABEL: Record<LiveState, string> = {
|
||||||
|
connecting: "Connecting",
|
||||||
|
failed: "Offline",
|
||||||
|
off: "Not connected",
|
||||||
|
open: "Live",
|
||||||
|
retrying: "Reconnecting",
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let expanded = $state<Set<string>>(new Set());
|
||||||
|
let remote = $state<SearchResponse | null>(null);
|
||||||
|
let searching = $state(false);
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let now = $state(Date.now());
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
}, TICK_MS);
|
||||||
|
return () => clearInterval(tick);
|
||||||
|
});
|
||||||
|
|
||||||
|
const needle = $derived(query.trim().toLowerCase());
|
||||||
|
const groups = $derived(groupByDay(index.conversations, new Date(now)));
|
||||||
|
|
||||||
|
const local = $derived.by((): ConversationSummary[] => {
|
||||||
|
if (!needle) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return index.conversations.filter(
|
||||||
|
(row) =>
|
||||||
|
(row.title ?? "").toLowerCase().includes(needle) ||
|
||||||
|
row.id.startsWith(needle) ||
|
||||||
|
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = $derived.by((): ConversationSummary[] => {
|
||||||
|
const seen = new Set(local.map((row) => row.id));
|
||||||
|
const extra = (remote?.query === needle ? remote.conversations : []).filter(
|
||||||
|
(row) => !seen.has(row.id)
|
||||||
|
);
|
||||||
|
return [...local, ...extra];
|
||||||
|
});
|
||||||
|
const files = $derived<SearchFile[]>(
|
||||||
|
remote?.query === needle ? remote.files : []
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const q = needle;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (q.length < SEARCH_MIN) {
|
||||||
|
remote = null;
|
||||||
|
searching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searching = true;
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await client.search(q);
|
||||||
|
if (result.query === query.trim().toLowerCase()) {
|
||||||
|
remote = result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
remote = null;
|
||||||
|
} finally {
|
||||||
|
searching = false;
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isOpen(group: DayGroup, position: number): boolean {
|
||||||
|
return position === 0 || group.live || expanded.has(group.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(day: string) {
|
||||||
|
const next = new Set(expanded);
|
||||||
|
if (next.has(day)) {
|
||||||
|
next.delete(day);
|
||||||
|
} else {
|
||||||
|
next.add(day);
|
||||||
|
}
|
||||||
|
expanded = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobsOpen = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function toggleJobs(day: string) {
|
||||||
|
const next = new Set(jobsOpen);
|
||||||
|
if (next.has(day)) {
|
||||||
|
next.delete(day);
|
||||||
|
} else {
|
||||||
|
next.add(day);
|
||||||
|
}
|
||||||
|
jobsOpen = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => index.load().catch(() => undefined);
|
||||||
|
const warm = (id: string) => index.prefetch([id]);
|
||||||
|
|
||||||
|
// What the agent did on its own that day: digests and scheduled runs.
|
||||||
|
function quietSummary(group: DayGroup): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (group.forks.length) {
|
||||||
|
parts.push(
|
||||||
|
`${group.forks.length} ${group.forks.length === 1 ? "digest" : "digests"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (group.jobs.length) {
|
||||||
|
parts.push(
|
||||||
|
`${group.jobs.length} ${group.jobs.length === 1 ? "job run" : "job runs"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkOf = (id: string) => href?.(id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex h-full min-h-0 flex-col", className)}>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2">
|
||||||
|
<label
|
||||||
|
class="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md bg-strip px-2 ring-1 ring-border focus-within:ring-ring"
|
||||||
|
>
|
||||||
|
<SearchIcon class="size-3.5 shrink-0 text-icon" />
|
||||||
|
<input
|
||||||
|
aria-label="Search conversations and memory"
|
||||||
|
class="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder="Search"
|
||||||
|
spellcheck="false"
|
||||||
|
type="search"
|
||||||
|
bind:value={query}
|
||||||
|
>
|
||||||
|
{#if searching}
|
||||||
|
<span
|
||||||
|
class="size-1.5 shrink-0 animate-pulse-dot rounded-full bg-signal"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
aria-label={LIVE_LABEL[index.live.state]}
|
||||||
|
class="grid size-8 shrink-0 place-items-center rounded-md hover:bg-accent"
|
||||||
|
onclick={() => index.reconnect()}
|
||||||
|
title="{LIVE_LABEL[index.live.state]} — tap to reconnect"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={cn("size-2 rounded-full", LIVE_DOT[index.live.state])}
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="New conversation"
|
||||||
|
class="rule-word inline-flex size-8 items-center justify-center rounded-md hover:bg-accent"
|
||||||
|
onclick={() => {
|
||||||
|
createOpen = true;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-6">
|
||||||
|
{#if index.live.state === "failed"}
|
||||||
|
<ErrorNote
|
||||||
|
message={index.live.detail ?? "event stream failed"}
|
||||||
|
retry={() => index.start()}
|
||||||
|
/>
|
||||||
|
{:else if !index.loaded}
|
||||||
|
<p class="px-1 py-3 text-muted-foreground text-sm">Loading…</p>
|
||||||
|
{:else if needle}
|
||||||
|
<section class="flex flex-col gap-2 pt-1">
|
||||||
|
<h2 class="label-quiet px-1">
|
||||||
|
{found.length === 0 && !searching ? "Nothing matches" : "Conversations"}
|
||||||
|
</h2>
|
||||||
|
{#if found.length > 0}
|
||||||
|
<div class="bay-rack">
|
||||||
|
{#each found as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
{client}
|
||||||
|
current={row.id === selected}
|
||||||
|
detail={row.last_item?.text ?? ""}
|
||||||
|
href={linkOf(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={onOpen}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if files.length > 0}
|
||||||
|
<h2 class="label-quiet px-1 pt-2">Memory</h2>
|
||||||
|
<div class="bay-rack">
|
||||||
|
{#each files as file (file.path)}
|
||||||
|
<button
|
||||||
|
class="strip w-full text-left text-sm"
|
||||||
|
onclick={() => onOpenFile?.(file.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="size-5"></span>
|
||||||
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
|
<span class="truncate font-medium">{file.path}</span>
|
||||||
|
{#if file.snippet}
|
||||||
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
|
{clip(file.snippet, 96)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="tabular text-muted-foreground text-xs">
|
||||||
|
{file.line ? `:${file.line}` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
{#each groups.slice(0, DAYS_SHOWN) as group, position (group.day)}
|
||||||
|
{@const shown = isOpen(group, position)}
|
||||||
|
<section class="flex flex-col gap-1.5 pt-2">
|
||||||
|
<button
|
||||||
|
aria-expanded={shown}
|
||||||
|
class="flex items-center gap-1.5 px-1 text-left"
|
||||||
|
onclick={() => toggle(group.day)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon
|
||||||
|
class={cn(
|
||||||
|
"size-3.5 text-icon transition-transform duration-150",
|
||||||
|
shown && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"font-medium text-sm",
|
||||||
|
position === 0 ? "text-foreground" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
{#if group.live}
|
||||||
|
<span
|
||||||
|
class="size-1.5 rounded-full bg-signal animate-pulse-dot"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
|
{daySummary(group, position === 0)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if shown}
|
||||||
|
<div class="bay-rack">
|
||||||
|
{#if group.master}
|
||||||
|
<Strip
|
||||||
|
{client}
|
||||||
|
current={group.master.id === selected}
|
||||||
|
detail={group.master.last_item?.text ?? ""}
|
||||||
|
href={linkOf(group.master.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={onOpen}
|
||||||
|
onWarm={warm}
|
||||||
|
row={group.master}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#each group.branches as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
{client}
|
||||||
|
current={row.id === selected}
|
||||||
|
detail={row.last_item?.text ?? ""}
|
||||||
|
href={linkOf(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={onOpen}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#each group.deep as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
{client}
|
||||||
|
current={row.id === selected}
|
||||||
|
detail={row.last_item?.text ?? ""}
|
||||||
|
href={linkOf(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={onOpen}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#each group.others as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
{client}
|
||||||
|
current={row.id === selected}
|
||||||
|
detail="{row.kind} · {row.status}"
|
||||||
|
href={linkOf(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={onOpen}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if group.forks.length + group.jobs.length > 0}
|
||||||
|
{@const shownQuiet = jobsOpen.has(group.day)}
|
||||||
|
<button
|
||||||
|
aria-expanded={shownQuiet}
|
||||||
|
class="strip w-full text-left text-muted-foreground text-xs"
|
||||||
|
onclick={() => toggleJobs(group.day)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="size-5"></span>
|
||||||
|
<span>
|
||||||
|
{quietSummary(group)}
|
||||||
|
· {shownQuiet ? "hide" : "show"}
|
||||||
|
</span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
{#if shownQuiet}
|
||||||
|
{#each [...group.forks, ...group.jobs] as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
{client}
|
||||||
|
current={row.id === selected}
|
||||||
|
detail={row.last_item?.text ?? `${row.kind} · ${row.status}`}
|
||||||
|
href={linkOf(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={onOpen}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{#if groups.length === 0}
|
||||||
|
<p class="px-1 py-3 text-muted-foreground text-sm">
|
||||||
|
No conversations yet. Start one, or wait for the morning master.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewConversation
|
||||||
|
{client}
|
||||||
|
onCreated={(id) => onOpen?.(id)}
|
||||||
|
bind:open={createOpen}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
count = null,
|
||||||
|
tone = "default",
|
||||||
|
hint = "",
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
aside,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count?: number | null;
|
||||||
|
tone?: "default" | "attention" | "signal";
|
||||||
|
hint?: string;
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
aside?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
attention: "text-attention-foreground",
|
||||||
|
default: "text-muted-foreground",
|
||||||
|
signal: "text-signal",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class={cn("bay", className)}>
|
||||||
|
<header class="flex items-baseline gap-2 px-1">
|
||||||
|
<h2 class={cn("font-medium text-sm", TONE[tone])}>{label}</h2>
|
||||||
|
{#if count !== null}
|
||||||
|
<span class={cn("tabular text-xs", TONE[tone])}>{count}</span>
|
||||||
|
{/if}
|
||||||
|
{#if hint}
|
||||||
|
<span class="truncate text-muted-foreground text-xs">{hint}</span>
|
||||||
|
{/if}
|
||||||
|
{#if aside}
|
||||||
|
<span class="ml-auto">{@render aside()}</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<div class="bay-rack">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { base } from "$app/paths";
|
||||||
|
import type {
|
||||||
|
ConversationSummary,
|
||||||
|
JobInfo,
|
||||||
|
QueuedJob,
|
||||||
|
VaultGraph,
|
||||||
|
} from "$lib/api/types";
|
||||||
|
import {
|
||||||
|
clip,
|
||||||
|
fmtCountdown,
|
||||||
|
fmtRelative,
|
||||||
|
fmtTime,
|
||||||
|
shortId,
|
||||||
|
} from "$lib/format";
|
||||||
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
|
import { summarizeInput, toolLabel } from "$lib/panel/activity.svelte";
|
||||||
|
import QuestionCard from "$lib/panel/question-card.svelte";
|
||||||
|
import { session } from "$lib/session.svelte";
|
||||||
|
import { ui } from "$lib/ui.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import Bay from "./bay.svelte";
|
||||||
|
import FloatMenu, { type FloatItem } from "./float-menu.svelte";
|
||||||
|
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
|
||||||
|
import Instruments from "./instruments.svelte";
|
||||||
|
import KindMark from "./kind-mark.svelte";
|
||||||
|
import { now as board } from "./now.svelte";
|
||||||
|
import { stateOf } from "./state";
|
||||||
|
import Strip from "./strip.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
compact?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const TICK_MS = 1000;
|
||||||
|
const QUIET_SHOWN = 6;
|
||||||
|
const TAPE_SHOWN = 14;
|
||||||
|
const HOURS_DAY = 24;
|
||||||
|
const DEEP_RECENT_MS = 36 * 3_600_000;
|
||||||
|
const SCHEDULED_SHOWN = 8;
|
||||||
|
const PAYLOAD_MAX = 90;
|
||||||
|
|
||||||
|
let now = $state(Date.now());
|
||||||
|
let spend = $state<number | null>(null);
|
||||||
|
let vault = $state<VaultGraph | null>(null);
|
||||||
|
let vaultFor = $state<string | null>(null);
|
||||||
|
let quietOpen = $state(false);
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let menuAt = $state({ x: 0, y: 0 });
|
||||||
|
let menuItems = $state<FloatItem[]>([]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
}, TICK_MS);
|
||||||
|
const { client } = session;
|
||||||
|
if (client) {
|
||||||
|
client
|
||||||
|
.usage({ group_by: "agent", hours: HOURS_DAY })
|
||||||
|
.then((usage) => {
|
||||||
|
spend = usage.total.cost_usd;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
return () => clearInterval(tick);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const key = board.liveIds.join("|");
|
||||||
|
if (key.length >= 0) {
|
||||||
|
untrack(() => board.refreshSnapshots().catch(() => undefined));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const href = (id: string) => `${base}/conversations/${id}`;
|
||||||
|
const GRAPH_FILES = 24;
|
||||||
|
const refresh = () => gateway.load().catch(() => undefined);
|
||||||
|
const warm = (id: string) => gateway.prefetch([id]);
|
||||||
|
|
||||||
|
async function loadVault(masterId: string) {
|
||||||
|
const { client } = session;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const context = await client.context(masterId);
|
||||||
|
const paths = context.files.slice(0, GRAPH_FILES).map((f) => f.path);
|
||||||
|
vault = paths.length > 0 ? await client.vaultGraph(paths, 60) : null;
|
||||||
|
} catch {
|
||||||
|
vault = null;
|
||||||
|
}
|
||||||
|
vaultFor = masterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { master } = board;
|
||||||
|
if (!master || master.running_turn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${master.id}:${master.last_activity_at ?? ""}`;
|
||||||
|
if (vaultFor !== key) {
|
||||||
|
vaultFor = key;
|
||||||
|
untrack(() => loadVault(master.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(id: string) {
|
||||||
|
ui.closeAll();
|
||||||
|
goto(href(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastTool(row: ConversationSummary): string {
|
||||||
|
const info = board.snapshots[row.id];
|
||||||
|
const tools = info?.turn?.tools ?? [];
|
||||||
|
const live = tools.filter((t) => !t.ended_at);
|
||||||
|
const tool = live.at(-1) ?? tools.at(-1);
|
||||||
|
if (!tool) {
|
||||||
|
return info?.turn?.text ? clip(info.turn.text, 80) : "thinking";
|
||||||
|
}
|
||||||
|
const what = summarizeInput(tool.name, tool.input);
|
||||||
|
const short = what.startsWith("/")
|
||||||
|
? what.split("/").slice(-2).join("/")
|
||||||
|
: what;
|
||||||
|
return `${toolLabel(tool.name)} · ${clip(short, 72)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quiet = $derived(
|
||||||
|
quietOpen ? board.quiet : board.quiet.slice(0, QUIET_SHOWN)
|
||||||
|
);
|
||||||
|
const windows = $derived(gateway.limits?.windows ?? []);
|
||||||
|
const tape = $derived(gateway.tape.slice(0, TAPE_SHOWN));
|
||||||
|
|
||||||
|
// What is coming: queued injects and the next cron runs, soonest first.
|
||||||
|
interface Coming {
|
||||||
|
at: string;
|
||||||
|
detail: string;
|
||||||
|
id: string;
|
||||||
|
job: JobInfo | null;
|
||||||
|
kind: "queue" | "job";
|
||||||
|
label: string;
|
||||||
|
queued: QueuedJob | null;
|
||||||
|
}
|
||||||
|
const coming = $derived.by((): Coming[] => {
|
||||||
|
const snapshot = gateway.jobs;
|
||||||
|
if (!snapshot) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: Coming[] = [];
|
||||||
|
for (const q of snapshot.queue) {
|
||||||
|
if (q.status === "queued" && q.execute_after) {
|
||||||
|
const { text } = q.payload;
|
||||||
|
out.push({
|
||||||
|
at: q.execute_after,
|
||||||
|
detail: typeof text === "string" ? text : q.entrypoint,
|
||||||
|
id: `q:${q.id}`,
|
||||||
|
job: null,
|
||||||
|
kind: "queue",
|
||||||
|
label: q.entrypoint.replace(INJECT_PREFIX, "inject "),
|
||||||
|
queued: q,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const job of snapshot.jobs) {
|
||||||
|
if (job.next_run) {
|
||||||
|
out.push({
|
||||||
|
at: job.next_run,
|
||||||
|
detail: `${job.cron ?? "manual"} · ${lastRun(job)}`,
|
||||||
|
id: `j:${job.name}`,
|
||||||
|
job,
|
||||||
|
kind: "job",
|
||||||
|
label: job.name,
|
||||||
|
queued: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
.sort((a, b) => a.at.localeCompare(b.at))
|
||||||
|
.slice(0, SCHEDULED_SHOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
function lastRun(job: JobInfo): string {
|
||||||
|
if (job.run) {
|
||||||
|
return `${job.run.status} ${fmtRelative(job.run.started_at, now)}`;
|
||||||
|
}
|
||||||
|
return job.last_run ? `ran ${fmtRelative(job.last_run, now)}` : "never ran";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MD_SUFFIX = /\.md$/;
|
||||||
|
const INJECT_PREFIX = /^inject[._-]?/;
|
||||||
|
const SECONDS = /:\d{2}$/;
|
||||||
|
|
||||||
|
function recent(row: ConversationSummary): boolean {
|
||||||
|
const at = Date.parse(row.last_activity_at ?? row.created_at ?? "");
|
||||||
|
return Number.isFinite(at) && now - at < DEEP_RECENT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The master, what hangs off it, what moves, and what spoke lately:
|
||||||
|
// not every open thread from the past week.
|
||||||
|
function conversationNodes(master: ConversationSummary | null): {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
} {
|
||||||
|
const nodes: GraphNode[] = [];
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
if (master) {
|
||||||
|
nodes.push({
|
||||||
|
href: href(master.id),
|
||||||
|
id: master.id,
|
||||||
|
kind: "master",
|
||||||
|
label: master.title ?? "master",
|
||||||
|
ring: 0,
|
||||||
|
state: stateOf(master),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of gateway.conversations) {
|
||||||
|
if (row.id === master?.id || row.kind === "master") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const child = Boolean(master) && row.parent === master?.id;
|
||||||
|
const live = Boolean(row.running_turn) || row.pending_question;
|
||||||
|
const shown = live || (row.status === "open" && (child || recent(row)));
|
||||||
|
if (!shown) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nodes.push({
|
||||||
|
href: href(row.id),
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
|
||||||
|
ring: child || live ? 1 : 2,
|
||||||
|
state: stateOf(row),
|
||||||
|
});
|
||||||
|
if (master && child) {
|
||||||
|
edges.push({ from: master.id, to: row.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { edges, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteState(touched: boolean, exists: boolean): GraphNode["state"] {
|
||||||
|
if (!touched) {
|
||||||
|
return "ghost";
|
||||||
|
}
|
||||||
|
return exists ? "quiet" : "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultNodes(
|
||||||
|
master: ConversationSummary,
|
||||||
|
graphData: VaultGraph
|
||||||
|
): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||||
|
const nodes: GraphNode[] = graphData.nodes.map((note) => ({
|
||||||
|
href: note.exists ? note.path : undefined,
|
||||||
|
id: note.path,
|
||||||
|
kind: "file",
|
||||||
|
label: note.title,
|
||||||
|
ring: note.touched ? 1 : 2,
|
||||||
|
state: noteState(note.touched, note.exists),
|
||||||
|
}));
|
||||||
|
const edges: GraphEdge[] = graphData.nodes
|
||||||
|
.filter((note) => note.touched)
|
||||||
|
.map((note) => ({ from: master.id, to: note.path }));
|
||||||
|
for (const edge of graphData.edges) {
|
||||||
|
edges.push({ from: edge.from, to: edge.to });
|
||||||
|
}
|
||||||
|
return { edges, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const graph = $derived.by((): { nodes: GraphNode[]; edges: GraphEdge[] } => {
|
||||||
|
const { master } = board;
|
||||||
|
const own = conversationNodes(master);
|
||||||
|
if (!(vault && master)) {
|
||||||
|
return own;
|
||||||
|
}
|
||||||
|
const extra = vaultNodes(master, vault);
|
||||||
|
return {
|
||||||
|
edges: [...own.edges, ...extra.edges],
|
||||||
|
nodes: [...own.nodes, ...extra.nodes],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function obsidianUrl(path: string): string {
|
||||||
|
return `obsidian://open?file=${encodeURIComponent(path.replace(MD_SUFFIX, ""))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNode(id: string) {
|
||||||
|
if (id.endsWith(".md")) {
|
||||||
|
window.open(obsidianUrl(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeMenu(node: GraphNode, x: number, y: number) {
|
||||||
|
menuAt = { x, y };
|
||||||
|
if (node.kind === "file") {
|
||||||
|
menuItems = [
|
||||||
|
{
|
||||||
|
label: "Open in Obsidian",
|
||||||
|
run: () => window.open(obsidianUrl(node.id)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open in Memory",
|
||||||
|
run: () => {
|
||||||
|
ui.closeAll();
|
||||||
|
goto(`${base}/memory?path=${encodeURIComponent(node.id)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gap: true,
|
||||||
|
label: "Copy path",
|
||||||
|
run: () =>
|
||||||
|
navigator.clipboard.writeText(node.id).catch(() => undefined),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
menuItems = [
|
||||||
|
{ label: "Open", run: () => open(node.id) },
|
||||||
|
{
|
||||||
|
label: "Open in a new tab",
|
||||||
|
run: () => window.open(href(node.id), "_blank", "noopener"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gap: true,
|
||||||
|
label: "Copy id",
|
||||||
|
run: () =>
|
||||||
|
navigator.clipboard.writeText(node.id).catch(() => undefined),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
menuOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comingMenu(event: MouseEvent, item: Coming) {
|
||||||
|
event.preventDefault();
|
||||||
|
menuAt = { x: event.clientX, y: event.clientY };
|
||||||
|
const items: FloatItem[] = [
|
||||||
|
{
|
||||||
|
label: "Open the scheduler",
|
||||||
|
run: () => {
|
||||||
|
ui.closeAll();
|
||||||
|
goto(`${base}/system/jobs`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (item.job && session.client) {
|
||||||
|
const { client } = session;
|
||||||
|
const { name } = item.job;
|
||||||
|
items.push({
|
||||||
|
label: "Run now",
|
||||||
|
run: () =>
|
||||||
|
client
|
||||||
|
.runJob(name)
|
||||||
|
.then(refreshJobs)
|
||||||
|
.catch(() => undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.queued && session.client) {
|
||||||
|
const { client } = session;
|
||||||
|
const { id } = item.queued;
|
||||||
|
items.push({
|
||||||
|
danger: true,
|
||||||
|
gap: true,
|
||||||
|
label: "Cancel",
|
||||||
|
run: () =>
|
||||||
|
client
|
||||||
|
.cancelJob(id)
|
||||||
|
.then(refreshJobs)
|
||||||
|
.catch(() => undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menuItems = items;
|
||||||
|
menuOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshJobs = () => gateway.refreshJobs().catch(() => undefined);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FloatMenu items={menuItems} x={menuAt.x} y={menuAt.y} bind:open={menuOpen} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"@container grid gap-8",
|
||||||
|
compact
|
||||||
|
? "grid-cols-1"
|
||||||
|
: "grid-cols-1 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_26rem]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col gap-8">
|
||||||
|
<Instruments
|
||||||
|
{compact}
|
||||||
|
contextTokens={board.contextTokens}
|
||||||
|
contextWindow={board.contextWindow}
|
||||||
|
{now}
|
||||||
|
{spend}
|
||||||
|
{windows}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if board.waiting.length > 0}
|
||||||
|
<Bay
|
||||||
|
count={board.waiting.length}
|
||||||
|
hint="the agent asked and stopped"
|
||||||
|
label="Waiting for you"
|
||||||
|
tone="attention"
|
||||||
|
>
|
||||||
|
{#each board.waiting as row (row.id)}
|
||||||
|
{@const info = board.snapshots[row.id]}
|
||||||
|
<Strip
|
||||||
|
client={session.client}
|
||||||
|
detail={info?.question?.questions[0]?.question ?? "question pending"}
|
||||||
|
href={href(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={open}
|
||||||
|
onWarm={warm}
|
||||||
|
open={Boolean(info?.question)}
|
||||||
|
{row}
|
||||||
|
state="waiting"
|
||||||
|
>
|
||||||
|
{#if info?.question && session.client}
|
||||||
|
<QuestionCard
|
||||||
|
client={session.client}
|
||||||
|
conversationId={row.id}
|
||||||
|
question={info.question}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Strip>
|
||||||
|
{/each}
|
||||||
|
</Bay>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Bay
|
||||||
|
count={board.running.length}
|
||||||
|
hint={board.running.length === 0 ? "nothing in a turn" : ""}
|
||||||
|
label="In motion"
|
||||||
|
tone="signal"
|
||||||
|
>
|
||||||
|
{#if board.running.length === 0}
|
||||||
|
<p class="px-4 py-3 text-muted-foreground text-sm">
|
||||||
|
Idle. The next message or inject lights a strip here.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each board.running as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
client={session.client}
|
||||||
|
detail={lastTool(row)}
|
||||||
|
href={href(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={open}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
state="running"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Bay>
|
||||||
|
|
||||||
|
{#if coming.length > 0}
|
||||||
|
<Bay
|
||||||
|
count={coming.length}
|
||||||
|
hint="queued injects and the next cron runs"
|
||||||
|
label="Coming up"
|
||||||
|
>
|
||||||
|
{#snippet aside()}
|
||||||
|
<a class="rule-word text-xs" href="{base}/system/jobs">scheduler</a>
|
||||||
|
{/snippet}
|
||||||
|
{#each coming as item (item.id)}
|
||||||
|
<a
|
||||||
|
class="strip text-left text-sm"
|
||||||
|
href="{base}/system/jobs"
|
||||||
|
oncontextmenu={(event) => comingMenu(event, item)}
|
||||||
|
title="right-click for actions"
|
||||||
|
>
|
||||||
|
{#if item.kind === "job"}
|
||||||
|
<KindMark kind="job" />
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] text-muted-foreground"
|
||||||
|
title="queued inject"
|
||||||
|
>
|
||||||
|
↳
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
|
<span class="truncate font-medium">{item.label}</span>
|
||||||
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
|
{clip(item.detail, PAYLOAD_MAX)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="tabular shrink-0 text-muted-foreground text-xs"
|
||||||
|
title={fmtTime(item.at)}
|
||||||
|
>
|
||||||
|
{fmtCountdown(item.at, now).replace("resets ", "")}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</Bay>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Bay
|
||||||
|
count={board.quiet.length}
|
||||||
|
hint="open, waiting for the next word"
|
||||||
|
label="Quiet"
|
||||||
|
>
|
||||||
|
{#snippet aside()}
|
||||||
|
{#if board.quiet.length > QUIET_SHOWN}
|
||||||
|
<button
|
||||||
|
class="rule-word text-xs"
|
||||||
|
onclick={() => {
|
||||||
|
quietOpen = !quietOpen;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{quietOpen ? "fewer" : `all ${board.quiet.length}`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#if !gateway.loaded}
|
||||||
|
<p class="px-4 py-3 text-muted-foreground text-sm">Loading…</p>
|
||||||
|
{:else if quiet.length === 0}
|
||||||
|
<p class="px-4 py-3 text-muted-foreground text-sm">
|
||||||
|
No open conversations.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each quiet as row (row.id)}
|
||||||
|
<Strip
|
||||||
|
client={session.client}
|
||||||
|
detail={row.last_item?.text ?? ""}
|
||||||
|
href={href(row.id)}
|
||||||
|
{now}
|
||||||
|
onChanged={refresh}
|
||||||
|
onclick={open}
|
||||||
|
onWarm={warm}
|
||||||
|
{row}
|
||||||
|
state="quiet"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Bay>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !compact}
|
||||||
|
<aside class="hidden min-w-0 flex-col gap-6 lg:flex">
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h2 class="label-quiet px-1">Around the master</h2>
|
||||||
|
<div class="h-[26rem] overflow-hidden rounded-lg border bg-strip/60">
|
||||||
|
<Graph
|
||||||
|
edges={graph.edges}
|
||||||
|
nodes={graph.nodes}
|
||||||
|
onMenu={nodeMenu}
|
||||||
|
onOpen={openNode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h2 class="label-quiet px-1">
|
||||||
|
Recent events
|
||||||
|
<span class="tabular font-normal">{gateway.tape.length}</span>
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-col px-1 text-xs">
|
||||||
|
{#each tape as item (item.seq)}
|
||||||
|
<li
|
||||||
|
class="ledger-grid grid-cols-[4.5rem_7rem_minmax(0,1fr)] py-0.5"
|
||||||
|
>
|
||||||
|
<span class="tabular text-muted-foreground">
|
||||||
|
{fmtTime(item.ts).replace(SECONDS, "")}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{item.type}</span>
|
||||||
|
<span class="truncate text-muted-foreground">
|
||||||
|
{#if item.conversation_id}
|
||||||
|
<a class="hover:underline" href={href(item.conversation_id)}>
|
||||||
|
{shortId(item.conversation_id)}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if typeof item.name === "string"}
|
||||||
|
{item.name}
|
||||||
|
{/if}
|
||||||
|
{#if typeof item.text === "string"}
|
||||||
|
{item.text.slice(0, 80)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="py-1 text-muted-foreground">
|
||||||
|
Nothing yet this session.
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface FloatItem {
|
||||||
|
danger?: boolean;
|
||||||
|
// A rule sits above this item.
|
||||||
|
gap?: boolean;
|
||||||
|
label: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
// A menu that opens where the pointer is, for things that are not
|
||||||
|
// conversations: a note in the graph, a file in the memory tree.
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
open = $bindable(false),
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
items: FloatItem[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
open?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const MARGIN = 8;
|
||||||
|
const WIDTH = 208;
|
||||||
|
let panel = $state<HTMLDivElement | null>(null);
|
||||||
|
let cursor = $state(0);
|
||||||
|
|
||||||
|
const left = $derived(
|
||||||
|
typeof window === "undefined"
|
||||||
|
? x
|
||||||
|
: Math.min(x, window.innerWidth - WIDTH - MARGIN)
|
||||||
|
);
|
||||||
|
const top = $derived.by(() => {
|
||||||
|
if (typeof window === "undefined" || !panel) {
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
return Math.min(y, window.innerHeight - panel.offsetHeight - MARGIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(item: FloatItem) {
|
||||||
|
close();
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor = 0;
|
||||||
|
const away = (event: PointerEvent) => {
|
||||||
|
if (panel && !panel.contains(event.target as Node)) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keys = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = (cursor + 1) % items.length;
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = (cursor - 1 + items.length) % items.length;
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = items[cursor];
|
||||||
|
if (item) {
|
||||||
|
pick(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", away, true);
|
||||||
|
window.addEventListener("keydown", keys, true);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
window.addEventListener("scroll", close, true);
|
||||||
|
queueMicrotask(() => panel?.focus());
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", away, true);
|
||||||
|
window.removeEventListener("keydown", keys, true);
|
||||||
|
window.removeEventListener("resize", close);
|
||||||
|
window.removeEventListener("scroll", close, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"fixed z-50 flex animate-island-in flex-col rounded-lg border bg-popover p-1 text-popover-foreground text-sm shadow-float outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="menu"
|
||||||
|
style="left: {left}px; top: {top}px; width: {WIDTH}px"
|
||||||
|
tabindex="-1"
|
||||||
|
bind:this={panel}
|
||||||
|
>
|
||||||
|
{#each items as item, index (item.label)}
|
||||||
|
{#if item.gap && index > 0}
|
||||||
|
<div class="my-1 h-px bg-border"></div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"flex w-full items-center rounded-md px-2 py-1.5 text-left hover:bg-accent",
|
||||||
|
index === cursor && "bg-accent",
|
||||||
|
item.danger && "text-destructive"
|
||||||
|
)}
|
||||||
|
onclick={() => pick(item)}
|
||||||
|
onpointerenter={() => {
|
||||||
|
cursor = index;
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
forceCenter,
|
||||||
|
forceCollide,
|
||||||
|
forceLink,
|
||||||
|
forceManyBody,
|
||||||
|
forceRadial,
|
||||||
|
forceSimulation,
|
||||||
|
type Simulation,
|
||||||
|
type SimulationLinkDatum,
|
||||||
|
type SimulationNodeDatum,
|
||||||
|
} from "d3-force";
|
||||||
|
|
||||||
|
// d3-force under a small API: rings keep the picture legible, collision
|
||||||
|
// keeps dots apart, a faint alpha target keeps the graph breathing.
|
||||||
|
|
||||||
|
export interface Body extends SimulationNodeDatum {
|
||||||
|
id: string;
|
||||||
|
ring: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Link extends SimulationLinkDatum<Body> {
|
||||||
|
source: Body;
|
||||||
|
target: Body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RING_RADIUS = [0, 96, 176, 236];
|
||||||
|
const CHARGE = -150;
|
||||||
|
const CHARGE_REACH = 340;
|
||||||
|
const LINK_REST = 62;
|
||||||
|
const LINK_RING_GAP = 14;
|
||||||
|
const LINK_STRENGTH = 0.45;
|
||||||
|
const COLLIDE_HUB = 24;
|
||||||
|
const COLLIDE = 13;
|
||||||
|
const RADIAL_HUB = 1;
|
||||||
|
const RADIAL = 0.07;
|
||||||
|
const CENTER = 0.02;
|
||||||
|
const ALPHA_DECAY = 0.03;
|
||||||
|
const VELOCITY_DECAY = 0.45;
|
||||||
|
const BREATH_TARGET = 0.012;
|
||||||
|
const SETTLED = 0.004;
|
||||||
|
const JITTER = 0.06;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const HASH_MOD = 1_000_003;
|
||||||
|
const HASH_BASE = 31;
|
||||||
|
|
||||||
|
function hash(id: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of id) {
|
||||||
|
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ringRadius(ring: number): number {
|
||||||
|
return RING_RADIUS[Math.min(ring, RING_RADIUS.length - 1)] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Force {
|
||||||
|
bodies: Body[] = [];
|
||||||
|
private readonly sim: Simulation<Body, Link>;
|
||||||
|
private readonly linkForce = forceLink<Body, Link>([])
|
||||||
|
.id((body) => body.id)
|
||||||
|
.distance(
|
||||||
|
(link) =>
|
||||||
|
LINK_REST +
|
||||||
|
Math.abs(link.source.ring - link.target.ring) * LINK_RING_GAP
|
||||||
|
)
|
||||||
|
.strength(LINK_STRENGTH);
|
||||||
|
private readonly index = new Map<string, Body>();
|
||||||
|
private clock = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sim = forceSimulation<Body>([])
|
||||||
|
.stop()
|
||||||
|
.alphaDecay(ALPHA_DECAY)
|
||||||
|
.velocityDecay(VELOCITY_DECAY)
|
||||||
|
.force(
|
||||||
|
"charge",
|
||||||
|
forceManyBody<Body>().strength(CHARGE).distanceMax(CHARGE_REACH)
|
||||||
|
)
|
||||||
|
.force("link", this.linkForce)
|
||||||
|
.force(
|
||||||
|
"collide",
|
||||||
|
forceCollide<Body>((body) => (body.ring === 0 ? COLLIDE_HUB : COLLIDE))
|
||||||
|
)
|
||||||
|
.force(
|
||||||
|
"radial",
|
||||||
|
forceRadial<Body>((body) => ringRadius(body.ring), 0, 0).strength(
|
||||||
|
(body) => (body.ring === 0 ? RADIAL_HUB : RADIAL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.force("center", forceCenter(0, 0).strength(CENTER));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep what is placed, seat newcomers on their ring beside a neighbour
|
||||||
|
// when they have one, forget what is gone.
|
||||||
|
sync(
|
||||||
|
nodes: { id: string; ring: number }[],
|
||||||
|
edges: { from: string; to: string }[]
|
||||||
|
): void {
|
||||||
|
const keep = new Set<string>();
|
||||||
|
const fresh: Body[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
keep.add(node.id);
|
||||||
|
const known = this.index.get(node.id);
|
||||||
|
if (known) {
|
||||||
|
known.ring = node.ring;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const angle = (hash(node.id) % 360) * (TAU / 360);
|
||||||
|
const r = ringRadius(node.ring);
|
||||||
|
const body: Body = {
|
||||||
|
id: node.id,
|
||||||
|
ring: node.ring,
|
||||||
|
x: Math.cos(angle) * r,
|
||||||
|
y: Math.sin(angle) * r,
|
||||||
|
};
|
||||||
|
this.index.set(node.id, body);
|
||||||
|
fresh.push(body);
|
||||||
|
}
|
||||||
|
for (const id of [...this.index.keys()]) {
|
||||||
|
if (!keep.has(id)) {
|
||||||
|
this.index.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bodies = nodes
|
||||||
|
.map((node) => this.index.get(node.id))
|
||||||
|
.filter((body): body is Body => body !== undefined);
|
||||||
|
const links: Link[] = [];
|
||||||
|
for (const edge of edges) {
|
||||||
|
const source = this.index.get(edge.from);
|
||||||
|
const target = this.index.get(edge.to);
|
||||||
|
if (source && target && source !== target) {
|
||||||
|
links.push({ source, target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const body of fresh) {
|
||||||
|
const link = links.find((l) => l.source === body || l.target === body);
|
||||||
|
if (link) {
|
||||||
|
const other = link.source === body ? link.target : link.source;
|
||||||
|
const r = ringRadius(body.ring);
|
||||||
|
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
|
||||||
|
body.x = Math.cos(angle) * r;
|
||||||
|
body.y = Math.sin(angle) * r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sim.nodes(this.bodies);
|
||||||
|
this.linkForce.links(links);
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
this.reheat(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body(id: string): Body | undefined {
|
||||||
|
return this.index.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get alpha(): number {
|
||||||
|
return this.sim.alpha();
|
||||||
|
}
|
||||||
|
|
||||||
|
reheat(to = 0.4): void {
|
||||||
|
this.sim.alpha(Math.max(this.sim.alpha(), to));
|
||||||
|
}
|
||||||
|
|
||||||
|
get settled(): boolean {
|
||||||
|
return this.sim.alpha() < SETTLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One tick; ``breathe`` keeps a faint target so the graph never freezes.
|
||||||
|
step(breathe: boolean): void {
|
||||||
|
this.clock += 1 / 60;
|
||||||
|
this.sim.alphaTarget(breathe ? BREATH_TARGET : 0);
|
||||||
|
if (breathe) {
|
||||||
|
for (const body of this.bodies) {
|
||||||
|
if (body.fx !== null && body.fx !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seed = hash(body.id) % 11;
|
||||||
|
body.vx = (body.vx ?? 0) + Math.sin(this.clock * 0.6 + seed) * JITTER;
|
||||||
|
body.vy =
|
||||||
|
(body.vy ?? 0) + Math.cos(this.clock * 0.5 + seed * 1.3) * JITTER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sim.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds(): { x: number; y: number; w: number; h: number } {
|
||||||
|
if (this.bodies.length === 0) {
|
||||||
|
return { h: 1, w: 1, x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
let minX = Number.POSITIVE_INFINITY;
|
||||||
|
let minY = Number.POSITIVE_INFINITY;
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY;
|
||||||
|
let maxY = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const body of this.bodies) {
|
||||||
|
minX = Math.min(minX, body.x);
|
||||||
|
minY = Math.min(minY, body.y);
|
||||||
|
maxX = Math.max(maxX, body.x);
|
||||||
|
maxY = Math.max(maxY, body.y);
|
||||||
|
}
|
||||||
|
return { h: maxY - minY || 1, w: maxX - minX || 1, x: minX, y: minY };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface GraphNode {
|
||||||
|
href?: string;
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
label: string;
|
||||||
|
// 0 = hub, 1 = first ring, 2 = second ring, 3 = ghost
|
||||||
|
ring: number;
|
||||||
|
state: "running" | "waiting" | "quiet" | "closed" | "ghost";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Maximize2Icon from "@lucide/svelte/icons/maximize-2";
|
||||||
|
import Minimize2Icon from "@lucide/svelte/icons/minimize-2";
|
||||||
|
import ScanIcon from "@lucide/svelte/icons/scan";
|
||||||
|
import { onMount, untrack } from "svelte";
|
||||||
|
import { clip } from "$lib/format";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { type Body, Force } from "./force";
|
||||||
|
|
||||||
|
let {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
onOpen,
|
||||||
|
onMenu,
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
onOpen?: (id: string) => void;
|
||||||
|
// The right button on a node: what is open lands where the pointer is.
|
||||||
|
onMenu?: (node: GraphNode, x: number, y: number) => void;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const LABEL_MAX = 24;
|
||||||
|
const LABEL_MAX_NEAR = 48;
|
||||||
|
const ZOOM_MIN = 0.25;
|
||||||
|
const ZOOM_MAX = 4;
|
||||||
|
const ZOOM_STEP = 0.0016;
|
||||||
|
const FIT_PAD = 0.86;
|
||||||
|
const FIT_MAX = 1.5;
|
||||||
|
const CLICK_SLOP = 4;
|
||||||
|
const LABEL_RING1_MAX = 14;
|
||||||
|
const LABEL_RING1_ZOOM = 1.2;
|
||||||
|
const LABEL_RING2_ZOOM = 2;
|
||||||
|
const SETTLE_FRAMES = 90;
|
||||||
|
const FONT = 11;
|
||||||
|
// Small box: the hub, its ring, and a few of what lies one link away.
|
||||||
|
const SMALL_RING1_FILES = 16;
|
||||||
|
const SMALL_GHOSTS = 12;
|
||||||
|
|
||||||
|
const force = new Force();
|
||||||
|
let frame = $state(0);
|
||||||
|
let width = $state(0);
|
||||||
|
let height = $state(0);
|
||||||
|
let k = $state(1);
|
||||||
|
let tx = $state(0);
|
||||||
|
let ty = $state(0);
|
||||||
|
let hovered = $state<string | null>(null);
|
||||||
|
let expanded = $state(false);
|
||||||
|
let touched = false;
|
||||||
|
let reduced = false;
|
||||||
|
let settling = 0;
|
||||||
|
let svg = $state<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
interface Drag {
|
||||||
|
body: Body | null;
|
||||||
|
moved: boolean;
|
||||||
|
pointer: number;
|
||||||
|
startTx: number;
|
||||||
|
startTy: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
}
|
||||||
|
let drag: Drag | null = null;
|
||||||
|
|
||||||
|
const shown = $derived.by((): GraphNode[] => {
|
||||||
|
if (expanded) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
let files = 0;
|
||||||
|
let ghosts = 0;
|
||||||
|
return nodes.filter((node) => {
|
||||||
|
if (node.kind !== "file") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (node.state === "ghost") {
|
||||||
|
ghosts += 1;
|
||||||
|
return ghosts <= SMALL_GHOSTS;
|
||||||
|
}
|
||||||
|
files += 1;
|
||||||
|
return files <= SMALL_RING1_FILES;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const byId = $derived(new Map(shown.map((node) => [node.id, node])));
|
||||||
|
const shownEdges = $derived(
|
||||||
|
edges.filter((edge) => byId.has(edge.from) && byId.has(edge.to))
|
||||||
|
);
|
||||||
|
const neighbours = $derived.by(() => {
|
||||||
|
const map = new Map<string, Set<string>>();
|
||||||
|
for (const edge of shownEdges) {
|
||||||
|
map.set(edge.from, (map.get(edge.from) ?? new Set()).add(edge.to));
|
||||||
|
map.set(edge.to, (map.get(edge.to) ?? new Set()).add(edge.from));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const ringOne = $derived(shown.filter((node) => node.ring === 1).length);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
force.sync(
|
||||||
|
shown.map((n) => ({ id: n.id, ring: n.ring })),
|
||||||
|
shownEdges
|
||||||
|
);
|
||||||
|
settling = SETTLE_FRAMES;
|
||||||
|
untrack(() => {
|
||||||
|
frame += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
let handle = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const breathe = !(reduced || drag);
|
||||||
|
if (breathe || !force.settled || settling > 0) {
|
||||||
|
force.step(breathe && force.settled);
|
||||||
|
frame += 1;
|
||||||
|
}
|
||||||
|
if (settling > 0) {
|
||||||
|
settling -= 1;
|
||||||
|
if (!touched) {
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
handle = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Positions as plain numbers, refreshed every frame: the bodies mutate in
|
||||||
|
// place, and nothing in the template would notice otherwise.
|
||||||
|
const pos = $derived.by(() => {
|
||||||
|
if (frame < 0) {
|
||||||
|
return new Map<string, { x: number; y: number }>();
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
force.bodies.map((body) => [body.id, { x: body.x, y: body.y }])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
if (width === 0 || height === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = force.bounds();
|
||||||
|
const pad = 48;
|
||||||
|
const scale = Math.min(
|
||||||
|
(width * FIT_PAD) / (b.w + pad),
|
||||||
|
(height * FIT_PAD) / (b.h + pad),
|
||||||
|
FIT_MAX
|
||||||
|
);
|
||||||
|
k = Math.max(scale, ZOOM_MIN);
|
||||||
|
tx = -(b.x + b.w / 2) * k;
|
||||||
|
ty = -(b.y + b.h / 2) * k;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refit() {
|
||||||
|
touched = false;
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer position in graph units.
|
||||||
|
function toGraph(event: PointerEvent | WheelEvent): { x: number; y: number } {
|
||||||
|
const rect = svg?.getBoundingClientRect();
|
||||||
|
const px = event.clientX - (rect?.left ?? 0) - width / 2 - tx;
|
||||||
|
const py = event.clientY - (rect?.top ?? 0) - height / 2 - ty;
|
||||||
|
return { x: px / k, y: py / k };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
touched = true;
|
||||||
|
const before = toGraph(event);
|
||||||
|
const next = Math.min(
|
||||||
|
ZOOM_MAX,
|
||||||
|
Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * ZOOM_STEP))
|
||||||
|
);
|
||||||
|
tx -= before.x * (next - k);
|
||||||
|
ty -= before.y * (next - k);
|
||||||
|
k = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(event: PointerEvent, body: Body | null) {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
svg?.setPointerCapture(event.pointerId);
|
||||||
|
drag = {
|
||||||
|
body,
|
||||||
|
moved: false,
|
||||||
|
pointer: event.pointerId,
|
||||||
|
startTx: tx,
|
||||||
|
startTy: ty,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
};
|
||||||
|
if (body) {
|
||||||
|
body.fx = body.x;
|
||||||
|
body.fy = body.y;
|
||||||
|
force.reheat(0.3);
|
||||||
|
settling = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(event: PointerEvent) {
|
||||||
|
if (!drag || drag.pointer !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = event.clientX - drag.startX;
|
||||||
|
const dy = event.clientY - drag.startY;
|
||||||
|
if (Math.abs(dx) > CLICK_SLOP || Math.abs(dy) > CLICK_SLOP) {
|
||||||
|
drag.moved = true;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (drag.body) {
|
||||||
|
const at = toGraph(event);
|
||||||
|
drag.body.fx = at.x;
|
||||||
|
drag.body.fy = at.y;
|
||||||
|
force.reheat(0.25);
|
||||||
|
} else if (drag.moved) {
|
||||||
|
tx = drag.startTx + dx;
|
||||||
|
ty = drag.startTy + dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(event: PointerEvent) {
|
||||||
|
if (!drag || drag.pointer !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { body, moved } = drag;
|
||||||
|
drag = null;
|
||||||
|
if (body) {
|
||||||
|
body.fx = null;
|
||||||
|
body.fy = null;
|
||||||
|
if (!moved) {
|
||||||
|
onOpen?.(body.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContext(event: MouseEvent, node: GraphNode) {
|
||||||
|
if (!onMenu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onMenu(node, event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && expanded) {
|
||||||
|
event.stopPropagation();
|
||||||
|
expanded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
expanded = !expanded;
|
||||||
|
touched = false;
|
||||||
|
settling = SETTLE_FRAMES / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RADIUS: Record<string, number> = {
|
||||||
|
closed: 4,
|
||||||
|
ghost: 3,
|
||||||
|
quiet: 5,
|
||||||
|
running: 7,
|
||||||
|
waiting: 6.5,
|
||||||
|
};
|
||||||
|
const FILL: Record<string, string> = {
|
||||||
|
branch: "var(--color-kind-branch)",
|
||||||
|
deep: "var(--color-kind-deep)",
|
||||||
|
file: "var(--foreground)",
|
||||||
|
fork: "var(--color-kind-fork)",
|
||||||
|
job: "var(--color-kind-job)",
|
||||||
|
master: "var(--color-kind-master)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function radius(node: GraphNode): number {
|
||||||
|
const base = RADIUS[node.state] ?? 4;
|
||||||
|
return node.ring === 0 ? base + 5 : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeOf(node: GraphNode): string {
|
||||||
|
if (node.state === "ghost") {
|
||||||
|
return "var(--muted-foreground)";
|
||||||
|
}
|
||||||
|
return node.state === "waiting" ? "var(--attention)" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillOf(node: GraphNode): string {
|
||||||
|
return node.state === "ghost"
|
||||||
|
? "var(--background)"
|
||||||
|
: (FILL[node.kind] ?? "var(--foreground)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function near(id: string): boolean {
|
||||||
|
return (
|
||||||
|
hovered === id ||
|
||||||
|
(hovered !== null && Boolean(neighbours.get(hovered)?.has(id)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels stay one screen size whatever the zoom; the ring decides
|
||||||
|
// whether a node earns one when nothing is hovered.
|
||||||
|
function labelled(node: GraphNode): boolean {
|
||||||
|
if (hovered) {
|
||||||
|
return near(node.id);
|
||||||
|
}
|
||||||
|
if (node.ring === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (node.ring === 1) {
|
||||||
|
return ringOne <= LABEL_RING1_MAX || k >= LABEL_RING1_ZOOM;
|
||||||
|
}
|
||||||
|
return k >= LABEL_RING2_ZOOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimmed(id: string): boolean {
|
||||||
|
return hovered !== null && !near(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeDimmed(edge: GraphEdge): boolean {
|
||||||
|
return hovered !== null && edge.from !== hovered && edge.to !== hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghostEdge(edge: GraphEdge): boolean {
|
||||||
|
return (
|
||||||
|
byId.get(edge.from)?.state === "ghost" ||
|
||||||
|
byId.get(edge.to)?.state === "ghost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"graph-frame relative overflow-hidden",
|
||||||
|
expanded
|
||||||
|
? "fixed inset-3 z-50 animate-island-in rounded-2xl border bg-background shadow-float sm:inset-6"
|
||||||
|
: cn("h-full w-full", className)
|
||||||
|
)}
|
||||||
|
bind:clientHeight={height}
|
||||||
|
bind:clientWidth={width}
|
||||||
|
>
|
||||||
|
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: the canvas pans and zooms; the nodes inside are links -->
|
||||||
|
<svg
|
||||||
|
aria-label="what the agent is touching"
|
||||||
|
class="block h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
|
ondblclick={refit}
|
||||||
|
onpointercancel={onUp}
|
||||||
|
onpointerdown={(event) => onDown(event, null)}
|
||||||
|
onpointermove={onMove}
|
||||||
|
onpointerup={onUp}
|
||||||
|
onwheel={onWheel}
|
||||||
|
role="img"
|
||||||
|
bind:this={svg}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="graph-glow">
|
||||||
|
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.4" />
|
||||||
|
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate({width / 2 + tx} {height / 2 + ty}) scale({k})">
|
||||||
|
{#each shownEdges as edge (edge.from + edge.to)}
|
||||||
|
{@const a = pos.get(edge.from)}
|
||||||
|
{@const b = pos.get(edge.to)}
|
||||||
|
{#if a && b}
|
||||||
|
<line
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
|
||||||
|
stroke-opacity={edgeDimmed(edge) ? 0.05 : 0.18}
|
||||||
|
stroke-width={1 / Math.sqrt(k)}
|
||||||
|
x1={a.x}
|
||||||
|
x2={b.x}
|
||||||
|
y1={a.y}
|
||||||
|
y2={b.y}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#each shown as node (node.id)}
|
||||||
|
{@const at = pos.get(node.id)}
|
||||||
|
{#if at}
|
||||||
|
{@const r = radius(node)}
|
||||||
|
{@const faded = dimmed(node.id)}
|
||||||
|
<a
|
||||||
|
class="cursor-pointer outline-none"
|
||||||
|
href={node.href ?? `#${node.id}`}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.detail === 0) {
|
||||||
|
onOpen?.(node.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
oncontextmenu={(event) => onContext(event, node)}
|
||||||
|
onpointerdown={(event) => onDown(event, force.body(node.id) ?? null)}
|
||||||
|
onpointerenter={() => {
|
||||||
|
hovered = node.id;
|
||||||
|
}}
|
||||||
|
onpointerleave={() => {
|
||||||
|
hovered = null;
|
||||||
|
}}
|
||||||
|
style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms"
|
||||||
|
>
|
||||||
|
{#if node.state === "running"}
|
||||||
|
<circle cx={at.x} cy={at.y} fill="url(#graph-glow)" r={r * 4} />
|
||||||
|
{/if}
|
||||||
|
<circle cx={at.x} cy={at.y} fill="transparent" r={r + 8} />
|
||||||
|
<circle
|
||||||
|
cx={at.x}
|
||||||
|
cy={at.y}
|
||||||
|
fill={fillOf(node)}
|
||||||
|
fill-opacity={node.state === "closed" ? 0.4 : 1}
|
||||||
|
{r}
|
||||||
|
stroke={hovered === node.id ? "var(--ring)" : strokeOf(node)}
|
||||||
|
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
|
||||||
|
stroke-width={node.state === "waiting" ? 2.5 : 1.25}
|
||||||
|
/>
|
||||||
|
{#if labelled(node)}
|
||||||
|
<text
|
||||||
|
dominant-baseline="hanging"
|
||||||
|
fill={node.state === "ghost" || node.state === "closed"
|
||||||
|
? "var(--muted-foreground)"
|
||||||
|
: "var(--foreground)"}
|
||||||
|
font-size={FONT / k}
|
||||||
|
font-weight={node.ring === 0 || hovered === node.id ? 600 : 400}
|
||||||
|
paint-order="stroke"
|
||||||
|
stroke="var(--background)"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width={3 / k}
|
||||||
|
text-anchor="middle"
|
||||||
|
x={at.x}
|
||||||
|
y={at.y + r + 3 / k}
|
||||||
|
>
|
||||||
|
{clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
aria-label="Fit the graph"
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
|
||||||
|
onclick={refit}
|
||||||
|
title="Fit (double-click the canvas)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ScanIcon class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={expanded ? "Shrink the graph" : "Expand the graph"}
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
title={expanded ? "Shrink (Esc)" : "Expand"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if expanded}
|
||||||
|
<Minimize2Icon class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<Maximize2Icon class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if !expanded && shown.length < nodes.length}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute bottom-2 left-3 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{nodes.length - shown.length}
|
||||||
|
more when expanded
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if nodes.length === 0}
|
||||||
|
<p
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Nothing reached yet.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LimitWindow } from "$lib/api/types";
|
||||||
|
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
|
||||||
|
import { limitLabel, limitWord } from "$lib/limits";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
contextTokens,
|
||||||
|
contextWindow,
|
||||||
|
windows,
|
||||||
|
spend = null,
|
||||||
|
now,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
contextTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
windows: LimitWindow[];
|
||||||
|
spend?: number | null;
|
||||||
|
now: number;
|
||||||
|
compact?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
allowed: "bg-primary",
|
||||||
|
allowed_warning: "bg-primary",
|
||||||
|
rejected: "bg-destructive",
|
||||||
|
};
|
||||||
|
const contextPct = $derived(
|
||||||
|
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
|
||||||
|
);
|
||||||
|
// The figure shown: the report's own, else the last one inside the window.
|
||||||
|
const figure = (w: LimitWindow) => w.utilization ?? w.last_known?.utilization;
|
||||||
|
const pct = (w: LimitWindow) =>
|
||||||
|
Math.min(100, Math.round((figure(w) ?? 0) * 100));
|
||||||
|
const bound = (w: LimitWindow) =>
|
||||||
|
w.utilization === null || w.utilization === undefined ? "≥ " : "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"grid gap-x-6 gap-y-4",
|
||||||
|
compact
|
||||||
|
? "@lg:grid-cols-5 grid-cols-2"
|
||||||
|
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="label-quiet">Context</span>
|
||||||
|
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
|
||||||
|
{fmtTokens(contextTokens)}
|
||||||
|
<span class="font-normal text-muted-foreground text-sm">
|
||||||
|
/ {fmtTokens(contextWindow)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label="context share"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuenow={contextPct}
|
||||||
|
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block h-full rounded-full bg-primary transition-[width] duration-500"
|
||||||
|
style="width: {contextPct}%"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#each windows as w (w.window)}
|
||||||
|
{@const known = figure(w) !== null && figure(w) !== undefined}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="label-quiet">{limitLabel(w.window)}</span>
|
||||||
|
{#if known}
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"tabular font-semibold text-2xl leading-none tracking-tight",
|
||||||
|
w.status === "rejected" && "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bound(w)}{pct(w)}
|
||||||
|
<span class="font-normal text-muted-foreground text-sm">%</span>
|
||||||
|
<span class="font-normal text-muted-foreground text-xs">
|
||||||
|
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label="{limitLabel(w.window)} utilization"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuenow={pct(w)}
|
||||||
|
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"block h-full rounded-full transition-[width] duration-500",
|
||||||
|
TONE[w.status]
|
||||||
|
)}
|
||||||
|
style="width: {pct(w)}%"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"font-semibold text-2xl leading-none tracking-tight",
|
||||||
|
w.status === "rejected" && "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{limitWord(w.status)}
|
||||||
|
<span class="font-normal text-muted-foreground text-xs">
|
||||||
|
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="tabular text-muted-foreground text-xs"
|
||||||
|
title="through the gateway this window · last report {fmtRelative(w.ts, now)}"
|
||||||
|
>
|
||||||
|
{fmtTokens(
|
||||||
|
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
||||||
|
)}
|
||||||
|
· {fmtMoney(w.gateway.cost_usd)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if spend !== null}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="label-quiet">Spend · 24 h</span>
|
||||||
|
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
|
||||||
|
{fmtMoney(spend)}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground text-xs">in API price</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fmtTokens } from "$lib/format";
|
||||||
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
|
import { ui } from "$lib/ui.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { now } from "./now.svelte";
|
||||||
|
|
||||||
|
let { class: className = "" }: { class?: string } = $props();
|
||||||
|
|
||||||
|
const running = $derived(now.running.length);
|
||||||
|
const waiting = $derived(now.waiting.length);
|
||||||
|
const worst = $derived.by(() => {
|
||||||
|
const windows = gateway.limits?.windows ?? [];
|
||||||
|
let top: number | null = null;
|
||||||
|
for (const w of windows) {
|
||||||
|
if (w.utilization !== null && w.utilization !== undefined) {
|
||||||
|
top = Math.max(top ?? 0, w.utilization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
});
|
||||||
|
const live = $derived(gateway.live.state);
|
||||||
|
const dot = $derived.by(() => {
|
||||||
|
if (live === "failed") {
|
||||||
|
return "bg-destructive";
|
||||||
|
}
|
||||||
|
if (live !== "open") {
|
||||||
|
return "bg-warn";
|
||||||
|
}
|
||||||
|
return running > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
|
||||||
|
});
|
||||||
|
const headline = $derived.by(() => {
|
||||||
|
if (live === "failed") {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
if (live !== "open") {
|
||||||
|
return "connecting";
|
||||||
|
}
|
||||||
|
if (running === 0) {
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
return running === 1 ? "1 in motion" : `${running} in motion`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-expanded={ui.island}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label="What is happening now"
|
||||||
|
class={cn("island", className)}
|
||||||
|
onclick={() => ui.toggleIsland()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
|
||||||
|
<span class="font-medium">{headline}</span>
|
||||||
|
{#if waiting > 0}
|
||||||
|
<span class="island-sep"></span>
|
||||||
|
<span class="font-medium text-attention-foreground">
|
||||||
|
{waiting === 1 ? "1 question" : `${waiting} questions`}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if now.contextTokens > 0}
|
||||||
|
<span class="island-sep hidden sm:block"></span>
|
||||||
|
<span
|
||||||
|
class="tabular hidden text-muted-foreground sm:inline"
|
||||||
|
title="master context"
|
||||||
|
>
|
||||||
|
{fmtTokens(now.contextTokens)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if worst !== null}
|
||||||
|
<span class="island-sep hidden sm:block"></span>
|
||||||
|
<span
|
||||||
|
class="tabular hidden text-muted-foreground sm:inline"
|
||||||
|
title="highest quota window"
|
||||||
|
>
|
||||||
|
{Math.round(worst * 100)}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<kbd
|
||||||
|
class="ml-1 hidden rounded border px-1 font-sans text-[10px] text-muted-foreground md:inline"
|
||||||
|
title="open the board"
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export const KIND_LETTER: Record<string, string> = {
|
||||||
|
branch: "B",
|
||||||
|
deep: "D",
|
||||||
|
fork: "F",
|
||||||
|
job: "J",
|
||||||
|
master: "M",
|
||||||
|
};
|
||||||
|
// What the letter stands for, for the tooltip and the legend.
|
||||||
|
export const KIND_MEANING: Record<string, string> = {
|
||||||
|
branch: "branch — a side thread off the master, merged back when done",
|
||||||
|
deep: "deep chat — a long thread of its own, closed with a digest",
|
||||||
|
fork: "fork — the agent's own copy of a thread, used to write a digest",
|
||||||
|
job: "job run — a scheduled or triggered task, no human in it",
|
||||||
|
master: "master — the day's main thread with the dispatcher",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Kind } from "$lib/api/types";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
branch: "text-kind-branch bg-kind-branch/12",
|
||||||
|
deep: "text-kind-deep bg-kind-deep/12",
|
||||||
|
fork: "text-kind-fork bg-kind-fork/12",
|
||||||
|
job: "text-kind-job bg-kind-job/12",
|
||||||
|
master: "text-kind-master bg-kind-master/12",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
aria-label={kind}
|
||||||
|
class={cn(
|
||||||
|
"inline-flex size-5 shrink-0 items-center justify-center rounded-full font-semibold text-[10px] leading-none",
|
||||||
|
TONE[kind] ?? "bg-muted text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="img"
|
||||||
|
title={KIND_MEANING[kind] ?? kind}
|
||||||
|
>
|
||||||
|
{KIND_LETTER[kind] ?? "?"}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ConversationInfo, ConversationSummary } from "$lib/api/types";
|
||||||
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
|
import { session } from "$lib/session.svelte";
|
||||||
|
import { byActivity } from "./state";
|
||||||
|
|
||||||
|
const CONTEXT_WINDOW = 1_000_000;
|
||||||
|
const SNAPSHOT_MIN_GAP_MS = 400;
|
||||||
|
|
||||||
|
// What the board shows: the open conversations sorted into bays, the
|
||||||
|
// open master's context, and a snapshot (tools in flight, the pending
|
||||||
|
// question) for every strip that is waiting or running.
|
||||||
|
class Now {
|
||||||
|
snapshots = $state<Record<string, ConversationInfo>>({});
|
||||||
|
private snapshotAt = 0;
|
||||||
|
private inFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
get master(): ConversationSummary | null {
|
||||||
|
return (
|
||||||
|
gateway.conversations.find(
|
||||||
|
(row) => row.kind === "master" && row.status === "open"
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get waiting(): ConversationSummary[] {
|
||||||
|
return gateway.open.filter((row) => row.pending_question).sort(byActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
get running(): ConversationSummary[] {
|
||||||
|
return gateway.open
|
||||||
|
.filter((row) => row.running_turn && !row.pending_question)
|
||||||
|
.sort(byActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
get quiet(): ConversationSummary[] {
|
||||||
|
return gateway.open
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
!(row.running_turn || row.pending_question) && row.kind !== "job"
|
||||||
|
)
|
||||||
|
.sort(byActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
get contextTokens(): number {
|
||||||
|
return this.master?.context_tokens ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contextShare(): number {
|
||||||
|
return Math.min(1, this.contextTokens / CONTEXT_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
get contextWindow(): number {
|
||||||
|
return CONTEXT_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
get liveIds(): string[] {
|
||||||
|
return [...this.waiting, ...this.running].map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One round of ``GET /api/conversations/{id}`` for every live strip,
|
||||||
|
// coalesced so a burst of turn events costs one fetch.
|
||||||
|
refreshSnapshots(): Promise<void> {
|
||||||
|
if (this.inFlight) {
|
||||||
|
return this.inFlight;
|
||||||
|
}
|
||||||
|
const gap = Date.now() - this.snapshotAt;
|
||||||
|
this.inFlight = (async () => {
|
||||||
|
if (gap < SNAPSHOT_MIN_GAP_MS) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, SNAPSHOT_MIN_GAP_MS - gap)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { client } = session;
|
||||||
|
const ids = this.liveIds;
|
||||||
|
if (!client || ids.length === 0) {
|
||||||
|
this.snapshots = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = await Promise.all(
|
||||||
|
ids.map((id) => client.conversation(id).catch(() => null))
|
||||||
|
);
|
||||||
|
const next: Record<string, ConversationInfo> = {};
|
||||||
|
for (const info of results) {
|
||||||
|
if (info) {
|
||||||
|
next[info.id] = info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.snapshots = next;
|
||||||
|
this.snapshotAt = Date.now();
|
||||||
|
})().finally(() => {
|
||||||
|
this.inFlight = null;
|
||||||
|
});
|
||||||
|
return this.inFlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const now = new Now();
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SearchIcon from "@lucide/svelte/icons/search";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { base } from "$app/paths";
|
||||||
|
import type { ConversationSummary } from "$lib/api/types";
|
||||||
|
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||||
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
|
import { SECTIONS, SYSTEM_PAGES } from "$lib/nav";
|
||||||
|
import { ui } from "$lib/ui.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import KindMark from "./kind-mark.svelte";
|
||||||
|
import { byActivity } from "./state";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
hint: string;
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
kind: "section" | "conversation";
|
||||||
|
label: string;
|
||||||
|
row?: ConversationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
let query = $state("");
|
||||||
|
let cursor = $state(0);
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const items = $derived.by((): Item[] => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
const sections: Item[] = [...SECTIONS, ...SYSTEM_PAGES]
|
||||||
|
.filter((s) => !needle || s.label.toLowerCase().includes(needle))
|
||||||
|
.map((s) => ({
|
||||||
|
hint: "section",
|
||||||
|
href: `${base}${s.href}`,
|
||||||
|
id: `s:${s.href}`,
|
||||||
|
kind: "section",
|
||||||
|
label: s.label,
|
||||||
|
}));
|
||||||
|
const conversations: Item[] = gateway.conversations
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
!needle ||
|
||||||
|
(row.title ?? "").toLowerCase().includes(needle) ||
|
||||||
|
row.id.startsWith(needle) ||
|
||||||
|
row.agent.toLowerCase().includes(needle) ||
|
||||||
|
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
.sort(byActivity)
|
||||||
|
.slice(0, needle ? LIMIT : 5)
|
||||||
|
.map((row) => ({
|
||||||
|
hint: `${row.status === "open" ? "" : `${row.status} · `}${fmtRelative(row.last_activity_at ?? row.created_at)}`,
|
||||||
|
href: `${base}/conversations/${row.id}`,
|
||||||
|
id: row.id,
|
||||||
|
kind: "conversation",
|
||||||
|
label: row.title ?? `${row.kind} · ${shortId(row.id)}`,
|
||||||
|
row,
|
||||||
|
}));
|
||||||
|
return needle
|
||||||
|
? [...conversations, ...sections]
|
||||||
|
: [...sections.slice(0, 4), ...conversations];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (ui.palette) {
|
||||||
|
query = "";
|
||||||
|
cursor = 0;
|
||||||
|
queueMicrotask(() => input?.focus());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (cursor >= items.length) {
|
||||||
|
cursor = Math.max(0, items.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function go(item: Item) {
|
||||||
|
ui.closeAll();
|
||||||
|
goto(item.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = Math.min(items.length - 1, cursor + 1);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = Math.max(0, cursor - 1);
|
||||||
|
} else if (event.key === "Enter" && items[cursor]) {
|
||||||
|
event.preventDefault();
|
||||||
|
go(items[cursor]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if ui.palette}
|
||||||
|
<div
|
||||||
|
aria-label="Go to"
|
||||||
|
aria-modal="true"
|
||||||
|
class="fixed inset-0 z-40 flex items-start justify-center px-4 pt-[12vh]"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
class="absolute inset-0 animate-fade-in bg-foreground/15 backdrop-blur-[2px]"
|
||||||
|
onclick={() => ui.closePalette()}
|
||||||
|
type="button"
|
||||||
|
></button>
|
||||||
|
<div
|
||||||
|
class="relative flex w-full max-w-lg animate-island-in flex-col overflow-hidden rounded-xl border bg-popover shadow-float"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2 border-b px-3">
|
||||||
|
<SearchIcon class="size-4 shrink-0 text-icon" />
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder="Conversation, section…"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
bind:this={input}
|
||||||
|
bind:value={query}
|
||||||
|
>
|
||||||
|
<kbd
|
||||||
|
class="rounded border px-1 font-sans text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
esc
|
||||||
|
</kbd>
|
||||||
|
</label>
|
||||||
|
<div class="max-h-[50vh] overflow-y-auto py-1" role="listbox">
|
||||||
|
{#each items as item, index (item.id)}
|
||||||
|
<div
|
||||||
|
aria-selected={index === cursor}
|
||||||
|
class={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm",
|
||||||
|
index === cursor && "bg-accent"
|
||||||
|
)}
|
||||||
|
onclick={() => go(item)}
|
||||||
|
onkeydown={(event) => event.key === "Enter" && go(item)}
|
||||||
|
onmousemove={() => {
|
||||||
|
cursor = index;
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if item.row}
|
||||||
|
<KindMark kind={item.row.kind} />
|
||||||
|
{:else}
|
||||||
|
<span class="size-5 shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="min-w-0 flex-1 truncate">{clip(item.label, 80)}</span>
|
||||||
|
<span class="tabular shrink-0 text-muted-foreground text-xs">
|
||||||
|
{item.hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="px-3 py-3 text-muted-foreground text-sm">
|
||||||
|
Nothing matches.
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ConversationSummary } from "$lib/api/types";
|
||||||
|
|
||||||
|
export type StripState = "waiting" | "running" | "quiet" | "closed";
|
||||||
|
|
||||||
|
function activityOf(row: ConversationSummary): string {
|
||||||
|
return row.last_activity_at ?? row.created_at ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byActivity(a: ConversationSummary, b: ConversationSummary) {
|
||||||
|
return activityOf(b).localeCompare(activityOf(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stateOf(row: ConversationSummary): StripState {
|
||||||
|
if (row.status !== "open") {
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
if (row.pending_question) {
|
||||||
|
return "waiting";
|
||||||
|
}
|
||||||
|
return row.running_turn ? "running" : "quiet";
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ApiClient } from "$lib/api/client";
|
||||||
|
import type { ConversationSummary } from "$lib/api/types";
|
||||||
|
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
|
||||||
|
import ConversationMenu from "$lib/panel/conversation-menu.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import KindMark from "./kind-mark.svelte";
|
||||||
|
import { type StripState, stateOf } from "./state";
|
||||||
|
|
||||||
|
let {
|
||||||
|
row,
|
||||||
|
href,
|
||||||
|
state = stateOf(row),
|
||||||
|
current = false,
|
||||||
|
open = false,
|
||||||
|
detail = "",
|
||||||
|
now = Date.now(),
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
onclick,
|
||||||
|
client = null,
|
||||||
|
onChanged,
|
||||||
|
onWarm,
|
||||||
|
}: {
|
||||||
|
row: ConversationSummary;
|
||||||
|
href?: string;
|
||||||
|
state?: StripState;
|
||||||
|
current?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
detail?: string;
|
||||||
|
now?: number;
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
onclick?: (id: string) => void;
|
||||||
|
// With a client the strip answers to the right button (and a long
|
||||||
|
// press) with the conversation's actions.
|
||||||
|
client?: ApiClient | null;
|
||||||
|
onChanged?: () => void;
|
||||||
|
// The pointer is over the strip: a good moment to fetch what it opens.
|
||||||
|
onWarm?: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const TITLE_MAX = 72;
|
||||||
|
const DETAIL_MAX = 96;
|
||||||
|
const title = $derived(
|
||||||
|
row.title
|
||||||
|
? clip(row.title, TITLE_MAX)
|
||||||
|
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
|
||||||
|
);
|
||||||
|
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
|
||||||
|
const tag = $derived(href ? "a" : "button");
|
||||||
|
|
||||||
|
function activate(event: MouseEvent) {
|
||||||
|
if (!onclick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
onclick(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function follow(id: string) {
|
||||||
|
if (onclick) {
|
||||||
|
onclick(id);
|
||||||
|
} else if (href) {
|
||||||
|
// Shared code cannot reach the router; a real anchor click can.
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = href.replace(row.id, id);
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet body(extra: Record<string, unknown>)}
|
||||||
|
<svelte:element
|
||||||
|
{...extra}
|
||||||
|
aria-current={current ? "true" : undefined}
|
||||||
|
class={cn("strip text-left text-sm", className, extra.class as string)}
|
||||||
|
data-row={row.id}
|
||||||
|
data-state={state}
|
||||||
|
href={href ?? undefined}
|
||||||
|
onclick={activate}
|
||||||
|
onpointerenter={onWarm ? () => onWarm(row.id) : undefined}
|
||||||
|
role={href ? undefined : "button"}
|
||||||
|
tabindex="0"
|
||||||
|
this={tag}
|
||||||
|
type={href ? undefined : "button"}
|
||||||
|
>
|
||||||
|
<KindMark kind={row.kind} />
|
||||||
|
<span class="flex min-w-0 flex-col leading-tight">
|
||||||
|
<span class="truncate font-medium">{title}</span>
|
||||||
|
{#if detail}
|
||||||
|
<span class="truncate text-muted-foreground text-xs">
|
||||||
|
{clip(detail, DETAIL_MAX)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="tabular flex shrink-0 items-center gap-3 text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{#if row.context_tokens}
|
||||||
|
<span title="context of the last turn"
|
||||||
|
>{fmtTokens(row.context_tokens)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="w-14 text-right">{fmtRelative(when, now)}</span>
|
||||||
|
</span>
|
||||||
|
</svelte:element>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if client}
|
||||||
|
<ConversationMenu
|
||||||
|
{client}
|
||||||
|
{current}
|
||||||
|
id={row.id}
|
||||||
|
mode="context"
|
||||||
|
onChanged={() => onChanged?.()}
|
||||||
|
onOpen={follow}
|
||||||
|
>
|
||||||
|
{#snippet trigger(props)}
|
||||||
|
{@render body(props)}
|
||||||
|
{/snippet}
|
||||||
|
</ConversationMenu>
|
||||||
|
{:else}
|
||||||
|
{@render body({})}
|
||||||
|
{/if}
|
||||||
|
{#if children && open}
|
||||||
|
<div class="strip-open">{@render children()}</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LogOutIcon from "@lucide/svelte/icons/log-out";
|
||||||
|
import MoonIcon from "@lucide/svelte/icons/moon";
|
||||||
|
import SunIcon from "@lucide/svelte/icons/sun";
|
||||||
|
import { mode, toggleMode } from "mode-watcher";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { base } from "$app/paths";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
|
||||||
|
import { session } from "$lib/session.svelte";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import Island from "./island.svelte";
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await session.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="relative z-30 grid h-12 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b bg-rack/85 px-3 backdrop-blur-md sm:px-5"
|
||||||
|
>
|
||||||
|
<nav aria-label="Sections" class="flex min-w-0 items-center gap-4">
|
||||||
|
<a
|
||||||
|
aria-label="Beaver"
|
||||||
|
class="flex size-6 shrink-0 items-center justify-center"
|
||||||
|
href="{base}/"
|
||||||
|
>
|
||||||
|
<span class="size-2.5 rounded-[3px] bg-primary"></span>
|
||||||
|
</a>
|
||||||
|
<div class="hidden items-center gap-4 sm:flex">
|
||||||
|
{#each SECTIONS as item (item.href)}
|
||||||
|
{@const active = isSectionActive(page.url.pathname, base, item.href)}
|
||||||
|
<a
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
class={cn("rule-word", active && "text-foreground")}
|
||||||
|
href="{base}{item.href}"
|
||||||
|
title="{item.label} ({item.key})"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<Island />
|
||||||
|
<div class="flex min-w-0 items-center justify-end gap-3">
|
||||||
|
<a
|
||||||
|
aria-current={isSectionActive(page.url.pathname, base, SYSTEM.href)
|
||||||
|
? "page"
|
||||||
|
: undefined}
|
||||||
|
class="rule-word hidden sm:inline"
|
||||||
|
href="{base}{SYSTEM.href}"
|
||||||
|
title="{SYSTEM.label} ({SYSTEM.key})"
|
||||||
|
>
|
||||||
|
{SYSTEM.label}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
|
||||||
|
onclick={toggleMode}
|
||||||
|
title="Toggle theme"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if mode.current === "dark"}
|
||||||
|
<SunIcon class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<MoonIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Sign out"
|
||||||
|
class="rule-word hidden size-7 items-center justify-center rounded-md sm:inline-flex"
|
||||||
|
onclick={signOut}
|
||||||
|
title="Sign out"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LogOutIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
+35
-16
@@ -1,6 +1,5 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
const NAV_KEY = "beaver.ui.nav";
|
|
||||||
const RAIL_KEY = "beaver.ui.rail";
|
const RAIL_KEY = "beaver.ui.rail";
|
||||||
|
|
||||||
function stored(key: string, fallback: boolean): boolean {
|
function stored(key: string, fallback: boolean): boolean {
|
||||||
@@ -17,28 +16,48 @@ function store(key: string, value: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chrome state: the section sidebar and the conversation rail. Explicit
|
// Chrome state: the island's board, the command palette, the conversation
|
||||||
// toggles persist; the automatic collapse on the conversations pages does
|
// rail. The rail toggle persists; the overlays never do.
|
||||||
// not, so leaving them restores what the user had.
|
|
||||||
class Ui {
|
class Ui {
|
||||||
nav = $state(stored(NAV_KEY, true));
|
|
||||||
rail = $state(stored(RAIL_KEY, true));
|
rail = $state(stored(RAIL_KEY, true));
|
||||||
|
island = $state(false);
|
||||||
setNav(open: boolean, persist = false): void {
|
palette = $state(false);
|
||||||
this.nav = open;
|
|
||||||
if (persist) {
|
|
||||||
store(NAV_KEY, open);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleNav(): void {
|
|
||||||
this.setNav(!this.nav, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleRail(): void {
|
toggleRail(): void {
|
||||||
this.rail = !this.rail;
|
this.rail = !this.rail;
|
||||||
store(RAIL_KEY, this.rail);
|
store(RAIL_KEY, this.rail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openIsland(): void {
|
||||||
|
this.palette = false;
|
||||||
|
this.island = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeIsland(): void {
|
||||||
|
this.island = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleIsland(): void {
|
||||||
|
if (this.island) {
|
||||||
|
this.closeIsland();
|
||||||
|
} else {
|
||||||
|
this.openIsland();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openPalette(): void {
|
||||||
|
this.island = false;
|
||||||
|
this.palette = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closePalette(): void {
|
||||||
|
this.palette = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAll(): void {
|
||||||
|
this.island = false;
|
||||||
|
this.palette = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ui = new Ui();
|
export const ui = new Ui();
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import { ModeWatcher } from "mode-watcher";
|
import { ModeWatcher } from "mode-watcher";
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { base } from "$app/paths";
|
import { base } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import favicon from "$lib/assets/favicon.svg";
|
import favicon from "$lib/assets/favicon.svg";
|
||||||
import AppSidebar from "$lib/components/app-sidebar.svelte";
|
|
||||||
import BottomNav from "$lib/components/bottom-nav.svelte";
|
import BottomNav from "$lib/components/bottom-nav.svelte";
|
||||||
import { Toaster } from "$lib/components/ui/sonner";
|
import { Toaster } from "$lib/components/ui/sonner";
|
||||||
import * as Tooltip from "$lib/components/ui/tooltip";
|
import * as Tooltip from "$lib/components/ui/tooltip";
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
|
import { SECTIONS, SYSTEM } from "$lib/nav";
|
||||||
import { session } from "$lib/session.svelte";
|
import { session } from "$lib/session.svelte";
|
||||||
|
import Board from "$lib/shell/board.svelte";
|
||||||
|
import Palette from "$lib/shell/palette.svelte";
|
||||||
|
import TopBar from "$lib/shell/top-bar.svelte";
|
||||||
import { ui } from "$lib/ui.svelte";
|
import { ui } from "$lib/ui.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
||||||
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
|
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
|
||||||
const inConversations = $derived(
|
const isHome = $derived(
|
||||||
page.url.pathname.startsWith(`${base}/conversations`)
|
page.url.pathname === `${base}/` || page.url.pathname === base
|
||||||
);
|
);
|
||||||
let navBefore: boolean | null = null;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!isPanel) {
|
if (!isPanel) {
|
||||||
@@ -48,23 +50,47 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (inConversations) {
|
if (page.url.pathname) {
|
||||||
untrack(() => {
|
ui.closeAll();
|
||||||
if (navBefore === null) {
|
|
||||||
navBefore = ui.nav;
|
|
||||||
ui.setNav(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (navBefore !== null) {
|
|
||||||
ui.setNav(navBefore);
|
|
||||||
navBefore = null;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function typing(target: EventTarget | null): boolean {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
return Boolean(
|
||||||
|
el &&
|
||||||
|
(el.tagName === "INPUT" ||
|
||||||
|
el.tagName === "TEXTAREA" ||
|
||||||
|
el.isContentEditable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
function onKeydown(event: KeyboardEvent) {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
ui.toggleNav();
|
if (ui.palette) {
|
||||||
|
ui.closePalette();
|
||||||
|
} else {
|
||||||
|
ui.openPalette();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape" && (ui.island || ui.palette)) {
|
||||||
|
ui.closeAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.altKey ||
|
||||||
|
typing(event.target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const section = [...SECTIONS, SYSTEM].find((s) => s.key === event.key);
|
||||||
|
if (section) {
|
||||||
|
event.preventDefault();
|
||||||
|
goto(`${base}${section.href}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -80,13 +106,34 @@
|
|||||||
{#if isLogin || isPanel}
|
{#if isLogin || isPanel}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if session.ready && session.user}
|
{:else if session.ready && session.user}
|
||||||
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
|
<div class="flex h-dvh flex-col overflow-hidden">
|
||||||
<AppSidebar />
|
<TopBar />
|
||||||
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||||
{@render children()}
|
<main class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
</main>
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
{#if ui.island && !isHome}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-20 flex items-start justify-center px-3 pt-3 sm:px-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Close the board"
|
||||||
|
class="absolute inset-0 animate-fade-in bg-foreground/10 backdrop-blur-[1.5px]"
|
||||||
|
onclick={() => ui.closeIsland()}
|
||||||
|
type="button"
|
||||||
|
></button>
|
||||||
|
<section
|
||||||
|
aria-label="Now"
|
||||||
|
class="relative max-h-full w-full max-w-4xl animate-island-in overflow-y-auto rounded-2xl border bg-background p-5 shadow-float sm:p-6"
|
||||||
|
>
|
||||||
|
<Board compact />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
|
<Palette />
|
||||||
{:else if session.ready && session.error}
|
{:else if session.ready && session.error}
|
||||||
<div class="flex h-dvh items-center justify-center p-6 text-sm">
|
<div class="flex h-dvh items-center justify-center p-6 text-sm">
|
||||||
<p class="text-destructive">{session.error}</p>
|
<p class="text-destructive">{session.error}</p>
|
||||||
|
|||||||
+3
-388
@@ -1,396 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import Board from "$lib/shell/board.svelte";
|
||||||
import { base } from "$app/paths";
|
|
||||||
import type {
|
|
||||||
AgentsResponse,
|
|
||||||
SessionsResponse,
|
|
||||||
UsageResponse,
|
|
||||||
} from "$lib/api/types";
|
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
|
||||||
import Endpoints from "$lib/components/endpoints.svelte";
|
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
|
||||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
|
||||||
import LimitBar from "$lib/components/limit-bar.svelte";
|
|
||||||
import PageHeader from "$lib/components/page-header.svelte";
|
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
|
||||||
import {
|
|
||||||
cacheShare,
|
|
||||||
elapsedMs,
|
|
||||||
fmtBytes,
|
|
||||||
fmtDuration,
|
|
||||||
fmtMoney,
|
|
||||||
fmtPct,
|
|
||||||
fmtSeconds,
|
|
||||||
fmtTime,
|
|
||||||
fmtTokens,
|
|
||||||
shortId,
|
|
||||||
} from "$lib/format";
|
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
|
||||||
import { session } from "$lib/session.svelte";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
|
|
||||||
const HOURS_5H = 5;
|
|
||||||
const HOURS_WEEK = 168;
|
|
||||||
const SESSIONS_POLL_MS = 10_000;
|
|
||||||
const TICK_MS = 1000;
|
|
||||||
|
|
||||||
let sessions = $state<SessionsResponse | null>(null);
|
|
||||||
let usage5h = $state<UsageResponse | null>(null);
|
|
||||||
let usageWeek = $state<UsageResponse | null>(null);
|
|
||||||
let agents = $state<AgentsResponse | null>(null);
|
|
||||||
let failure = $state<string | null>(null);
|
|
||||||
let now = $state(Date.now());
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const { client } = session;
|
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
failure = null;
|
|
||||||
try {
|
|
||||||
[sessions, usage5h, usageWeek, agents] = await Promise.all([
|
|
||||||
client.sessions(),
|
|
||||||
client.usage({ group_by: "agent", hours: HOURS_5H }),
|
|
||||||
client.usage({ group_by: "agent", hours: HOURS_WEEK }),
|
|
||||||
client.agents(),
|
|
||||||
]);
|
|
||||||
await gateway.refreshLimits();
|
|
||||||
} catch (cause) {
|
|
||||||
failure = cause instanceof Error ? cause.message : String(cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollSessions() {
|
|
||||||
const { client } = session;
|
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sessions = await client.sessions().catch(() => sessions);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
load();
|
|
||||||
const poll = setInterval(pollSessions, SESSIONS_POLL_MS);
|
|
||||||
const tick = setInterval(() => {
|
|
||||||
now = Date.now();
|
|
||||||
}, TICK_MS);
|
|
||||||
return () => {
|
|
||||||
clearInterval(poll);
|
|
||||||
clearInterval(tick);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const running = $derived(gateway.running);
|
|
||||||
const subtitle = $derived.by(() => {
|
|
||||||
if (running.length > 0) {
|
|
||||||
return `${running.length} running`;
|
|
||||||
}
|
|
||||||
return gateway.loaded ? "idle" : "";
|
|
||||||
});
|
|
||||||
const windows = $derived(gateway.limits?.windows ?? []);
|
|
||||||
const tape = $derived(gateway.tape.slice(0, 40));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Now · Beaver</title></svelte:head>
|
<svelte:head><title>Now · Beaver</title></svelte:head>
|
||||||
|
|
||||||
<PageHeader {subtitle} title="Now" />
|
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div class="flex flex-col gap-8 px-4 py-4 sm:px-6">
|
<div class="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6">
|
||||||
{#if failure}
|
<Board />
|
||||||
<ErrorNote message={failure} retry={load} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Running
|
|
||||||
</h2>
|
|
||||||
{#if !gateway.loaded}
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
{:else if running.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
hint="Nothing is in a turn. The ledger fills in the moment a message or an inject lands."
|
|
||||||
title="The agents are idle"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<ul class="flex flex-col">
|
|
||||||
{#each running as row (row.id)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)_auto] border-b py-2 text-sm"
|
|
||||||
href="{base}/conversations/{row.id}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="size-2 animate-pulse-dot rounded-full bg-signal"
|
|
||||||
></span>
|
|
||||||
<KindBadge kind={row.kind} />
|
|
||||||
<span class="flex min-w-0 flex-col">
|
|
||||||
<span class="truncate font-medium">
|
|
||||||
{row.title || `${row.kind} ${shortId(row.id)}`}
|
|
||||||
</span>
|
|
||||||
<span class="truncate text-muted-foreground text-xs">
|
|
||||||
{row.agent}
|
|
||||||
{row.pending_question ? " · waiting for an answer" : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="tabular text-muted-foreground text-xs">
|
|
||||||
{fmtDuration(elapsedMs(row.last_activity_at ?? "", null, now))}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Subscription quota
|
|
||||||
</h2>
|
|
||||||
{#if windows.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
hint="The SDK reports a window the first time its state changes; until then there is nothing to show."
|
|
||||||
title="No rate-limit reports yet"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col divide-y">
|
|
||||||
{#each windows as w (w.window)}
|
|
||||||
<LimitBar {now} window={w} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Spend via gateway
|
|
||||||
</h2>
|
|
||||||
{#if usage5h && usageWeek}
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-muted-foreground text-xs">
|
|
||||||
<tr class="border-b text-left">
|
|
||||||
<th class="py-1.5 pr-3 font-medium">range</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">cost</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">in / out</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">cache share</th>
|
|
||||||
<th class="py-1.5 text-right font-medium">written</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each [["last 5 h", usage5h.total], ["last 7 d", usageWeek.total]] as [label, total] (label)}
|
|
||||||
{@const t = total as UsageResponse["total"]}
|
|
||||||
{@const share = cacheShare(t.input, t.cache_read)}
|
|
||||||
<tr class="border-b">
|
|
||||||
<td class="py-1.5 pr-3 font-medium">{label}</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right font-semibold">
|
|
||||||
{fmtMoney(t.cost_usd)}
|
|
||||||
</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right">{t.turns}</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right">
|
|
||||||
{fmtTokens(t.input)}
|
|
||||||
/ {fmtTokens(t.output)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class={cn("tabular py-1.5 pr-3 text-right", share !== null && share < 0.5 && t.turns > 0 && "text-warn")}
|
|
||||||
>
|
|
||||||
{fmtPct(share)}
|
|
||||||
</td>
|
|
||||||
<td class="tabular py-1.5 text-right">
|
|
||||||
{fmtTokens(t.cache_creation)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<a class="text-link text-xs hover:underline" href="{base}/usage">
|
|
||||||
Full breakdown →
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<Skeleton class="h-32 w-full" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="flex flex-col gap-2">
|
|
||||||
<div class="flex items-baseline gap-3">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Live sessions
|
|
||||||
</h2>
|
|
||||||
{#if sessions}
|
|
||||||
<span class="tabular text-muted-foreground text-xs">
|
|
||||||
{sessions.sessions.length}
|
|
||||||
processes · {fmtBytes(sessions.rss)}
|
|
||||||
{#if sessions.rss_limit}
|
|
||||||
of {fmtBytes(sessions.rss_limit)}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if !sessions}
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
{:else if sessions.sessions.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
hint="Sessions spawn on the first turn and are reaped by idle time or memory pressure."
|
|
||||||
title="No claude processes alive"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-muted-foreground text-xs">
|
|
||||||
<tr class="border-b text-left">
|
|
||||||
<th class="py-1.5 pr-3 font-medium">agent</th>
|
|
||||||
<th class="py-1.5 pr-3 font-medium">kind</th>
|
|
||||||
<th class="py-1.5 pr-3 font-medium">state</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">rss</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">idle</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">age</th>
|
|
||||||
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
|
|
||||||
<th class="py-1.5 font-medium">conversation</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each sessions.sessions as s (s.key)}
|
|
||||||
<tr class="row-hover border-b">
|
|
||||||
<td class="py-1.5 pr-3 font-medium">{s.agent}</td>
|
|
||||||
<td class="py-1.5 pr-3"><KindBadge kind={s.kind} /></td>
|
|
||||||
<td class="py-1.5 pr-3 text-xs">
|
|
||||||
<span
|
|
||||||
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
|
|
||||||
>
|
|
||||||
{s.busy ? "busy" : "idle"}
|
|
||||||
</span>
|
|
||||||
{#if s.pinned}
|
|
||||||
<span class="ml-1 text-muted-foreground">pinned</span>
|
|
||||||
{/if}
|
|
||||||
{#if s.dirty}
|
|
||||||
<span class="ml-1 text-warn">dirty</span>
|
|
||||||
{/if}
|
|
||||||
{#if s.pending_question}
|
|
||||||
<span class="ml-1 text-link">question</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right">
|
|
||||||
{fmtBytes(s.rss)}
|
|
||||||
</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right">
|
|
||||||
{fmtSeconds(s.idle_seconds)}
|
|
||||||
</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right">
|
|
||||||
{fmtSeconds(s.age_seconds)}
|
|
||||||
</td>
|
|
||||||
<td class="tabular py-1.5 pr-3 text-right">{s.turns}</td>
|
|
||||||
<td class="tabular py-1.5 text-xs">
|
|
||||||
<a
|
|
||||||
class="text-link hover:underline"
|
|
||||||
href="{base}/conversations/{s.key}"
|
|
||||||
>
|
|
||||||
{shortId(s.key)}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid gap-6 lg:grid-cols-2">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Agents
|
|
||||||
</h2>
|
|
||||||
{#if agents}
|
|
||||||
<ul class="flex flex-col">
|
|
||||||
{#each agents.agents as a (a.name)}
|
|
||||||
<li
|
|
||||||
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<span class="flex min-w-0 flex-col">
|
|
||||||
<span class="truncate font-medium">{a.name}</span>
|
|
||||||
<span class="truncate text-muted-foreground text-xs">
|
|
||||||
{a.model}{a.effort ? ` · ${a.effort}` : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex gap-1">
|
|
||||||
{#each a.kinds as k (k)}
|
|
||||||
<KindBadge kind={k} />
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<Skeleton class="h-20 w-full" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Endpoints
|
|
||||||
</h2>
|
|
||||||
{#if agents}
|
|
||||||
<Endpoints catalog={agents} />
|
|
||||||
{:else}
|
|
||||||
<Skeleton class="h-20 w-full" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
Event tape
|
|
||||||
</h2>
|
|
||||||
{#if tape.length === 0}
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
Quiet. Bus events (turns, tools, injects, questions) scroll here as
|
|
||||||
they happen.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="flex flex-col font-mono text-xs">
|
|
||||||
{#each tape as item (item.seq)}
|
|
||||||
<li class="ledger-grid grid-cols-[5rem_8rem_minmax(0,1fr)] py-0.5">
|
|
||||||
<span class="tabular text-muted-foreground"
|
|
||||||
>{fmtTime(item.ts)}</span
|
|
||||||
>
|
|
||||||
<span class="truncate">{item.type}</span>
|
|
||||||
<span class="truncate text-muted-foreground">
|
|
||||||
{#if item.conversation_id}
|
|
||||||
<a
|
|
||||||
class="hover:underline"
|
|
||||||
href="{base}/conversations/{item.conversation_id}"
|
|
||||||
>
|
|
||||||
{shortId(item.conversation_id)}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{#if typeof item.name === "string"}
|
|
||||||
{item.name}
|
|
||||||
{/if}
|
|
||||||
{#if typeof item.text === "string"}
|
|
||||||
{item.text.slice(0, 80)}
|
|
||||||
{/if}
|
|
||||||
{#if typeof item.stop === "string"}
|
|
||||||
{item.stop}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import type { AuditRecord } from "$lib/api/types";
|
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
|
||||||
import PageHeader from "$lib/components/page-header.svelte";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
|
||||||
import { fmtDateTime } from "$lib/format";
|
|
||||||
import { session } from "$lib/session.svelte";
|
|
||||||
|
|
||||||
const PAGE = 100;
|
|
||||||
let records = $state<AuditRecord[] | null>(null);
|
|
||||||
let nextBefore = $state<number | null>(null);
|
|
||||||
let failure = $state<string | null>(null);
|
|
||||||
let busy = $state(false);
|
|
||||||
|
|
||||||
async function load(more = false) {
|
|
||||||
const { client } = session;
|
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
failure = null;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
const page = await client.audit({
|
|
||||||
before: more && nextBefore ? nextBefore : undefined,
|
|
||||||
limit: PAGE,
|
|
||||||
});
|
|
||||||
records = more ? [...(records ?? []), ...page.records] : page.records;
|
|
||||||
nextBefore = page.next_before;
|
|
||||||
} catch (cause) {
|
|
||||||
failure = cause instanceof Error ? cause.message : String(cause);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
|
|
||||||
function detail(record: AuditRecord): string {
|
|
||||||
if (typeof record.detail === "string") {
|
|
||||||
return record.detail;
|
|
||||||
}
|
|
||||||
return Object.entries(record.detail)
|
|
||||||
.map(
|
|
||||||
([key, value]) =>
|
|
||||||
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`
|
|
||||||
)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>Audit · Beaver</title></svelte:head>
|
|
||||||
|
|
||||||
<PageHeader title="Audit" />
|
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-3 px-4 py-4 sm:px-6">
|
|
||||||
{#if failure}
|
|
||||||
<ErrorNote message={failure} retry={() => load()} />
|
|
||||||
{:else if records === null}
|
|
||||||
<Skeleton class="h-40 w-full" />
|
|
||||||
{:else if records.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
hint="Logins, token changes and API writes land here."
|
|
||||||
title="Nothing audited yet"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<ul class="flex flex-col text-xs">
|
|
||||||
{#each records as record (record.id)}
|
|
||||||
<li
|
|
||||||
class="ledger-grid grid-cols-[9rem_7rem_9rem_minmax(0,1fr)] border-b py-1.5"
|
|
||||||
>
|
|
||||||
<span class="tabular text-muted-foreground"
|
|
||||||
>{fmtDateTime(record.ts)}</span
|
|
||||||
>
|
|
||||||
<span class="truncate font-medium">{record.kind}</span>
|
|
||||||
<span class="truncate text-muted-foreground">{record.actor}</span>
|
|
||||||
<span class="truncate font-mono text-muted-foreground">
|
|
||||||
{#if record.agent}
|
|
||||||
{record.agent}
|
|
||||||
·
|
|
||||||
{/if}
|
|
||||||
{detail(record)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{#if nextBefore !== null}
|
|
||||||
<Button
|
|
||||||
class="self-start"
|
|
||||||
disabled={busy}
|
|
||||||
onclick={() => load(true)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Older
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
|
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { base } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
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 { gateway } from "$lib/gateway.svelte";
|
||||||
|
import Rail from "$lib/rail/rail.svelte";
|
||||||
|
import { session } from "$lib/session.svelte";
|
||||||
import { ui } from "$lib/ui.svelte";
|
import { ui } from "$lib/ui.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const selected = $derived(page.params.id ?? null);
|
const selected = $derived(page.params.id ?? null);
|
||||||
const running = $derived(gateway.running.length);
|
const href = (id: string) => `${base}/conversations/${id}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Conversations · Beaver</title></svelte:head>
|
<svelte:head><title>Conversations · Beaver</title></svelte:head>
|
||||||
@@ -20,39 +22,42 @@
|
|||||||
"grid min-h-0 flex-1 grid-cols-1",
|
"grid min-h-0 flex-1 grid-cols-1",
|
||||||
ui.rail
|
ui.rail
|
||||||
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
|
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
|
||||||
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
|
: "lg:grid-cols-[2.5rem_minmax(0,1fr)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"min-h-0 border-r bg-sidebar/40",
|
"min-h-0 border-r",
|
||||||
selected ? "hidden lg:block" : "block"
|
selected ? "hidden lg:block" : "block"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{#if ui.rail}
|
{#if ui.rail}
|
||||||
<ConversationList {selected} />
|
{#if session.client}
|
||||||
|
<Rail
|
||||||
|
client={session.client}
|
||||||
|
{href}
|
||||||
|
index={gateway}
|
||||||
|
onOpenFile={(path) =>
|
||||||
|
goto(`${base}/memory?path=${encodeURIComponent(path)}`)}
|
||||||
|
{selected}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
|
<div class="hidden h-full flex-col items-center py-2 lg:flex">
|
||||||
<Button
|
<button
|
||||||
aria-label="Show conversations"
|
aria-label="Show conversations"
|
||||||
|
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
|
||||||
onclick={() => ui.toggleRail()}
|
onclick={() => ui.toggleRail()}
|
||||||
size="icon-sm"
|
|
||||||
title="Show conversations"
|
title="Show conversations"
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
>
|
||||||
<PanelLeftOpenIcon class="size-4" />
|
<PanelLeftOpenIcon class="size-4" />
|
||||||
</Button>
|
</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>
|
||||||
<div class="h-full lg:hidden">
|
<div class="h-full lg:hidden">
|
||||||
<ConversationList {selected} />
|
{#if session.client}
|
||||||
|
<Rail client={session.client} {href} index={gateway} {selected} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { gateway } from "$lib/gateway.svelte";
|
import { gateway } from "$lib/gateway.svelte";
|
||||||
|
import KindMark, { KIND_MEANING } from "$lib/shell/kind-mark.svelte";
|
||||||
|
|
||||||
|
const KINDS = ["master", "branch", "deep", "fork", "job"];
|
||||||
const running = $derived(gateway.running.length);
|
const running = $derived(gateway.running.length);
|
||||||
|
|
||||||
|
function meaning(kind: string): string {
|
||||||
|
return KIND_MEANING[kind]?.split(" — ")[1] ?? "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-6">
|
||||||
class="flex flex-1 items-center justify-center p-6 text-muted-foreground text-sm"
|
<p class="text-center text-muted-foreground text-sm">
|
||||||
>
|
Pick a strip on the left.
|
||||||
{#if running > 0}
|
{#if running > 0}
|
||||||
{running}
|
<span class="text-signal">{running} in motion right now.</span>
|
||||||
running - pick one on the left.
|
{/if}
|
||||||
{:else}
|
</p>
|
||||||
Pick a conversation on the left.
|
<dl
|
||||||
{/if}
|
class="grid max-w-md grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-sm"
|
||||||
|
>
|
||||||
|
{#each KINDS as kind (kind)}
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
<KindMark {kind} />
|
||||||
|
<span class="font-medium">{kind}</span>
|
||||||
|
</dt>
|
||||||
|
<dd class="text-muted-foreground">{meaning(kind)}</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
<p class="max-w-md text-center text-muted-foreground text-xs">
|
||||||
|
Days go by the last word, not by the parent: an open branch follows the
|
||||||
|
master through the morning rotation. Right-click a strip for its actions.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user