feat(activity,api,settings): activity panel v0 over conversation events sse

This commit is contained in:
hh
2026-08-28 17:01:02 +02:00
parent 755a475f27
commit a1bf4db905
12 changed files with 1179 additions and 153 deletions
+657
View File
@@ -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);
}
}
}
+167 -124
View File
@@ -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,123 +138,129 @@ 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> = {
...authHeader(settings),
"Content-Type": "application/json",
Accept: "text/event-stream",
};
const res = await fetch(url, {
const res = await fetch(`${mdBase(settings)}/chat/stream`, {
method: "POST",
headers,
headers: {
...authHeader(settings),
"Content-Type": "application/json",
Accept: "text/event-stream",
},
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;
if (!res.ok || !res.body) throw await errorFromResponse(res);
await readSse(res.body, (frame) => {
let data: unknown;
try {
const parsed = JSON.parse(text) as { detail?: unknown };
detail =
parsed && typeof parsed === "object" && "detail" in parsed
? parsed.detail
: parsed;
data = JSON.parse(frame.data);
} catch {
// not JSON; leave detail as the raw text
return "continue";
}
const msg =
typeof detail === "string" && detail
? detail
: `HTTP ${res.status}`;
throw new BeaverApiError(res.status, msg, detail);
}
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");
const obj = data as Record<string, unknown>;
if (frame.event === "delta") {
const content = obj.new_content;
if (typeof content === "string") cb.onDelta(content);
return "continue";
}
}
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 (frame.event === "error") {
const code =
typeof obj.status_code === "number" ? obj.status_code : 500;
const detail =
typeof obj.detail === "string" ? obj.detail : `HTTP ${code}`;
throw new BeaverApiError(code, detail, obj);
}
return "continue";
});
}
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";
let data: unknown;
try {
data = JSON.parse(dataLines.join("\n"));
} catch {
// ---- 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";
}
const obj = data as Record<string, unknown>;
if (event === "delta") {
const content = obj.new_content;
if (typeof content === "string") cb.onDelta(content);
return "continue";
}
if (event === "done") {
cb.onDone(obj as unknown as ChatResponse);
return "stop";
}
if (event === "error") {
const code =
typeof obj.status_code === "number" ? obj.status_code : 500;
const detail =
typeof obj.detail === "string" ? obj.detail : `HTTP ${code}`;
throw new BeaverApiError(code, detail, obj);
}
return "continue";
});
}
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}