feat(activity,api,settings): activity panel v0 over conversation events sse
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
PLUGIN_ID := beaver
|
||||
ASSETS := manifest.json main.js versions.json
|
||||
PM := $(shell command -v pnpm 2>/dev/null || command -v npm 2>/dev/null)
|
||||
ASSETS := manifest.json main.js styles.css versions.json
|
||||
# package-lock.json → npm; otherwise whichever of pnpm/npm is around.
|
||||
PM := $(if $(wildcard package-lock.json),$(shell command -v npm 2>/dev/null),$(shell command -v pnpm 2>/dev/null || command -v npm 2>/dev/null))
|
||||
|
||||
.PHONY: install zip _build
|
||||
|
||||
|
||||
@@ -2,20 +2,37 @@
|
||||
|
||||
[](https://sladge.net)
|
||||
|
||||
Sends the current note to Beaver's `markdown frontend` (`POST /chat` on `beaver-gateway`) and writes the agent's reply back into the file. Skips the Obsidian Sync round-trip by POSTing the buffer contents directly.
|
||||
Two things:
|
||||
|
||||
- **Send the current note** to Beaver's `markdown frontend` (`POST /chat/stream` on `beaver-gateway`) and stream the agent's reply back into the buffer. Skips the Obsidian Sync round-trip by POSTing the buffer contents directly.
|
||||
- **Activity panel** (v0): a side view that follows the active note's `conversation_id` and shows, live over SSE, what the agent is doing — tool calls, subagents as a tree, turn status, usage of each turn.
|
||||
|
||||
## Commands
|
||||
|
||||
Both commands appear in the command palette only when the active note's YAML frontmatter has an `agent:` field:
|
||||
- **Beaver: Send using selected agent** — uses `frontmatter.agent`. Only when the active note has an `agent:` field.
|
||||
- **Beaver: Send using different agent** — fetches the agent list from the gateway and opens a fuzzy picker. Same condition.
|
||||
- **Beaver: Open chat in panel** — reveals the activity panel in the right sidebar (creates it on first use) and points it at the active note.
|
||||
|
||||
- **Beaver: Send using selected agent** — uses `frontmatter.agent`.
|
||||
- **Beaver: Send using different agent** — fetches the agent list from the gateway and opens a fuzzy picker.
|
||||
## Activity panel
|
||||
|
||||
Subscribes to `GET /api/conversations/{id}/events` (SSE, bearer) where `{id}` is the `conversation_id` from the active note's frontmatter. Switching notes switches the subscription; a note without `conversation_id` shows a hint instead. On connect it fetches `GET /api/conversations/{id}` for the snapshot (title, agent, kind, status, whether a turn is running), then renders bus events:
|
||||
|
||||
- `turn.start` / `turn.end` — a turn block with status (running / done / interrupted / error), origin (`user` / `inject`), start time, duration and usage (`in · out · cache read/write · $ · time`).
|
||||
- `tool` / `tool.result` — a tool row (name, input summary, result status); clicking it expands the raw input and the result preview. Rows with `parent_tool_use_id` nest under the `Task`/`Agent` tool that spawned the subagent.
|
||||
- `stream` `content_block_start` of type `tool_use` — pre-creates the row so a long-running tool shows up before its input is complete.
|
||||
- `say` — text the dispatcher sent out of an inject turn.
|
||||
|
||||
The stream reconnects with exponential backoff (1 s → 30 s, jittered) and resets on `hello`. `401`/`403`/`404` are not retried: the panel shows the error and waits for you to fix the token or the id. When it reconnects and the snapshot says nothing is running, turns still shown as running are marked interrupted — their `turn.end` was lost with the connection.
|
||||
|
||||
Plain DOM and CSS on Obsidian's theme variables; no framework. It is the v0 of the panel from `архитектура.md` §2 — the full Svelte UI (M5) replaces it.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Base URL** — markdown-frontend root, e.g. `http://localhost:62993`.
|
||||
- **Bearer token** — a token with the `messages` scope (mint via the admin frontend or `BOOTSTRAP_TOKENS`).
|
||||
- **Test connection** — calls `GET /agents`.
|
||||
- **Base URL** — markdown-frontend root, e.g. `http://localhost:62993` or `https://host/md`.
|
||||
- **API origin** — where `/api/…` lives, e.g. `http://localhost:62994` or `https://host`. Empty = derived from Base URL (`…/md` → `…`, port `62993` → `62994`).
|
||||
- **Bearer token** — needs the `messages` scope for sending and the `api` scope for the panel; a `*` bootstrap token covers both.
|
||||
- **Vault subpath** — folder in this vault that maps to the gateway's vault root.
|
||||
- **Test connection** / **Test API** — `GET /agents` on the markdown frontend, `GET /api/agents` on the API origin.
|
||||
|
||||
## Note format
|
||||
|
||||
@@ -29,27 +46,27 @@ agent: beaver-opus-medium
|
||||
hi there
|
||||
```
|
||||
|
||||
The gateway parses `### User:` / `### Assistant:` markers; a note with no markers is treated as a single user turn. After a successful send, the gateway returns the new file content (including an `### Assistant:` block and a fresh `### User:` scaffold), and the plugin writes it back to the buffer.
|
||||
The gateway parses `### User:` / `### Assistant:` markers; a note with no markers is treated as a single user turn. After the first turn the gateway adds `conversation_id` to the frontmatter — that is what the activity panel keys off.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
make install VAULT=~/Obsidian/my-vault # build + copy manifest.json + main.js into <vault>/.obsidian/plugins/beaver
|
||||
make install VAULT=~/Obsidian/my-vault # build + copy manifest.json, main.js, styles.css, versions.json into <vault>/.obsidian/plugins/beaver
|
||||
make zip # build → beaver-plugin-<version>.zip you can carry to any vault
|
||||
```
|
||||
|
||||
Then enable **Beaver** in Settings → Community plugins.
|
||||
Then enable **Beaver** in Settings → Community plugins (or reload it if it was already enabled).
|
||||
|
||||
### Mobile
|
||||
|
||||
The plugin is desktop-and-mobile (`isDesktopOnly: false`). Two things to get right on a phone:
|
||||
|
||||
- The base URL in plugin settings has to be reachable from the phone — `http://localhost:62993` won't work; use the gateway's LAN IP, a tunnel, or a public hostname.
|
||||
- The base URL / API origin in plugin settings have to be reachable from the phone — `http://localhost:…` won't work; use the gateway's LAN IP, a tunnel, or a public hostname.
|
||||
- Get the plugin files onto the phone vault via Obsidian Sync (toggle "Installed community plugins" in your sync settings) or any file-sync tool you already use (Working Copy, Syncthing, etc.). `make install` only knows how to write to a local path.
|
||||
|
||||
### Hand-rolled
|
||||
|
||||
Three files are all Obsidian needs — `manifest.json`, `main.js`, `versions.json`. Drop them in `<vault>/.obsidian/plugins/beaver/` however you like (unzip, `scp`, `rsync`, your own git, …).
|
||||
Four files are all Obsidian needs — `manifest.json`, `main.js`, `styles.css`, `versions.json`. Drop them in `<vault>/.obsidian/plugins/beaver/` however you like (unzip, `scp`, `rsync`, your own git, …).
|
||||
|
||||
## Bumping the version
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "beaver",
|
||||
"name": "Beaver",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Send the current note to a Beaver agent via the markdown frontend.",
|
||||
"author": "h",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "beaver-plugin-obsidian",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "beaver-plugin-obsidian",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "beaver-plugin-obsidian",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Obsidian plugin for Beaver's markdown frontend.",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
import { ItemView, TFile, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import {
|
||||
BeaverApiError,
|
||||
BusEvent,
|
||||
ConversationInfo,
|
||||
getConversation,
|
||||
streamConversationEvents,
|
||||
} from "./api";
|
||||
import type BeaverPlugin from "./main";
|
||||
|
||||
export const ACTIVITY_VIEW_TYPE = "beaver-activity";
|
||||
|
||||
// ---- model ----
|
||||
|
||||
type ToolStatus = "running" | "done" | "error" | "aborted";
|
||||
|
||||
export interface ToolNode {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
parent: string | null;
|
||||
status: ToolStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
result: string;
|
||||
children: ToolNode[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
type TurnStatus = "running" | "done" | "interrupted" | "error";
|
||||
|
||||
export interface TurnUsage {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_creation?: number;
|
||||
cost_usd?: number | null;
|
||||
duration_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
id: string;
|
||||
origin: string;
|
||||
status: TurnStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
usage: TurnUsage | null;
|
||||
resultSubtype: string | null;
|
||||
roots: ToolNode[];
|
||||
nodes: Map<string, ToolNode>;
|
||||
says: string[];
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
state: "off" | "connecting" | "open" | "retrying" | "failed";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const MAX_TURNS = 20;
|
||||
|
||||
// Pure state: bus events in, tree of turns → tool calls → subagent tool
|
||||
// calls out. ``apply`` returns whether anything the panel shows changed,
|
||||
// so the chatty ``stream`` deltas don't trigger re-renders.
|
||||
export class ActivityModel {
|
||||
conversation: ConversationInfo | null = null;
|
||||
turns: Turn[] = []; // newest first
|
||||
connection: Connection = { state: "off" };
|
||||
|
||||
setConversation(info: ConversationInfo): void {
|
||||
this.conversation = info;
|
||||
const now = new Date().toISOString();
|
||||
// The snapshot is the truth about what runs *now*. A turn we still
|
||||
// show as running but the server doesn't (we were disconnected when
|
||||
// it ended, or the gateway restarted) is over; its ``turn.end`` is
|
||||
// not coming.
|
||||
for (const turn of this.turns) {
|
||||
if (turn.status !== "running" || turn.id === info.running_turn) continue;
|
||||
turn.status = "interrupted";
|
||||
turn.endedAt = now;
|
||||
for (const node of turn.nodes.values()) {
|
||||
if (node.status === "running") {
|
||||
node.status = "aborted";
|
||||
node.endedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connected mid-turn: the events for this turn started before we
|
||||
// subscribed. Open the turn so its tools have somewhere to land.
|
||||
if (info.running_turn) this.ensureTurn(info.running_turn, now, null);
|
||||
}
|
||||
|
||||
apply(ev: BusEvent): boolean {
|
||||
const ts = typeof ev.ts === "string" ? ev.ts : new Date().toISOString();
|
||||
const turnId = typeof ev.turn_id === "string" ? ev.turn_id : null;
|
||||
const parent =
|
||||
typeof ev.parent_tool_use_id === "string" ? ev.parent_tool_use_id : null;
|
||||
switch (ev.type) {
|
||||
case "turn.start": {
|
||||
if (!turnId) return false;
|
||||
const turn = this.ensureTurn(turnId, ts, str(ev.origin));
|
||||
turn.status = "running";
|
||||
turn.startedAt = ts;
|
||||
return true;
|
||||
}
|
||||
case "stream": {
|
||||
// Only the opening of a tool_use block is interesting here: it
|
||||
// gives us id + name before the full ``tool`` event arrives with
|
||||
// the input, so long-running tools show up as soon as they start.
|
||||
if (!turnId) return false;
|
||||
const raw = ev.event;
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
const sdk = raw as Record<string, unknown>;
|
||||
if (sdk.type !== "content_block_start") return false;
|
||||
const block = sdk.content_block as Record<string, unknown> | undefined;
|
||||
if (!block || block.type !== "tool_use") return false;
|
||||
if (typeof block.id !== "string") return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const existed = turn.nodes.has(block.id);
|
||||
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
|
||||
return !existed;
|
||||
}
|
||||
case "tool": {
|
||||
if (!turnId || typeof ev.tool_use_id !== "string") return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const node = this.ensureNode(
|
||||
turn,
|
||||
ev.tool_use_id,
|
||||
str(ev.name) ?? "?",
|
||||
parent,
|
||||
ts,
|
||||
);
|
||||
node.input = ev.input ?? null;
|
||||
return true;
|
||||
}
|
||||
case "tool.result": {
|
||||
if (!turnId || typeof ev.tool_use_id !== "string") return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const node = this.ensureNode(turn, ev.tool_use_id, "?", parent, ts);
|
||||
node.status = ev.is_error ? "error" : "done";
|
||||
node.endedAt = ts;
|
||||
node.result = str(ev.content) ?? "";
|
||||
return true;
|
||||
}
|
||||
case "result": {
|
||||
if (!turnId) return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
turn.resultSubtype = str(ev.subtype);
|
||||
if (ev.is_error) turn.status = "error";
|
||||
return true;
|
||||
}
|
||||
case "turn.end": {
|
||||
if (!turnId) return false;
|
||||
const turn = this.ensureTurn(turnId, ts, str(ev.origin));
|
||||
const stop = str(ev.stop);
|
||||
turn.status =
|
||||
stop === "end_turn"
|
||||
? turn.status === "error"
|
||||
? "error"
|
||||
: "done"
|
||||
: stop === "interrupted"
|
||||
? "interrupted"
|
||||
: "error";
|
||||
turn.endedAt = ts;
|
||||
turn.usage =
|
||||
ev.usage && typeof ev.usage === "object"
|
||||
? (ev.usage as TurnUsage)
|
||||
: null;
|
||||
for (const node of turn.nodes.values()) {
|
||||
if (node.status === "running") {
|
||||
node.status = "aborted";
|
||||
node.endedAt = ts;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "say": {
|
||||
if (typeof ev.text !== "string") return false;
|
||||
const turn = turnId
|
||||
? this.ensureTurn(turnId, ts, null)
|
||||
: this.turns[0];
|
||||
if (!turn) return false;
|
||||
turn.says.push(ev.text);
|
||||
return true;
|
||||
}
|
||||
case "conversation.updated":
|
||||
case "conversation.created": {
|
||||
if (!this.conversation || ev.id !== this.conversation.id) return false;
|
||||
this.conversation = {
|
||||
...this.conversation,
|
||||
...(ev as unknown as Partial<ConversationInfo>),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureTurn(id: string, ts: string, origin: string | null): Turn {
|
||||
let turn = this.turns.find((t) => t.id === id);
|
||||
if (!turn) {
|
||||
turn = {
|
||||
id,
|
||||
origin: origin ?? "?",
|
||||
status: "running",
|
||||
startedAt: ts,
|
||||
endedAt: null,
|
||||
usage: null,
|
||||
resultSubtype: null,
|
||||
roots: [],
|
||||
nodes: new Map(),
|
||||
says: [],
|
||||
};
|
||||
this.turns.unshift(turn);
|
||||
if (this.turns.length > MAX_TURNS) this.turns.length = MAX_TURNS;
|
||||
} else if (origin && turn.origin === "?") {
|
||||
turn.origin = origin;
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private ensureNode(
|
||||
turn: Turn,
|
||||
id: string,
|
||||
name: string,
|
||||
parent: string | null,
|
||||
ts: string,
|
||||
): ToolNode {
|
||||
let node = turn.nodes.get(id);
|
||||
if (node) {
|
||||
if (node.name === "?" && name !== "?") node.name = name;
|
||||
return node;
|
||||
}
|
||||
node = {
|
||||
id,
|
||||
name,
|
||||
input: null,
|
||||
parent,
|
||||
status: "running",
|
||||
startedAt: ts,
|
||||
endedAt: null,
|
||||
result: "",
|
||||
children: [],
|
||||
expanded: false,
|
||||
};
|
||||
turn.nodes.set(id, node);
|
||||
if (parent) {
|
||||
// A subagent's tool call: hang it under the Task/Agent tool that
|
||||
// spawned it. Events can arrive before that tool's own ``tool``
|
||||
// event (the SDK streams the child first), so create a placeholder.
|
||||
this.ensureNode(turn, parent, "?", null, ts).children.push(node);
|
||||
} else {
|
||||
turn.roots.push(node);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
// ---- presentation helpers ----
|
||||
|
||||
function pick(input: unknown, ...keys: string[]): string | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const v = obj[key];
|
||||
if (typeof v === "string" && v.trim()) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clip(s: string, max: number): string {
|
||||
const one = s.replace(/\s+/g, " ").trim();
|
||||
return one.length > max ? one.slice(0, max - 1) + "…" : one;
|
||||
}
|
||||
|
||||
export function summarizeInput(name: string, input: unknown): string {
|
||||
if (input == null) return "";
|
||||
let s: string | null = null;
|
||||
switch (name) {
|
||||
case "Bash":
|
||||
s = pick(input, "command", "description");
|
||||
break;
|
||||
case "Read":
|
||||
case "Edit":
|
||||
case "Write":
|
||||
case "MultiEdit":
|
||||
case "NotebookEdit":
|
||||
s = pick(input, "file_path", "notebook_path");
|
||||
break;
|
||||
case "Grep":
|
||||
case "Glob":
|
||||
s = pick(input, "pattern");
|
||||
break;
|
||||
case "Task":
|
||||
case "Agent":
|
||||
s = pick(input, "description", "prompt");
|
||||
break;
|
||||
case "WebFetch":
|
||||
s = pick(input, "url");
|
||||
break;
|
||||
case "WebSearch":
|
||||
s = pick(input, "query");
|
||||
break;
|
||||
case "Skill":
|
||||
s = pick(input, "skill");
|
||||
break;
|
||||
default:
|
||||
s = null;
|
||||
}
|
||||
if (s === null) {
|
||||
if (typeof input === "object") {
|
||||
const first = Object.values(input as Record<string, unknown>).find(
|
||||
(v) => typeof v === "string" && v.trim(),
|
||||
);
|
||||
s = typeof first === "string" ? first : JSON.stringify(input);
|
||||
} else {
|
||||
s = String(input);
|
||||
}
|
||||
}
|
||||
return clip(s, 140);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.round((ms % 60_000) / 1000);
|
||||
return `${m}m${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number | undefined): string {
|
||||
if (n == null) return "-";
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
|
||||
}
|
||||
|
||||
function fmtUsage(u: TurnUsage): string {
|
||||
const parts = [
|
||||
`in ${fmtTokens(u.input)}`,
|
||||
`out ${fmtTokens(u.output)}`,
|
||||
`cache ${fmtTokens(u.cache_read)}/${fmtTokens(u.cache_creation)}`,
|
||||
];
|
||||
if (typeof u.cost_usd === "number") parts.push(`$${u.cost_usd.toFixed(4)}`);
|
||||
if (typeof u.duration_ms === "number") parts.push(fmtDuration(u.duration_ms));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function elapsed(from: string, to: string | null): number {
|
||||
const a = new Date(from).getTime();
|
||||
const b = to ? new Date(to).getTime() : Date.now();
|
||||
return Number.isNaN(a) || Number.isNaN(b) ? 0 : Math.max(0, b - a);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const timer = window.setTimeout(done, ms);
|
||||
function done() {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
const BACKOFF_MIN_MS = 1000;
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
|
||||
// ---- view ----
|
||||
|
||||
export class ActivityView extends ItemView {
|
||||
private readonly plugin: BeaverPlugin;
|
||||
private model = new ActivityModel();
|
||||
private abort: AbortController | null = null;
|
||||
private conversationId: string | null = null;
|
||||
private file: TFile | null = null;
|
||||
private root: HTMLElement | null = null;
|
||||
private renderQueued = false;
|
||||
private ticker: number | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return ACTIVITY_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return "Beaver: activity";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return "activity";
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.contentEl.empty();
|
||||
this.root = this.contentEl.createDiv({ cls: "beaver-activity" });
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-open", (file) => {
|
||||
// ``null`` means the active leaf isn't a file (e.g. this panel
|
||||
// got focus) — keep following the last note rather than drop it.
|
||||
if (file) this.follow(file);
|
||||
}),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("changed", (file) => {
|
||||
// The note we follow just got (or lost) its conversation_id —
|
||||
// typically right after the first send. Re-resolve.
|
||||
if (this.file && file.path === this.file.path) this.follow(file);
|
||||
}),
|
||||
);
|
||||
// Once a second while a turn runs, so "running for 12s" ticks.
|
||||
this.ticker = window.setInterval(() => {
|
||||
if (this.model.turns.some((t) => t.status === "running")) this.render();
|
||||
}, 1000);
|
||||
this.registerInterval(this.ticker);
|
||||
this.follow(this.app.workspace.getActiveFile());
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
this.unsubscribe();
|
||||
this.root = null;
|
||||
}
|
||||
|
||||
// Follow ``file``: subscribe to the conversation its frontmatter names,
|
||||
// or show why we can't. Cheap when nothing changed.
|
||||
follow(file: TFile | null): void {
|
||||
this.file = file;
|
||||
const id = this.plugin.conversationIdOf(file);
|
||||
if (id === this.conversationId) {
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
this.unsubscribe();
|
||||
this.conversationId = id;
|
||||
this.model = new ActivityModel();
|
||||
if (id) this.subscribe(id);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
private subscribe(id: string): void {
|
||||
const controller = new AbortController();
|
||||
this.abort = controller;
|
||||
void this.loop(id, controller.signal).catch((err) => {
|
||||
console.error("Beaver activity: subscription loop died", err);
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribe(): void {
|
||||
this.abort?.abort();
|
||||
this.abort = null;
|
||||
this.model.connection = { state: "off" };
|
||||
}
|
||||
|
||||
private async loop(id: string, signal: AbortSignal): Promise<void> {
|
||||
let delay = BACKOFF_MIN_MS;
|
||||
while (!signal.aborted) {
|
||||
this.model.connection = {
|
||||
state: delay === BACKOFF_MIN_MS ? "connecting" : "retrying",
|
||||
detail: this.model.connection.detail,
|
||||
};
|
||||
this.scheduleRender();
|
||||
try {
|
||||
// Snapshot first: status/title and, if a turn is already
|
||||
// running, its id, so events that follow land in the right place.
|
||||
const info = await getConversation(this.plugin.settings, id);
|
||||
if (signal.aborted) return;
|
||||
this.model.setConversation(info);
|
||||
this.scheduleRender();
|
||||
await streamConversationEvents(
|
||||
this.plugin.settings,
|
||||
id,
|
||||
(ev) => {
|
||||
if (ev.type === "hello") {
|
||||
delay = BACKOFF_MIN_MS;
|
||||
this.model.connection = { state: "open" };
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
if (this.model.apply(ev)) this.scheduleRender();
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
this.model.connection = { state: "retrying", detail: "stream closed" };
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
if (
|
||||
err instanceof BeaverApiError &&
|
||||
(err.status === 401 || err.status === 403 || err.status === 404)
|
||||
) {
|
||||
// Not transient: wrong token/scope or unknown conversation.
|
||||
// Retrying would only spam the gateway; the user has to act.
|
||||
this.model.connection = {
|
||||
state: "failed",
|
||||
detail: `${err.status}: ${err.message}`,
|
||||
};
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
this.model.connection = {
|
||||
state: "retrying",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
this.scheduleRender();
|
||||
await sleep(delay + Math.random() * 250, signal);
|
||||
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.renderQueued) return;
|
||||
this.renderQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.renderQueued = false;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const root = this.root;
|
||||
if (!root) return;
|
||||
root.empty();
|
||||
this.renderHeader(root);
|
||||
const body = root.createDiv({ cls: "beaver-activity-body" });
|
||||
if (!this.file) {
|
||||
body.createDiv({
|
||||
cls: "beaver-activity-empty",
|
||||
text: "Open a note with conversation_id in its frontmatter.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.conversationId) {
|
||||
body.createDiv({
|
||||
cls: "beaver-activity-empty",
|
||||
text: `«${this.file.basename}» has no conversation_id in its frontmatter.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.model.turns.length === 0) {
|
||||
body.createDiv({
|
||||
cls: "beaver-activity-empty",
|
||||
text:
|
||||
this.model.connection.state === "open"
|
||||
? "No activity yet - waiting for the next turn."
|
||||
: "Connecting…",
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const turn of this.model.turns) this.renderTurn(body, turn);
|
||||
}
|
||||
|
||||
private renderHeader(root: HTMLElement): void {
|
||||
const head = root.createDiv({ cls: "beaver-activity-head" });
|
||||
const conv = this.model.conversation;
|
||||
const title = head.createDiv({ cls: "beaver-activity-title" });
|
||||
title.createSpan({
|
||||
text:
|
||||
conv?.title ||
|
||||
this.file?.basename ||
|
||||
this.conversationId ||
|
||||
"Beaver: activity",
|
||||
});
|
||||
const conn = this.model.connection;
|
||||
const dot = title.createSpan({
|
||||
cls: `beaver-activity-conn is-${conn.state}`,
|
||||
attr: { "aria-label": conn.detail ?? conn.state, title: conn.detail ?? conn.state },
|
||||
});
|
||||
dot.setText(conn.state);
|
||||
|
||||
const meta = head.createDiv({ cls: "beaver-activity-meta" });
|
||||
if (conv) {
|
||||
meta.createSpan({ text: conv.agent });
|
||||
meta.createSpan({ text: conv.kind });
|
||||
meta.createSpan({ text: conv.status });
|
||||
if (conv.running_turn) meta.createSpan({ cls: "is-running", text: "turn running" });
|
||||
if (conv.pending_question) meta.createSpan({ cls: "is-pending", text: "question pending" });
|
||||
} else if (this.conversationId) {
|
||||
meta.createSpan({ text: this.conversationId });
|
||||
}
|
||||
if (conn.state === "failed" || conn.state === "retrying") {
|
||||
head.createDiv({
|
||||
cls: `beaver-activity-note is-${conn.state}`,
|
||||
text: conn.detail ?? conn.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderTurn(parent: HTMLElement, turn: Turn): void {
|
||||
const el = parent.createDiv({ cls: `beaver-activity-turn is-${turn.status}` });
|
||||
const line = el.createDiv({ cls: "beaver-activity-turn-line" });
|
||||
line.createSpan({ cls: "beaver-activity-status", text: turn.status });
|
||||
line.createSpan({ cls: "beaver-activity-origin", text: turn.origin });
|
||||
line.createSpan({ cls: "beaver-activity-time", text: fmtTime(turn.startedAt) });
|
||||
const dur = turn.usage?.duration_ms ?? elapsed(turn.startedAt, turn.endedAt);
|
||||
line.createSpan({ cls: "beaver-activity-duration", text: fmtDuration(dur) });
|
||||
if (turn.usage) {
|
||||
el.createDiv({ cls: "beaver-activity-usage", text: fmtUsage(turn.usage) });
|
||||
}
|
||||
for (const text of turn.says) {
|
||||
const say = el.createDiv({ cls: "beaver-activity-say" });
|
||||
const icon = say.createSpan({ cls: "beaver-activity-icon" });
|
||||
setIcon(icon, "message-square");
|
||||
say.createSpan({ text: clip(text, 300) });
|
||||
}
|
||||
if (turn.roots.length > 0) {
|
||||
const tree = el.createDiv({ cls: "beaver-activity-tree" });
|
||||
for (const node of turn.roots) this.renderNode(tree, node);
|
||||
}
|
||||
}
|
||||
|
||||
private renderNode(parent: HTMLElement, node: ToolNode): void {
|
||||
const el = parent.createDiv({ cls: `beaver-activity-tool is-${node.status}` });
|
||||
const row = el.createDiv({ cls: "beaver-activity-tool-row" });
|
||||
row.createSpan({ cls: "beaver-activity-dot" });
|
||||
row.createSpan({ cls: "beaver-activity-tool-name", text: node.name });
|
||||
const summary = summarizeInput(node.name, node.input);
|
||||
if (summary) row.createSpan({ cls: "beaver-activity-tool-summary", text: summary });
|
||||
const dur = elapsed(node.startedAt, node.endedAt);
|
||||
if (node.status !== "running" || dur >= 2000) {
|
||||
row.createSpan({ cls: "beaver-activity-duration", text: fmtDuration(dur) });
|
||||
}
|
||||
row.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
node.expanded = !node.expanded;
|
||||
this.render();
|
||||
});
|
||||
if (node.expanded) {
|
||||
const details = el.createDiv({ cls: "beaver-activity-tool-details" });
|
||||
if (node.input != null) {
|
||||
details.createEl("pre", {
|
||||
text: JSON.stringify(node.input, null, 2),
|
||||
});
|
||||
}
|
||||
if (node.result) {
|
||||
details.createEl("pre", { cls: "is-result", text: node.result });
|
||||
}
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
const kids = el.createDiv({ cls: "beaver-activity-children" });
|
||||
for (const child of node.children) this.renderNode(kids, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
-108
@@ -1,5 +1,6 @@
|
||||
import { requestUrl, RequestUrlParam } from "obsidian";
|
||||
import type { BeaverSettings } from "./settings";
|
||||
import { readSse } from "./sse";
|
||||
|
||||
export interface ChatRequest {
|
||||
filename: string;
|
||||
@@ -25,53 +26,89 @@ export class BeaverApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(settings: BeaverSettings): string {
|
||||
// Two hosts, one token. ``mdBase`` is the markdown frontend (``/chat``,
|
||||
// ``/agents``); ``apiBase`` is the origin under which the conversations
|
||||
// API lives (``<apiBase>/api/conversations/…``). Behind the gateway's
|
||||
// Caddy the markdown root is ``https://host/md`` and the API root is the
|
||||
// bare ``https://host``, so when the API origin isn't set explicitly we
|
||||
// derive it: strip a trailing ``/md``, or bump the default local port.
|
||||
export function mdBase(settings: BeaverSettings): string {
|
||||
const url = settings.baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!url) throw new Error("Beaver: base URL is not configured");
|
||||
return url;
|
||||
}
|
||||
|
||||
export function apiBase(settings: BeaverSettings): string {
|
||||
const explicit = settings.apiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (explicit) return explicit;
|
||||
const md = mdBase(settings);
|
||||
if (md.endsWith("/md")) return md.slice(0, -"/md".length);
|
||||
if (/:62993$/.test(md)) return md.replace(/:62993$/, ":62994");
|
||||
throw new Error(
|
||||
`Beaver: API origin is not configured and can't be derived from ${md}`,
|
||||
);
|
||||
}
|
||||
|
||||
function authHeader(settings: BeaverSettings): Record<string, string> {
|
||||
const token = settings.token.trim();
|
||||
if (!token) throw new Error("Beaver: bearer token is not configured");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
// FastAPI's default error body is ``{detail: …}``; the API frontend
|
||||
// rewrites it to ``{error: …}``. Accept both, fall back to the raw text.
|
||||
function errorMessage(status: number, body: unknown): string {
|
||||
if (body && typeof body === "object") {
|
||||
const obj = body as Record<string, unknown>;
|
||||
for (const key of ["detail", "error"]) {
|
||||
if (typeof obj[key] === "string") return obj[key] as string;
|
||||
}
|
||||
}
|
||||
if (typeof body === "string" && body) return body;
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
async function call(
|
||||
settings: BeaverSettings,
|
||||
init: Omit<RequestUrlParam, "url"> & { path: string },
|
||||
init: Omit<RequestUrlParam, "url"> & { url: string },
|
||||
): Promise<unknown> {
|
||||
const { path, ...rest } = init;
|
||||
const url = `${baseUrl(settings)}${path}`;
|
||||
const headers = {
|
||||
...authHeader(settings),
|
||||
...(rest.headers ?? {}),
|
||||
...(init.headers ?? {}),
|
||||
};
|
||||
// throw=false → we handle non-2xx ourselves so we can extract FastAPI's
|
||||
// {detail: "..."} body and surface a useful message.
|
||||
const res = await requestUrl({ url, throw: false, ...rest, headers });
|
||||
// throw=false → we handle non-2xx ourselves so we can extract the
|
||||
// error body and surface a useful message.
|
||||
const res = await requestUrl({ throw: false, ...init, headers });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
let detail: unknown;
|
||||
let body: unknown;
|
||||
try {
|
||||
detail = res.json;
|
||||
body = res.json;
|
||||
} catch {
|
||||
detail = res.text;
|
||||
body = res.text;
|
||||
}
|
||||
const detailMsg =
|
||||
detail && typeof detail === "object" && "detail" in detail
|
||||
? String((detail as { detail: unknown }).detail)
|
||||
: typeof detail === "string"
|
||||
? detail
|
||||
: `HTTP ${res.status}`;
|
||||
throw new BeaverApiError(res.status, detailMsg, detail);
|
||||
throw new BeaverApiError(res.status, errorMessage(res.status, body), body);
|
||||
}
|
||||
return res.json;
|
||||
}
|
||||
|
||||
async function errorFromResponse(res: Response): Promise<BeaverApiError> {
|
||||
// Drain the body so we can surface a useful detail. 409 in particular
|
||||
// returns JSON; the rest may be JSON or plain text.
|
||||
const text = await res.text().catch(() => "");
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
// not JSON; leave body as the raw text
|
||||
}
|
||||
return new BeaverApiError(res.status, errorMessage(res.status, body), body);
|
||||
}
|
||||
|
||||
export async function listAgents(settings: BeaverSettings): Promise<string[]> {
|
||||
const body = (await call(settings, { path: "/agents", method: "GET" })) as {
|
||||
agents?: Array<{ name?: unknown }>;
|
||||
};
|
||||
const body = (await call(settings, {
|
||||
url: `${mdBase(settings)}/agents`,
|
||||
method: "GET",
|
||||
})) as { agents?: Array<{ name?: unknown }> };
|
||||
const list = body.agents ?? [];
|
||||
return list
|
||||
.map((a) => (typeof a?.name === "string" ? a.name : null))
|
||||
@@ -83,7 +120,7 @@ export async function sendChat(
|
||||
req: ChatRequest,
|
||||
): Promise<ChatResponse> {
|
||||
return (await call(settings, {
|
||||
path: "/chat",
|
||||
url: `${mdBase(settings)}/chat`,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(req),
|
||||
@@ -101,118 +138,46 @@ export interface StreamChatCallbacks {
|
||||
onDone(response: ChatResponse): void;
|
||||
}
|
||||
|
||||
// Server-Sent Events arrive as ``event: <name>\ndata: <json>\n\n``
|
||||
// frames. We can't use ``requestUrl`` (it buffers the whole body), so
|
||||
// SSE is the one place in the plugin that goes through native
|
||||
// ``fetch``. CORS is allowed by the gateway's ``CORSMiddleware``; auth
|
||||
// is the same bearer token as the other endpoints.
|
||||
// CORS is allowed by the gateway's ``CORSMiddleware``; auth is the same
|
||||
// bearer token as the other endpoints.
|
||||
export async function sendChatStream(
|
||||
settings: BeaverSettings,
|
||||
req: ChatRequest,
|
||||
cb: StreamChatCallbacks,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const url = `${baseUrl(settings)}/chat/stream`;
|
||||
const headers: Record<string, string> = {
|
||||
const res = await fetch(`${mdBase(settings)}/chat/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeader(settings),
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
},
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
// Drain the body so we can surface a useful detail. 409 in
|
||||
// particular returns JSON; the rest may be JSON or plain text.
|
||||
const text = await res.text().catch(() => "");
|
||||
let detail: unknown = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown };
|
||||
detail =
|
||||
parsed && typeof parsed === "object" && "detail" in parsed
|
||||
? parsed.detail
|
||||
: parsed;
|
||||
} catch {
|
||||
// not JSON; leave detail as the raw text
|
||||
}
|
||||
const msg =
|
||||
typeof detail === "string" && detail
|
||||
? detail
|
||||
: `HTTP ${res.status}`;
|
||||
throw new BeaverApiError(res.status, msg, detail);
|
||||
}
|
||||
if (!res.ok || !res.body) throw await errorFromResponse(res);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
// SSE frames are separated by a blank line (``\n\n``). We buffer
|
||||
// partial frames across reads, then flush full ones in order.
|
||||
let buf = "";
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
done = chunk.done;
|
||||
if (chunk.value) buf += decoder.decode(chunk.value, { stream: !done });
|
||||
let sep = buf.indexOf("\n\n");
|
||||
while (sep >= 0) {
|
||||
const frame = buf.slice(0, sep);
|
||||
buf = buf.slice(sep + 2);
|
||||
const handled = handleSseFrame(frame, cb);
|
||||
if (handled === "stop") {
|
||||
// ``done``/``error`` is terminal — stop reading even if the
|
||||
// server sends extra padding before closing.
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
return;
|
||||
}
|
||||
sep = buf.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSseFrame(
|
||||
frame: string,
|
||||
cb: StreamChatCallbacks,
|
||||
): "continue" | "stop" {
|
||||
// SSE lines: ``event: <name>`` / ``data: <json>``. ``data`` may span
|
||||
// multiple lines (concatenated with ``\n``) per the spec; we honour
|
||||
// that even though the gateway emits single-line ``data:`` today.
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const rawLine of frame.split("\n")) {
|
||||
const line = rawLine.replace(/\r$/, "");
|
||||
if (!line || line.startsWith(":")) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const field = line.slice(0, colon);
|
||||
let value = line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
if (field === "event") event = value;
|
||||
else if (field === "data") dataLines.push(value);
|
||||
}
|
||||
if (dataLines.length === 0) return "continue";
|
||||
await readSse(res.body, (frame) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(dataLines.join("\n"));
|
||||
data = JSON.parse(frame.data);
|
||||
} catch {
|
||||
return "continue";
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (event === "delta") {
|
||||
if (frame.event === "delta") {
|
||||
const content = obj.new_content;
|
||||
if (typeof content === "string") cb.onDelta(content);
|
||||
return "continue";
|
||||
}
|
||||
if (event === "done") {
|
||||
if (frame.event === "done") {
|
||||
cb.onDone(obj as unknown as ChatResponse);
|
||||
// ``done``/``error`` is terminal — stop reading even if the
|
||||
// server sends extra padding before closing.
|
||||
return "stop";
|
||||
}
|
||||
if (event === "error") {
|
||||
if (frame.event === "error") {
|
||||
const code =
|
||||
typeof obj.status_code === "number" ? obj.status_code : 500;
|
||||
const detail =
|
||||
@@ -220,4 +185,82 @@ function handleSseFrame(
|
||||
throw new BeaverApiError(code, detail, obj);
|
||||
}
|
||||
return "continue";
|
||||
});
|
||||
}
|
||||
|
||||
// ---- conversations API (``/api``, bearer scope ``api``) ----
|
||||
|
||||
export interface ConversationInfo {
|
||||
id: string;
|
||||
kind: string;
|
||||
agent: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
running_turn: string | null;
|
||||
pending_question: unknown;
|
||||
parent?: string | null;
|
||||
live?: boolean;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
// One record of the gateway bus as it arrives over SSE: ``type`` is the
|
||||
// SSE event name (``turn.start``, ``stream``, ``tool``, ``tool.result``,
|
||||
// ``result``, ``turn.end``, ``say``, ``conversation.*`` …), the rest is
|
||||
// the event's own payload.
|
||||
export type BusEvent = {
|
||||
type: string;
|
||||
seq?: number;
|
||||
ts?: string;
|
||||
conversation_id?: string | null;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export async function listApiAgents(
|
||||
settings: BeaverSettings,
|
||||
): Promise<string[]> {
|
||||
const body = (await call(settings, {
|
||||
url: `${apiBase(settings)}/api/agents`,
|
||||
method: "GET",
|
||||
})) as { agents?: Array<{ name?: unknown }> };
|
||||
return (body.agents ?? [])
|
||||
.map((a) => (typeof a?.name === "string" ? a.name : null))
|
||||
.filter((n): n is string => !!n);
|
||||
}
|
||||
|
||||
export async function getConversation(
|
||||
settings: BeaverSettings,
|
||||
id: string,
|
||||
): Promise<ConversationInfo> {
|
||||
return (await call(settings, {
|
||||
url: `${apiBase(settings)}/api/conversations/${encodeURIComponent(id)}`,
|
||||
method: "GET",
|
||||
})) as ConversationInfo;
|
||||
}
|
||||
|
||||
// Resolves when the server closes the stream; rejects on HTTP errors,
|
||||
// network failures and abort. Reconnection is the caller's business.
|
||||
export async function streamConversationEvents(
|
||||
settings: BeaverSettings,
|
||||
id: string,
|
||||
onEvent: (event: BusEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const url = `${apiBase(settings)}/api/conversations/${encodeURIComponent(id)}/events`;
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { ...authHeader(settings), Accept: "text/event-stream" },
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !res.body) throw await errorFromResponse(res);
|
||||
await readSse(res.body, (frame) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(frame.data);
|
||||
} catch {
|
||||
return "continue";
|
||||
}
|
||||
if (data && typeof data === "object") {
|
||||
onEvent({ ...(data as Record<string, unknown>), type: frame.event });
|
||||
}
|
||||
return "continue";
|
||||
});
|
||||
}
|
||||
|
||||
+46
@@ -4,6 +4,7 @@ import {
|
||||
Notice,
|
||||
Plugin,
|
||||
TFile,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
import {
|
||||
BeaverApiError,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
listAgents,
|
||||
sendChatStream,
|
||||
} from "./api";
|
||||
import { ACTIVITY_VIEW_TYPE, ActivityView } from "./activity";
|
||||
import { pickAgent } from "./agentPicker";
|
||||
import {
|
||||
BeaverSettings,
|
||||
@@ -159,6 +161,21 @@ export default class BeaverPlugin extends Plugin {
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new BeaverSettingsTab(this.app, this));
|
||||
this.registerView(
|
||||
ACTIVITY_VIEW_TYPE,
|
||||
(leaf) => new ActivityView(leaf, this),
|
||||
);
|
||||
|
||||
this.addCommand({
|
||||
id: "open-in-panel",
|
||||
name: "Open chat in panel",
|
||||
checkCallback: (checking) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file || file.extension !== "md") return false;
|
||||
if (!checking) void this.openActivityPanel(file);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "send-selected",
|
||||
@@ -201,6 +218,35 @@ export default class BeaverPlugin extends Plugin {
|
||||
this.lastListFailed = false;
|
||||
}
|
||||
|
||||
// ``conversation_id`` from the note's frontmatter, or null. The panel
|
||||
// and the command both key off this; the gateway writes it after the
|
||||
// first turn (§3.10: frontmatter is only ``agent`` + ``conversation_id``).
|
||||
conversationIdOf(file: TFile | null): string | null {
|
||||
if (!file || file.extension !== "md") return null;
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const raw = cache?.frontmatter?.conversation_id;
|
||||
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
|
||||
}
|
||||
|
||||
// Reveal the activity panel (creating it in the right sidebar on first
|
||||
// use) and point it at ``file``.
|
||||
async openActivityPanel(file: TFile | null): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
let leaf: WorkspaceLeaf | null =
|
||||
workspace.getLeavesOfType(ACTIVITY_VIEW_TYPE)[0] ?? null;
|
||||
if (!leaf) {
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
if (!leaf) {
|
||||
new Notice("Beaver: couldn't open the side panel");
|
||||
return;
|
||||
}
|
||||
await leaf.setViewState({ type: ACTIVITY_VIEW_TYPE, active: true });
|
||||
}
|
||||
await workspace.revealLeaf(leaf);
|
||||
const view = leaf.view;
|
||||
if (view instanceof ActivityView) view.follow(file);
|
||||
}
|
||||
|
||||
private getFrontmatterAgentContext():
|
||||
| { file: TFile; agent: string }
|
||||
| null {
|
||||
|
||||
+50
-10
@@ -1,15 +1,17 @@
|
||||
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
import { listAgents, BeaverApiError } from "./api";
|
||||
import { BeaverApiError, apiBase, listAgents, listApiAgents } from "./api";
|
||||
import type BeaverPlugin from "./main";
|
||||
|
||||
export interface BeaverSettings {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
vaultSubpath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: BeaverSettings = {
|
||||
baseUrl: "http://localhost:62993",
|
||||
apiBaseUrl: "",
|
||||
token: "",
|
||||
vaultSubpath: "",
|
||||
};
|
||||
@@ -18,6 +20,14 @@ export function normalizeSubpath(raw: string): string {
|
||||
return raw.trim().replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof BeaverApiError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
}
|
||||
|
||||
export class BeaverSettingsTab extends PluginSettingTab {
|
||||
plugin: BeaverPlugin;
|
||||
|
||||
@@ -43,9 +53,29 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("API origin")
|
||||
.setDesc(
|
||||
"Origin of the conversations API; `/api/…` is appended to it. " +
|
||||
"Leave empty to derive from Base URL (`…/md` → `…`, port 62993 → 62994). " +
|
||||
"Example: https://beaver.example.com or http://localhost:62994.",
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("(derived)")
|
||||
.setValue(this.plugin.settings.apiBaseUrl)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.apiBaseUrl = value.trim().replace(/\/+$/, "");
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Bearer token")
|
||||
.setDesc("Token with the `messages` scope.")
|
||||
.setDesc(
|
||||
"Token with the `messages` scope for sending and the `api` scope " +
|
||||
"for the activity panel (or a `*` bootstrap token).",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "password";
|
||||
text
|
||||
@@ -75,7 +105,7 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Test connection")
|
||||
.setDesc("Calls GET /agents and reports the count.")
|
||||
.setDesc("Calls GET /agents on the markdown frontend and reports the count.")
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("Test").onClick(async () => {
|
||||
try {
|
||||
@@ -83,13 +113,23 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
||||
this.plugin.cacheAgents(agents);
|
||||
new Notice(`Beaver: found ${agents.length} agents`);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof BeaverApiError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
new Notice(`Beaver: ${msg}`, 8000);
|
||||
new Notice(`Beaver: ${errorText(err)}`, 8000);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Test API")
|
||||
.setDesc("Calls GET /api/agents on the API origin (needs the `api` scope).")
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("Test").onClick(async () => {
|
||||
try {
|
||||
const agents = await listApiAgents(this.plugin.settings);
|
||||
new Notice(
|
||||
`Beaver: API at ${apiBase(this.plugin.settings)} - ${agents.length} agents`,
|
||||
);
|
||||
} catch (err) {
|
||||
new Notice(`Beaver: ${errorText(err)}`, 8000);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Server-Sent Events over a ``fetch`` body. ``EventSource`` can't send a
|
||||
// bearer header and ``requestUrl`` buffers the whole response, so every
|
||||
// SSE consumer in the plugin (chat streaming, the activity panel) goes
|
||||
// through this reader. Frames are ``event: <name>\ndata: <json>\n\n``;
|
||||
// ``data`` may span several lines per the spec, comment lines (``:
|
||||
// keepalive``) are dropped.
|
||||
|
||||
export interface SseFrame {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export function parseSseFrame(frame: string): SseFrame | null {
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const rawLine of frame.split("\n")) {
|
||||
const line = rawLine.replace(/\r$/, "");
|
||||
if (!line || line.startsWith(":")) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const field = line.slice(0, colon);
|
||||
let value = line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
if (field === "event") event = value;
|
||||
else if (field === "data") dataLines.push(value);
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return { event, data: dataLines.join("\n") };
|
||||
}
|
||||
|
||||
// Reads ``body`` to the end (or until ``onFrame`` returns ``"stop"``),
|
||||
// delivering complete frames in order. Partial frames are buffered
|
||||
// across reads. Aborting the underlying fetch rejects ``reader.read()``
|
||||
// with an ``AbortError`` that propagates to the caller.
|
||||
export async function readSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onFrame: (frame: SseFrame) => "continue" | "stop",
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let done = false;
|
||||
try {
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
done = chunk.done;
|
||||
if (chunk.value) buf += decoder.decode(chunk.value, { stream: !done });
|
||||
let sep = buf.indexOf("\n\n");
|
||||
while (sep >= 0) {
|
||||
const frame = parseSseFrame(buf.slice(0, sep));
|
||||
buf = buf.slice(sep + 2);
|
||||
if (frame && onFrame(frame) === "stop") return;
|
||||
sep = buf.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/* Beaver: activity panel (plain DOM, v0). Theme through Obsidian variables. */
|
||||
.beaver-activity {
|
||||
font-size: var(--font-ui-small);
|
||||
line-height: 1.4;
|
||||
user-select: text;
|
||||
}
|
||||
.beaver-activity-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-bottom: var(--size-4-2);
|
||||
margin-bottom: var(--size-4-2);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary);
|
||||
}
|
||||
.beaver-activity-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.beaver-activity-conn {
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-weight: var(--font-normal);
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.beaver-activity-conn::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.beaver-activity-conn.is-open::before { background: var(--color-green); }
|
||||
.beaver-activity-conn.is-connecting::before,
|
||||
.beaver-activity-conn.is-retrying::before { background: var(--color-yellow); }
|
||||
.beaver-activity-conn.is-failed::before { background: var(--color-red); }
|
||||
|
||||
.beaver-activity-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-4-1) var(--size-4-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
.beaver-activity-meta .is-running { color: var(--color-yellow); }
|
||||
.beaver-activity-meta .is-pending { color: var(--color-orange); }
|
||||
.beaver-activity-note {
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.beaver-activity-note.is-failed { color: var(--color-red); }
|
||||
|
||||
.beaver-activity-empty {
|
||||
color: var(--text-muted);
|
||||
padding: var(--size-4-2) 0;
|
||||
}
|
||||
|
||||
.beaver-activity-turn {
|
||||
padding: var(--size-4-2) 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.beaver-activity-turn:last-child { border-bottom: none; }
|
||||
.beaver-activity-turn-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--size-4-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
.beaver-activity-status {
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.beaver-activity-turn.is-running .beaver-activity-status { color: var(--color-yellow); }
|
||||
.beaver-activity-turn.is-done .beaver-activity-status { color: var(--color-green); }
|
||||
.beaver-activity-turn.is-interrupted .beaver-activity-status { color: var(--color-orange); }
|
||||
.beaver-activity-turn.is-error .beaver-activity-status { color: var(--color-red); }
|
||||
.beaver-activity-duration { margin-left: auto; color: var(--text-faint); white-space: nowrap; }
|
||||
.beaver-activity-usage {
|
||||
color: var(--text-faint);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.beaver-activity-say {
|
||||
display: flex;
|
||||
gap: var(--size-4-1);
|
||||
margin-top: var(--size-4-1);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.beaver-activity-icon svg { width: 14px; height: 14px; vertical-align: -2px; }
|
||||
|
||||
.beaver-activity-tree { margin-top: var(--size-4-1); }
|
||||
.beaver-activity-tool-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--size-4-1);
|
||||
padding: 1px 0;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.beaver-activity-tool-row:hover { background: var(--background-modifier-hover); }
|
||||
.beaver-activity-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
align-self: center;
|
||||
}
|
||||
.beaver-activity-tool.is-running > .beaver-activity-tool-row .beaver-activity-dot {
|
||||
background: var(--color-yellow);
|
||||
animation: beaver-activity-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.beaver-activity-tool.is-done > .beaver-activity-tool-row .beaver-activity-dot { background: var(--color-green); }
|
||||
.beaver-activity-tool.is-error > .beaver-activity-tool-row .beaver-activity-dot { background: var(--color-red); }
|
||||
.beaver-activity-tool.is-aborted > .beaver-activity-tool-row .beaver-activity-dot { background: var(--color-orange); }
|
||||
@keyframes beaver-activity-pulse {
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.beaver-activity-tool-name {
|
||||
flex: none;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.beaver-activity-tool-summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.beaver-activity-tool-details pre {
|
||||
margin: var(--size-4-1) 0 var(--size-4-1) 12px;
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
font-size: var(--font-ui-smaller);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--background-secondary);
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
.beaver-activity-tool-details pre.is-result { border-left: 2px solid var(--color-green); }
|
||||
.beaver-activity-tool.is-error > .beaver-activity-tool-details pre.is-result { border-left-color: var(--color-red); }
|
||||
.beaver-activity-children {
|
||||
margin-left: 12px;
|
||||
padding-left: var(--size-4-2);
|
||||
border-left: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"0.1.0": "1.5.0"
|
||||
"0.1.0": "1.5.0",
|
||||
"0.2.0": "1.5.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user