feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests

This commit is contained in:
hh
2026-08-29 18:30:31 +02:00
parent a1bf4db905
commit 04400c5076
33 changed files with 3378 additions and 1803 deletions
-657
View File
@@ -1,657 +0,0 @@
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);
}
}
}
+4 -2
View File
@@ -1,10 +1,12 @@
import { App, FuzzySuggestModal } from "obsidian";
import { type App, FuzzySuggestModal } from "obsidian";
export function pickAgent(app: App, agents: string[]): Promise<string | null> {
return new Promise((resolve) => {
let resolved = false;
const settle = (value: string | null) => {
if (resolved) return;
if (resolved) {
return;
}
resolved = true;
resolve(value);
};
+55 -97
View File
@@ -1,19 +1,20 @@
import { requestUrl, RequestUrlParam } from "obsidian";
import type { RequestUrlParam } from "obsidian";
import { requestUrl } from "obsidian";
import type { BeaverSettings } from "./settings";
import { readSse } from "./sse";
export interface ChatRequest {
filename: string;
content: string;
agent?: string;
content: string;
filename: string;
}
export interface ChatResponse {
status: "ok" | "nothing_to_do" | "in_progress";
reason?: string;
agent?: string;
turns_appended?: number;
new_content?: string;
reason?: string;
status: "ok" | "nothing_to_do" | "in_progress";
turns_appended?: number;
}
export class BeaverApiError extends Error {
@@ -32,26 +33,39 @@ export class BeaverApiError extends Error {
// 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.
const TRAILING_SLASHES = /\/+$/;
const LOCAL_MD_PORT = /:62993$/;
export function mdBase(settings: BeaverSettings): string {
const url = settings.baseUrl.trim().replace(/\/+$/, "");
if (!url) throw new Error("Beaver: base URL is not configured");
const url = settings.baseUrl.trim().replace(TRAILING_SLASHES, "");
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 explicit = settings.apiBaseUrl.trim().replace(TRAILING_SLASHES, "");
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");
if (md.endsWith("/md")) {
return md.slice(0, -"/md".length);
}
if (LOCAL_MD_PORT.test(md)) {
return md.replace(LOCAL_MD_PORT, ":62994");
}
throw new Error(
`Beaver: API origin is not configured and can't be derived from ${md}`,
`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");
if (!token) {
throw new Error("Beaver: bearer token is not configured");
}
return { Authorization: `Bearer ${token}` };
}
@@ -61,16 +75,20 @@ 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 obj[key] === "string") {
return obj[key] as string;
}
}
}
if (typeof body === "string" && body) return body;
if (typeof body === "string" && body) {
return body;
}
return `HTTP ${status}`;
}
async function call(
settings: BeaverSettings,
init: Omit<RequestUrlParam, "url"> & { url: string },
init: Omit<RequestUrlParam, "url"> & { url: string }
): Promise<unknown> {
const headers = {
...authHeader(settings),
@@ -106,8 +124,8 @@ async function errorFromResponse(res: Response): Promise<BeaverApiError> {
export async function listAgents(settings: BeaverSettings): Promise<string[]> {
const body = (await call(settings, {
url: `${mdBase(settings)}/agents`,
method: "GET",
url: `${mdBase(settings)}/agents`,
})) as { agents?: Array<{ name?: unknown }> };
const list = body.agents ?? [];
return list
@@ -117,13 +135,13 @@ export async function listAgents(settings: BeaverSettings): Promise<string[]> {
export async function sendChat(
settings: BeaverSettings,
req: ChatRequest,
req: ChatRequest
): Promise<ChatResponse> {
return (await call(settings, {
url: `${mdBase(settings)}/chat`,
method: "POST",
contentType: "application/json",
body: JSON.stringify(req),
contentType: "application/json",
method: "POST",
url: `${mdBase(settings)}/chat`,
})) as ChatResponse;
}
@@ -133,9 +151,9 @@ export interface StreamChatCallbacks {
// responsible for splicing it into the editor — we don't ship the
// diff because the gateway already renders the canonical view and we
// don't want two slightly-different renderers to drift.
onDelta(newContent: string): void;
onDelta: (newContent: string) => void;
// Fires exactly once at end-of-turn (success or nothing_to_do).
onDone(response: ChatResponse): void;
onDone: (response: ChatResponse) => void;
}
// CORS is allowed by the gateway's ``CORSMiddleware``; auth is the same
@@ -144,19 +162,21 @@ export async function sendChatStream(
settings: BeaverSettings,
req: ChatRequest,
cb: StreamChatCallbacks,
signal?: AbortSignal,
signal?: AbortSignal
): Promise<void> {
const res = await fetch(`${mdBase(settings)}/chat/stream`, {
method: "POST",
body: JSON.stringify(req),
headers: {
...authHeader(settings),
"Content-Type": "application/json",
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify(req),
method: "POST",
signal,
});
if (!res.ok || !res.body) throw await errorFromResponse(res);
if (!(res.ok && res.body)) {
throw await errorFromResponse(res);
}
await readSse(res.body, (frame) => {
let data: unknown;
@@ -168,7 +188,9 @@ export async function sendChatStream(
const obj = data as Record<string, unknown>;
if (frame.event === "delta") {
const content = obj.new_content;
if (typeof content === "string") cb.onDelta(content);
if (typeof content === "string") {
cb.onDelta(content);
}
return "continue";
}
if (frame.event === "done") {
@@ -178,8 +200,7 @@ export async function sendChatStream(
return "stop";
}
if (frame.event === "error") {
const code =
typeof obj.status_code === "number" ? obj.status_code : 500;
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);
@@ -190,77 +211,14 @@ export async function sendChatStream(
// ---- 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,
settings: BeaverSettings
): Promise<string[]> {
const body = (await call(settings, {
url: `${apiBase(settings)}/api/agents`,
method: "GET",
url: `${apiBase(settings)}/api/agents`,
})) 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";
});
}
+202 -144
View File
@@ -1,71 +1,66 @@
import type { WorkspaceLeaf } from "obsidian";
import {
Editor,
MarkdownView,
type Editor,
type MarkdownView,
Notice,
Plugin,
TFile,
WorkspaceLeaf,
type TFile,
} from "obsidian";
import {
BeaverApiError,
ChatResponse,
listAgents,
sendChatStream,
} from "./api";
import { ACTIVITY_VIEW_TYPE, ActivityView } from "./activity";
import { pickAgent } from "./agentPicker";
import {
BeaverSettings,
BeaverSettingsTab,
DEFAULT_SETTINGS,
} from "./settings";
import { ApiClient } from "$lib/api/client";
import { pickAgent } from "./agent-picker";
import type { ChatResponse } from "./api";
import { apiBase, BeaverApiError, listAgents, sendChatStream } from "./api";
import type { BeaverSettings } from "./settings";
import { BeaverSettingsTab, DEFAULT_SETTINGS, errorText } from "./settings";
import { PanelView, VIEW_TYPE_PANEL } from "./view";
const AGENT_CACHE_TTL_MS = 5 * 60 * 1000;
// Obsidian's ``Editor`` is a thin shim over a CodeMirror 6
// ``EditorView``; reaching for it directly is the only way to grab
// the real scrollable DOM node (``.cm-scroller``). Property name is
// unofficial but stable across recent Obsidian releases.
// Obsidian's ``Editor`` is a thin shim over a CodeMirror 6 ``EditorView``;
// reaching for it directly is the only way to grab the real scrollable DOM
// node (``.cm-scroller``). Property name is unofficial but stable across
// recent Obsidian releases.
interface CMHandle {
scrollDOM: HTMLElement;
}
function cmOf(editor: Editor): CMHandle | null {
const cm = (editor as unknown as { cm?: CMHandle }).cm;
const { cm } = editor as unknown as { cm?: CMHandle };
return cm && cm.scrollDOM instanceof HTMLElement ? cm : null;
}
// Boundary of the YAML frontmatter block. ``---\n…---\n`` at the very
// start of the file; anything else is a no-frontmatter file and we
// return 0.
// Boundary of the YAML frontmatter block: ``---\n…---\n`` at the very start
// of the file; anything else is a no-frontmatter file and we return 0.
function frontmatterEnd(s: string): number {
if (!s.startsWith("---\n")) return 0;
if (!s.startsWith("---\n")) {
return 0;
}
const idx = s.indexOf("\n---\n", 4);
return idx >= 0 ? idx + 5 : 0;
}
// Replace only the regions that actually changed instead of
// ``editor.setValue`` (which resets cursor + scroll and is expensive
// on every snapshot for long files).
// ``editor.setValue`` (which resets cursor + scroll and is expensive on
// every snapshot for long files).
//
// Fast path: when the body after the frontmatter is identical except
// for a tail append (the common case for the final write gateway
// refreshes frontmatter at the top and appends USER_SCAFFOLD at the
// bottom, body in between is unchanged), we apply TWO small edits
// instead of one huge one. The frontmatter rewrite stays a small,
// localised change; the tail append touches only the very last bytes.
// A single big ``replaceRange`` spanning the frontmatter triggers
// Obsidian's Properties-widget rebuild + an asynchronous scroll-to-
// start-of-content that's hard to override.
// Fast path: when the body after the frontmatter is identical except for a
// tail append (the common case for the final write - the gateway refreshes
// frontmatter at the top and appends USER_SCAFFOLD at the bottom, body in
// between unchanged), we apply TWO small edits instead of one huge one. A
// single big ``replaceRange`` spanning the frontmatter triggers Obsidian's
// Properties-widget rebuild + an asynchronous scroll-to-start-of-content
// that is hard to override.
//
// Slow path: prefix/suffix-trimmed single splice for any other shape.
//
// After every edit we restore scroll on the scrollDOM directly,
// repeatedly across several frames, because the widget rebuild keeps
// resetting scroll asynchronously and a single restore loses the
// race.
// After every edit we restore scroll on the scrollDOM directly, repeatedly
// across several frames, because the widget rebuild keeps resetting scroll
// asynchronously and a single restore loses the race.
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: three splice shapes belong together
function spliceIntoEditor(editor: Editor, newContent: string): void {
const current = editor.getValue();
if (current === newContent) return;
if (current === newContent) {
return;
}
const cm = cmOf(editor);
const savedScrollTop = cm?.scrollDOM.scrollTop ?? null;
@@ -76,11 +71,13 @@ function spliceIntoEditor(editor: Editor, newContent: string): void {
const fmsDiffer =
current.slice(0, oldFmEnd) !== newContent.slice(0, newFmEnd);
if (newBody.startsWith(oldBody) && (fmsDiffer || newBody.length > oldBody.length)) {
// Tail append first, then (maybe) frontmatter rewrite. Order
// matters: appending at the current end keeps the frontmatter
// splice positions valid. Reversing the order would invalidate
// the append offset after the frontmatter grew.
if (
newBody.startsWith(oldBody) &&
(fmsDiffer || newBody.length > oldBody.length)
) {
// Tail append first, then (maybe) frontmatter rewrite. Order matters:
// appending at the current end keeps the frontmatter splice positions
// valid; the reverse would invalidate the append offset.
if (newBody.length > oldBody.length) {
const appendStart = editor.offsetToPos(current.length);
editor.replaceRange(newBody.slice(oldBody.length), appendStart);
@@ -90,9 +87,12 @@ function spliceIntoEditor(editor: Editor, newContent: string): void {
const fmEnd = editor.offsetToPos(oldFmEnd);
editor.replaceRange(newContent.slice(0, newFmEnd), fmStart, fmEnd);
}
} else if (oldBody.startsWith(newBody) && (fmsDiffer || oldBody.length > newBody.length)) {
// Symmetric case: tail truncation. Unlikely on the streaming
// path but cheap to handle.
} else if (
oldBody.startsWith(newBody) &&
(fmsDiffer || oldBody.length > newBody.length)
) {
// Symmetric case: tail truncation. Unlikely on the streaming path but
// cheap to handle.
if (oldBody.length > newBody.length) {
const truncStart = editor.offsetToPos(oldFmEnd + newBody.length);
const truncEnd = editor.offsetToPos(current.length);
@@ -111,7 +111,7 @@ function spliceIntoEditor(editor: Editor, newContent: string): void {
prefix < cap &&
current.charCodeAt(prefix) === newContent.charCodeAt(prefix)
) {
prefix++;
prefix += 1;
}
let suffix = 0;
const maxSuffix = cap - prefix;
@@ -120,22 +120,21 @@ function spliceIntoEditor(editor: Editor, newContent: string): void {
current.charCodeAt(current.length - 1 - suffix) ===
newContent.charCodeAt(newContent.length - 1 - suffix)
) {
suffix++;
suffix += 1;
}
const start = editor.offsetToPos(prefix);
const end = editor.offsetToPos(current.length - suffix);
editor.replaceRange(
newContent.slice(prefix, newContent.length - suffix),
start,
end,
end
);
}
if (cm != null && savedScrollTop != null) {
// Race against Obsidian's deferred scroll-reset. Sync restore +
// two animation frames + two timeouts; whichever fires after
// Obsidian's own scroll write wins. Cheap noop on the
// already-correct frames.
if (cm !== null && savedScrollTop !== null) {
// Race against Obsidian's deferred scroll-reset: sync restore + two
// animation frames + two timeouts; whichever fires after Obsidian's own
// scroll write wins. Cheap noop on the already-correct frames.
const restore = () => {
if (cm.scrollDOM.scrollTop !== savedScrollTop) {
cm.scrollDOM.scrollTop = savedScrollTop;
@@ -153,6 +152,7 @@ function spliceIntoEditor(editor: Editor, newContent: string): void {
export default class BeaverPlugin extends Plugin {
settings: BeaverSettings = { ...DEFAULT_SETTINGS };
readonly views = new Set<PanelView>();
private cachedAgents: string[] | null = null;
private cachedAt = 0;
@@ -161,55 +161,103 @@ 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.registerView(VIEW_TYPE_PANEL, (leaf) => new PanelView(leaf, this));
this.addRibbonIcon("bot", "Beaver", () => {
this.openPanel("side").catch(() => undefined);
});
this.addCommand({
callback: () => {
this.openPanel("side").catch(() => undefined);
},
id: "open-panel",
name: "Open panel",
});
this.addCommand({
callback: () => {
this.openPanel("tab").catch(() => undefined);
},
id: "open-panel-tab",
name: "Open panel in a tab",
});
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);
const id = this.conversationIdOf(file);
if (!id) {
return false;
}
if (!checking) {
this.openPanel("side", id).catch(() => undefined);
}
return true;
},
id: "open-in-panel",
name: "Open chat in panel",
});
this.addCommand({
checkCallback: (checking) => {
const ctx = this.getFrontmatterAgentContext();
if (!ctx) {
return false;
}
if (!checking) {
this.dispatch(ctx.file, ctx.agent).catch(() => undefined);
}
return true;
},
id: "send-selected",
name: "Send using selected agent",
checkCallback: (checking) => {
const ctx = this.getFrontmatterAgentContext();
if (!ctx) return false;
if (!checking) void this.dispatch(ctx.file, ctx.agent);
return true;
},
});
this.addCommand({
id: "send-different",
name: "Send using different agent",
checkCallback: (checking) => {
const ctx = this.getFrontmatterAgentContext();
if (!ctx) return false;
if (!checking) void this.runDifferent(ctx.file);
if (!ctx) {
return false;
}
if (!checking) {
this.runDifferent(ctx.file).catch(() => undefined);
}
return true;
},
id: "send-different",
name: "Send using different agent",
});
}
async loadSettings(): Promise<void> {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) };
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
await Promise.all([...this.views].map((view) => view.remount()));
}
// The conversations API client for the panel, or null until the settings
// name a reachable origin and a token.
apiClient(): ApiClient | null {
const token = this.settings.token.trim();
if (!token) {
return null;
}
try {
return new ApiClient(apiBase(this.settings), token);
} catch {
return null;
}
}
openSettings(): void {
const { setting } = this.app as unknown as {
setting: { open: () => void; openTabById: (id: string) => void };
};
setting.open();
setting.openTabById(this.manifest.id);
}
cacheAgents(agents: string[]): void {
@@ -218,44 +266,51 @@ 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``).
// ``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;
if (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> {
// Reveal a panel (creating one on first use) and, given an id, show it.
async openPanel(placement: "side" | "tab", id?: string): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null =
workspace.getLeavesOfType(ACTIVITY_VIEW_TYPE)[0] ?? null;
workspace.getLeavesOfType(VIEW_TYPE_PANEL)[0] ?? null;
if (!leaf) {
leaf = workspace.getRightLeaf(false);
leaf =
placement === "tab"
? workspace.getLeaf("tab")
: workspace.getRightLeaf(false);
if (!leaf) {
new Notice("Beaver: couldn't open the side panel");
new Notice("Beaver: couldn't open the panel");
return;
}
await leaf.setViewState({ type: ACTIVITY_VIEW_TYPE, active: true });
await leaf.setViewState({ active: true, type: VIEW_TYPE_PANEL });
}
await workspace.revealLeaf(leaf);
const view = leaf.view;
if (view instanceof ActivityView) view.follow(file);
if (id && leaf.view instanceof PanelView) {
leaf.view.show(id);
}
}
private getFrontmatterAgentContext():
| { file: TFile; agent: string }
| null {
private getFrontmatterAgentContext(): { file: TFile; agent: string } | null {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") return null;
if (file?.extension !== "md") {
return null;
}
const cache = this.app.metadataCache.getFileCache(file);
const raw = cache?.frontmatter?.agent;
if (typeof raw !== "string" || !raw.trim()) return null;
return { file, agent: raw.trim() };
if (typeof raw !== "string" || !raw.trim()) {
return null;
}
return { agent: raw.trim(), file };
}
private async runDifferent(file: TFile): Promise<void> {
@@ -270,7 +325,9 @@ export default class BeaverPlugin extends Plugin {
return;
}
const picked = await pickAgent(this.app, agents);
if (!picked) return;
if (!picked) {
return;
}
await this.dispatch(file, picked);
} catch (err) {
this.notifyError("send-different", err);
@@ -282,7 +339,9 @@ export default class BeaverPlugin extends Plugin {
this.cachedAgents &&
!this.lastListFailed &&
Date.now() - this.cachedAt < AGENT_CACHE_TTL_MS;
if (fresh) return this.cachedAgents;
if (fresh) {
return this.cachedAgents;
}
try {
const agents = await listAgents(this.settings);
this.cacheAgents(agents);
@@ -296,13 +355,15 @@ export default class BeaverPlugin extends Plugin {
private async dispatch(file: TFile, agent: string): Promise<void> {
const filename = this.gatewayFilename(file);
if (!filename) return;
if (!filename) {
return;
}
// Snapshot the buffer if the file is open, otherwise read disk. We
// don't keep the editor: an ``Editor`` belongs to a leaf, not a
// file, so if the user opens another note in the same tab mid-
// stream the old handle now points at the wrong document. Every
// delta and the final write re-resolve the leaf by ``file.path``.
// Snapshot the buffer if the file is open, otherwise read disk. We don't
// keep the editor: an ``Editor`` belongs to a leaf, not a file, so if the
// user opens another note in the same tab mid-stream the old handle now
// points at the wrong document. Every delta and the final write
// re-resolve the leaf by ``file.path``.
const initialEditor = this.findEditorFor(file);
const content = initialEditor
? initialEditor.getValue()
@@ -313,23 +374,24 @@ export default class BeaverPlugin extends Plugin {
try {
await sendChatStream(
this.settings,
{ filename, content, agent },
{ agent, content, filename },
{
onDelta: (newContent) => {
// Live splice into the editor only — we deliberately don't
// write the partial to disk. The gateway also skips its own
// intermediate file writes on this endpoint, so the local
// file (and Obsidian Sync's copy) only sees the final state
// once. Re-resolved per delta: if ``file`` is no longer
// open anywhere, the delta is dropped and the final
// Live splice into the editor only - the partial never touches
// disk. The gateway also skips its own intermediate writes on
// this endpoint, so the file (and Obsidian Sync's copy) sees the
// final state once. Re-resolved per delta: if ``file`` is no
// longer open anywhere, the delta is dropped and the final
// ``vault.modify`` catches the buffer up.
const editor = this.findEditorFor(file);
if (editor) spliceIntoEditor(editor, newContent);
if (editor) {
spliceIntoEditor(editor, newContent);
}
},
onDone: (resp) => {
finalResponse = resp;
onDone: (response) => {
finalResponse = response;
},
},
}
);
} catch (err) {
if (err instanceof BeaverApiError && err.status === 409) {
@@ -343,11 +405,9 @@ export default class BeaverPlugin extends Plugin {
}
if (!finalResponse) {
// Stream ended without a ``done`` event — shouldn't happen but
// we surface it instead of pretending nothing was wrong.
this.notifyError(
`sending to ${agent}`,
new Error("stream ended without a done event"),
new Error("stream ended without a done event")
);
return;
}
@@ -366,28 +426,32 @@ export default class BeaverPlugin extends Plugin {
private gatewayFilename(file: TFile): string | null {
const subpath = this.settings.vaultSubpath;
if (!subpath) return file.path;
const prefix = subpath + "/";
if (!subpath) {
return file.path;
}
const prefix = `${subpath}/`;
if (file.path === subpath || file.path.startsWith(prefix)) {
return file.path.slice(prefix.length);
}
new Notice(
`Beaver: file ${file.path} is outside the configured vault subpath (${subpath})`,
8000,
8000
);
return null;
}
private findEditorFor(file: TFile): Editor | null {
// Walk open markdown views we want the editor instance that owns
// ``file`` *right now* so we can splice (keeps the buffer's edit
// history intact) instead of falling back to vault.modify. Never
// cached by callers: ``view.file`` changes when the user navigates
// within the same leaf, and is ``null`` between switches.
// Walk open markdown views: we want the editor instance that owns
// ``file`` *right now* so we can splice (keeps the buffer's edit history
// intact) instead of falling back to vault.modify. Never cached by
// callers: ``view.file`` changes when the user navigates within the same
// leaf, and is ``null`` between switches.
const leaves = this.app.workspace.getLeavesOfType("markdown");
for (const leaf of leaves) {
const view = leaf.view as MarkdownView;
if (view?.file?.path === file.path) return view.editor;
if (view?.file?.path === file.path) {
return view.editor;
}
}
return null;
}
@@ -395,26 +459,20 @@ export default class BeaverPlugin extends Plugin {
private async writeBack(file: TFile, newContent: string): Promise<void> {
const editor = this.findEditorFor(file);
if (editor) {
// Reuse the splice path so the final write (frontmatter refresh
// at the top + USER_SCAFFOLD appended at the bottom) preserves
// scroll just like the streaming deltas did.
// Reuse the splice path so the final write (frontmatter refresh at the
// top + USER_SCAFFOLD appended at the bottom) preserves scroll just
// like the streaming deltas did.
spliceIntoEditor(editor, newContent);
return;
}
// File not open (anymore): write to disk. Obsidian refreshes any
// buffer that opens it later, and a leaf that shows it in another
// pane gets the update through the vault event.
// File not open (anymore): write to disk. Obsidian refreshes any buffer
// that opens it later, and a leaf that shows it in another pane gets the
// update through the vault event.
await this.app.vault.modify(file, newContent);
}
private notifyError(action: string, err: unknown): void {
const msg =
err instanceof BeaverApiError
? `${err.status}: ${err.message}`
: err instanceof Error
? err.message
: String(err);
new Notice(`Beaver (${action}): ${msg}`, 8000);
new Notice(`Beaver (${action}): ${errorText(err)}`, 8000);
console.error("Beaver:", action, err);
}
}
+32 -23
View File
@@ -1,17 +1,17 @@
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import { BeaverApiError, apiBase, listAgents, listApiAgents } from "./api";
import { type App, Notice, PluginSettingTab, Setting } from "obsidian";
import { apiBase, BeaverApiError, listAgents, listApiAgents } from "./api";
import type BeaverPlugin from "./main";
export interface BeaverSettings {
baseUrl: string;
apiBaseUrl: string;
baseUrl: string;
token: string;
vaultSubpath: string;
}
export const DEFAULT_SETTINGS: BeaverSettings = {
baseUrl: "http://localhost:62993",
apiBaseUrl: "",
baseUrl: "http://localhost:62993",
token: "",
vaultSubpath: "",
};
@@ -20,12 +20,13 @@ 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);
const TRAILING_SLASHES = /\/+$/;
export function errorText(err: unknown): string {
if (err instanceof BeaverApiError) {
return `${err.status}: ${err.message}`;
}
return err instanceof Error ? err.message : String(err);
}
export class BeaverSettingsTab extends PluginSettingTab {
@@ -48,9 +49,11 @@ export class BeaverSettingsTab extends PluginSettingTab {
.setPlaceholder("http://localhost:62993")
.setValue(this.plugin.settings.baseUrl)
.onChange(async (value) => {
this.plugin.settings.baseUrl = value.trim().replace(/\/+$/, "");
this.plugin.settings.baseUrl = value
.trim()
.replace(TRAILING_SLASHES, "");
await this.plugin.saveSettings();
}),
})
);
new Setting(containerEl)
@@ -58,23 +61,25 @@ export class BeaverSettingsTab extends PluginSettingTab {
.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.",
"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(/\/+$/, "");
this.plugin.settings.apiBaseUrl = value
.trim()
.replace(TRAILING_SLASHES, "");
await this.plugin.saveSettings();
}),
})
);
new Setting(containerEl)
.setName("Bearer token")
.setDesc(
"Token with the `messages` scope for sending and the `api` scope " +
"for the activity panel (or a `*` bootstrap token).",
"for the panel (or a `*` bootstrap token)."
)
.addText((text) => {
text.inputEl.type = "password";
@@ -91,7 +96,7 @@ export class BeaverSettingsTab extends PluginSettingTab {
.setName("Vault subpath")
.setDesc(
"Folder in this Obsidian vault that maps to the gateway's vault root. " +
"Leave empty if the two vault roots match. Example: `💬 чаты`.",
"Leave empty if the two vault roots match. Example: `💬 чаты`."
)
.addText((text) =>
text
@@ -100,12 +105,14 @@ export class BeaverSettingsTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.vaultSubpath = normalizeSubpath(value);
await this.plugin.saveSettings();
}),
})
);
new Setting(containerEl)
.setName("Test connection")
.setDesc("Calls GET /agents on the markdown frontend and reports the count.")
.setDesc(
"Calls GET /agents on the markdown frontend and reports the count."
)
.addButton((btn) =>
btn.setButtonText("Test").onClick(async () => {
try {
@@ -115,23 +122,25 @@ export class BeaverSettingsTab extends PluginSettingTab {
} catch (err) {
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).")
.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`,
`Beaver: API at ${apiBase(this.plugin.settings)} - ${agents.length} agents`
);
} catch (err) {
new Notice(`Beaver: ${errorText(err)}`, 8000);
}
}),
})
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Notice } from "obsidian";
const SHORT_MS = 4000;
const LONG_MS = 8000;
function show(message: string, duration: number): Notice {
return new Notice(message, duration);
}
// ``svelte-sonner`` as the shared panel code imports it, backed by Obsidian
// notices; the esbuild resolver points the bare import here.
export const toast = {
error: (message: string) => show(`Beaver: ${message}`, LONG_MS),
info: (message: string) => show(message, SHORT_MS),
message: (message: string) => show(message, SHORT_MS),
success: (message: string) => show(message, SHORT_MS),
warning: (message: string) => show(message, LONG_MS),
};
+32 -14
View File
@@ -5,27 +5,40 @@
// ``data`` may span several lines per the spec, comment lines (``:
// keepalive``) are dropped.
const CARRIAGE_RETURN = /\r$/;
export interface SseFrame {
event: string;
data: string;
event: 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 line = rawLine.replace(CARRIAGE_RETURN, "");
if (!line || line.startsWith(":")) {
continue;
}
const colon = line.indexOf(":");
if (colon < 0) continue;
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 (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") };
if (dataLines.length === 0) {
return null;
}
return { data: dataLines.join("\n"), event };
}
// Reads ``body`` to the end (or until ``onFrame`` returns ``"stop"``),
@@ -34,7 +47,7 @@ export function parseSseFrame(frame: string): SseFrame | null {
// with an ``AbortError`` that propagates to the caller.
export async function readSse(
body: ReadableStream<Uint8Array>,
onFrame: (frame: SseFrame) => "continue" | "stop",
onFrame: (frame: SseFrame) => "continue" | "stop"
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
@@ -42,14 +55,19 @@ export async function readSse(
let done = false;
try {
while (!done) {
const chunk = await reader.read();
done = chunk.done;
if (chunk.value) buf += decoder.decode(chunk.value, { stream: !done });
// biome-ignore lint/performance/noAwaitInLoops: a stream is read chunk by chunk
const { done: finished, value } = await reader.read();
done = finished;
if (value) {
buf += decoder.decode(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;
if (frame && onFrame(frame) === "stop") {
return;
}
sep = buf.indexOf("\n\n");
}
}
+8
View File
@@ -0,0 +1,8 @@
// What a panel leaf remembers: the conversation on screen, whether it follows
// the active note, and which note that is. Reactive so the view and the
// Svelte tree share one truth.
export class PanelState {
selected = $state<string | null>(null);
follow = $state(true);
sourcePath = $state("");
}
+365
View File
@@ -0,0 +1,365 @@
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities) important;
@import "../../beaver-gateway/ui/src/lib/panel/panel.css";
@source "./";
@source "../../beaver-gateway/ui/src/lib";
@custom-variant dark (&:where(.theme-dark, .theme-dark *));
/* The panel's tokens are Obsidian's own variables, exactly as beaver-calendar
maps them onto .bcal-root: the theme, the accent, the font and the radii
come from the app and the user's theme. The msos palette never enters. */
.beaver-root {
--background: var(--background-primary);
--foreground: var(--text-normal);
--card: var(--background-primary-alt);
--card-foreground: var(--text-normal);
--popover: var(--background-secondary);
--popover-foreground: var(--text-normal);
--primary: var(--color-accent);
--primary-foreground: var(--text-on-accent);
--secondary: var(--background-modifier-hover);
--secondary-foreground: var(--text-normal);
--muted: var(--background-modifier-hover);
--muted-foreground: var(--text-muted);
--accent: var(--background-modifier-hover);
--accent-foreground: var(--text-normal);
--destructive: var(--text-error);
--destructive-foreground: var(--text-on-accent);
--border: var(--background-modifier-border);
--input: var(--background-modifier-border);
--ring: var(--color-accent);
--icon: var(--text-muted);
--signal: var(--color-accent);
--link: var(--link-color, var(--text-accent));
--note: oklch(0.52 0.13 78);
--warn: oklch(0.55 0.13 75);
--status-new: var(--color-accent);
--status-done: oklch(0.55 0.14 155);
--status-skip: var(--text-faint);
--status-reply: oklch(0.55 0.14 235);
--status-snooze: oklch(0.66 0.13 75);
--status-meeting: oklch(0.5 0.2 300);
--status-work: oklch(0.55 0.13 195);
--sidebar: var(--background-secondary);
--sidebar-accent: var(--background-modifier-hover);
--radius: 0.45rem;
}
.theme-dark .beaver-root {
--note: oklch(0.84 0.14 88);
--warn: oklch(0.8 0.13 75);
--status-done: oklch(0.74 0.14 155);
--status-reply: oklch(0.72 0.13 235);
--status-snooze: oklch(0.8 0.13 75);
--status-meeting: oklch(0.72 0.16 300);
--status-work: oklch(0.75 0.12 195);
}
@theme inline {
--font-sans: var(--font-interface);
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--color-icon: var(--icon);
--color-signal: var(--signal);
--color-note: var(--note);
--color-sidebar: var(--sidebar);
--color-sidebar-accent: var(--sidebar-accent);
--color-status-new: var(--status-new);
--color-status-done: var(--status-done);
--color-status-skip: var(--status-skip);
--color-status-reply: var(--status-reply);
--color-status-snooze: var(--status-snooze);
--color-status-meeting: var(--status-meeting);
--color-status-work: var(--status-work);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
}
.beaver-root {
height: 100%;
font-family: var(--font-interface);
font-size: var(--text-sm);
color: var(--foreground);
background: var(--background);
}
/* Portal layer for menus and dialogs: a zero-sized anchor at body level, above
the sidedocks, carrying the theme aliases so popovers stay on theme. */
.beaver-portal {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-menu, 65);
width: 0;
height: 0;
background: transparent;
}
.beaver-root.beaver-root *,
.beaver-root.beaver-root *::before,
.beaver-root.beaver-root *::after {
box-sizing: border-box;
border: 0 solid var(--border);
}
.beaver-root.beaver-root
:is(h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd) {
margin: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.beaver-root.beaver-root :is(ul, ol) {
padding: 0;
margin: 0;
list-style: none;
}
.beaver-root.beaver-root :is(button, input, select, textarea) {
height: auto;
min-height: 0;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
letter-spacing: inherit;
-webkit-appearance: none;
appearance: none;
outline: none;
background: transparent;
border: 0 solid var(--border);
border-radius: 0;
box-shadow: none;
}
.beaver-root.beaver-root :is(button, input, select, textarea):hover,
.beaver-root.beaver-root :is(button, input, select, textarea):focus,
.beaver-root.beaver-root :is(button, input, select, textarea):active {
box-shadow: none;
}
.beaver-root.beaver-root button {
text-align: inherit;
cursor: pointer;
}
.beaver-root.beaver-root button:hover {
background: transparent;
}
.beaver-root.beaver-root button:disabled {
cursor: default;
}
.beaver-root.beaver-root input::placeholder,
.beaver-root.beaver-root textarea::placeholder {
color: var(--muted-foreground);
}
.beaver-root.beaver-root :is(input, textarea) {
caret-color: var(--primary);
}
.beaver-root.beaver-root :focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.beaver-root.beaver-root svg {
display: block;
flex-shrink: 0;
}
.beaver-root.beaver-root a,
.beaver-root.beaver-root a:hover {
color: inherit;
text-decoration: inherit;
}
.beaver-root.beaver-root img {
display: block;
max-width: 100%;
height: auto;
}
.beaver-root.beaver-root :is(code, pre, kbd) {
font-family: var(--font-monospace);
font-size: 0.8125rem;
}
/* Chat text rendered by Obsidian itself, kept compact for a sidedock. */
.beaver-root.beaver-root .beaver-md {
font-size: var(--text-sm);
line-height: 1.5;
}
.beaver-root.beaver-root .beaver-md :is(p, ul, ol, pre, blockquote, table) {
margin-block: 0.4em;
}
.beaver-root.beaver-root .beaver-md :is(h1, h2, h3, h4, h5, h6) {
margin-block: 0.8em 0.4em;
font-size: 1em;
font-weight: 600;
}
.beaver-root.beaver-root .beaver-md :is(ul, ol) {
padding-left: 1.25em;
list-style: disc;
}
.beaver-root.beaver-root .beaver-md ol {
list-style: decimal;
}
.beaver-root.beaver-root .beaver-md li + li {
margin-top: 0.15em;
}
.beaver-root.beaver-root .beaver-md a {
color: var(--link);
text-decoration: none;
}
.beaver-root.beaver-root .beaver-md a:hover {
text-decoration: underline;
}
.beaver-root.beaver-root .beaver-md a.is-unresolved {
color: var(--link-unresolved-color, var(--muted-foreground));
opacity: 0.75;
}
.beaver-root.beaver-root .beaver-md code {
padding: 0.1em 0.3em;
font-size: 0.9em;
font-weight: 500;
background: color-mix(in oklch, var(--muted) 70%, transparent);
border-radius: var(--radius-sm);
}
.beaver-root.beaver-root .beaver-md pre {
padding: 0.6em 0.8em;
overflow: auto;
background: var(--muted);
border-radius: var(--radius-md);
}
.beaver-root.beaver-root .beaver-md pre code {
padding: 0;
font-weight: 400;
background: transparent;
}
.beaver-root.beaver-root .beaver-md blockquote {
padding-left: 0.75em;
color: var(--muted-foreground);
border-left: 1px solid var(--border);
}
.beaver-root.beaver-root .beaver-md > :first-child {
margin-top: 0;
}
.beaver-root.beaver-root .beaver-md > :last-child {
margin-bottom: 0;
}
/* The luma primitives are pill-shaped for the browser; inside Obsidian every
control takes the app's own corner language, like beaver-calendar's. */
@layer utilities {
/* Utilities are !important here, so the hidden attribute needs its own weight. */
.beaver-root [hidden] {
display: none !important;
}
.beaver-root [data-slot="button"],
.beaver-root [data-slot="select-trigger"] {
border-radius: var(--button-radius, var(--radius-s)) !important;
}
.beaver-root [data-slot="input"],
.beaver-root [data-slot="tabs-trigger"],
.beaver-root [data-slot$="-item"] {
border-radius: var(--input-radius, var(--radius-s)) !important;
}
.beaver-root [data-slot="tabs-list"],
.beaver-root [data-slot="skeleton"] {
border-radius: var(--radius-m, 8px) !important;
}
/* The luma tabs mark the active trigger with the page colour, which on a
dark Obsidian theme is the same as the surface behind the list. */
.beaver-root [data-slot="tabs-list"] {
background: color-mix(
in oklab,
var(--text-normal) 6%,
transparent
) !important;
}
.beaver-root [data-slot="tabs-trigger"][data-state="active"] {
color: var(--text-normal) !important;
background: color-mix(
in oklab,
var(--text-normal) 14%,
transparent
) !important;
}
.beaver-root [data-slot="dialog-content"],
.beaver-root [data-slot="popover-content"],
.beaver-root [data-slot="dropdown-menu-content"],
.beaver-root [data-slot="context-menu-content"],
.beaver-root [data-slot="select-content"] {
border: 1px solid var(--border) !important;
border-radius: var(--radius-m, 8px) !important;
box-shadow: var(--shadow-s) !important;
}
.beaver-root [data-slot="dropdown-menu-content"],
.beaver-root [data-slot="context-menu-content"],
.beaver-root [data-slot="select-content"] {
background: var(--popover) !important;
}
.beaver-root [data-slot="dropdown-menu-content"]::before,
.beaver-root [data-slot="context-menu-content"]::before,
.beaver-root [data-slot="select-content"]::before {
display: none !important;
}
.beaver-root [data-slot="button"][data-variant="default"],
.beaver-root [data-slot="button"]:not([class*="bg-"]) {
font-weight: var(--font-medium, 500) !important;
}
}
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import SettingsIcon from "@lucide/svelte/icons/settings";
import { BitsConfig } from "bits-ui";
import type { Component, App as ObsidianApp } from "obsidian";
import type { ApiClient } from "$lib/api/client";
import EmptyState from "$lib/components/empty-state.svelte";
import { Button } from "$lib/components/ui/button";
import { providePanelHost } from "$lib/panel/host";
import { ConversationIndex } from "$lib/panel/index.svelte";
import PanelShell from "$lib/panel/panel-shell.svelte";
import type { PanelState } from "../state.svelte";
import { obsidianHost } from "./host";
interface Props {
app: ObsidianApp;
client: ApiClient | null;
component: Component;
onOpenSettings: () => void;
state: PanelState;
vaultSubpath: () => string;
}
let { app, client, component, onOpenSettings, state, vaultSubpath }: Props =
$props();
// Menus hang off body, not off the view: Obsidian's sidedocks paint above
// the leaf, so anything portalled inside it is covered once it crosses a
// pane edge. The layer carries .beaver-root so the theme reaches popovers.
const layer = document.createElement("div");
layer.className = "beaver-root beaver-portal";
$effect(() => {
document.body.append(layer);
return () => layer.remove();
});
// Props are read once: the view remounts the tree when settings change.
// svelte-ignore state_referenced_locally
providePanelHost(
obsidianHost({
app,
component,
sourcePath: () => state.sourcePath,
vaultSubpath,
})
);
// svelte-ignore state_referenced_locally
const index = client ? new ConversationIndex(() => client) : null;
</script>
<BitsConfig defaultPortalTo={layer}>
<div class="beaver-root">
{#if client && index}
<PanelShell
{client}
{index}
showFollow
bind:follow={state.follow}
bind:selected={state.selected}
/>
{:else}
<div class="p-4">
<EmptyState
hint="Set the gateway URL and a token with the api scope in the plugin settings."
title="Beaver is not connected"
>
<Button onclick={onOpenSettings} size="sm" variant="outline">
<SettingsIcon class="size-4" />
Open settings
</Button>
</EmptyState>
</div>
{/if}
</div>
</BitsConfig>
+56
View File
@@ -0,0 +1,56 @@
import type { App, Component } from "obsidian";
import { MarkdownRenderer, Platform } from "obsidian";
import type { PanelHost } from "$lib/panel/host";
export interface HostOptions {
app: App;
component: Component;
// The note the panel currently follows; link resolution starts from it.
sourcePath: () => string;
// Folder in this vault that maps to the gateway's vault root.
vaultSubpath: () => string;
}
// The Obsidian side of the panel: markdown through the app's own renderer,
// [[links]] opened in place, a conversation's note opened by its binding.
export function obsidianHost(options: HostOptions): PanelHost {
const { app, component } = options;
return {
markdown(node, text) {
let alive = true;
const staging = document.createElement("div");
MarkdownRenderer.render(
app,
text,
staging,
options.sourcePath(),
component
)
.then(() => {
if (alive) {
node.replaceChildren(...staging.childNodes);
}
})
.catch(() => undefined);
return () => {
alive = false;
};
},
name: "obsidian",
openLink(target, kind) {
if (kind === "internal") {
app.workspace
.openLinkText(target, options.sourcePath(), false)
.catch(() => undefined);
return;
}
window.open(target);
},
openNote(path) {
const subpath = options.vaultSubpath();
const full = subpath ? `${subpath}/${path}` : path;
app.workspace.openLinkText(full, "", false).catch(() => undefined);
},
touch: Platform.isMobile,
};
}
+136
View File
@@ -0,0 +1,136 @@
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
import { ItemView, TFile } from "obsidian";
import { mount, unmount } from "svelte";
import type BeaverPlugin from "./main";
import { PanelState } from "./state.svelte";
import App from "./ui/app.svelte";
export const VIEW_TYPE_PANEL = "beaver-panel";
interface SavedState {
follow?: unknown;
selected?: unknown;
}
// One panel leaf: a sidedock column, a tab, or the phone screen. Follows
// the active note's ``conversation_id`` unless pinned, and remembers both
// across restarts through the workspace layout.
export class PanelView extends ItemView {
readonly state = new PanelState();
private readonly plugin: BeaverPlugin;
private root: Record<string, unknown> | null = null;
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
super(leaf);
this.plugin = plugin;
this.navigation = false;
}
getViewType(): string {
return VIEW_TYPE_PANEL;
}
getDisplayText(): string {
return "Beaver";
}
getIcon(): string {
return "bot";
}
getState(): Record<string, unknown> {
return { follow: this.state.follow, selected: this.state.selected };
}
async setState(state: unknown, result: ViewStateResult): Promise<void> {
const saved = (state ?? {}) as SavedState;
if (typeof saved.follow === "boolean") {
this.state.follow = saved.follow;
}
if (typeof saved.selected === "string" || saved.selected === null) {
this.state.selected = saved.selected;
}
await super.setState(state, result);
}
onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.style.padding = "0";
this.contentEl.style.overflow = "hidden";
this.registerEvent(
this.app.workspace.on("file-open", (file) => {
// ``null`` means the active leaf is not a file (this panel got
// focus, say): 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 its conversation_id - typically
// right after the first send. Re-resolve.
if (file.path === this.state.sourcePath) {
this.follow(file);
}
})
);
this.follow(this.app.workspace.getActiveFile());
this.plugin.views.add(this);
this.mountApp();
return Promise.resolve();
}
async onClose(): Promise<void> {
this.plugin.views.delete(this);
await this.unmountApp();
this.contentEl.empty();
}
// The active note changed: remember it, and switch to its conversation
// when following and it has one.
follow(file: TFile | null): void {
if (!(file instanceof TFile)) {
return;
}
this.state.sourcePath = file.path;
if (!this.state.follow) {
return;
}
const id = this.plugin.conversationIdOf(file);
if (id) {
this.state.selected = id;
}
}
show(id: string): void {
this.state.selected = id;
}
// Settings changed: the client is built from them, so rebuild the tree.
async remount(): Promise<void> {
await this.unmountApp();
this.mountApp();
}
private mountApp(): void {
this.root = mount(App, {
props: {
app: this.app,
client: this.plugin.apiClient(),
component: this,
onOpenSettings: () => this.plugin.openSettings(),
state: this.state,
vaultSubpath: () => this.plugin.settings.vaultSubpath,
},
target: this.contentEl,
});
}
private async unmountApp(): Promise<void> {
if (this.root) {
await unmount(this.root);
this.root = null;
}
}
}