feat(activity,api,settings): activity panel v0 over conversation events sse
This commit is contained in:
+657
@@ -0,0 +1,657 @@
|
||||
import { ItemView, TFile, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import {
|
||||
BeaverApiError,
|
||||
BusEvent,
|
||||
ConversationInfo,
|
||||
getConversation,
|
||||
streamConversationEvents,
|
||||
} from "./api";
|
||||
import type BeaverPlugin from "./main";
|
||||
|
||||
export const ACTIVITY_VIEW_TYPE = "beaver-activity";
|
||||
|
||||
// ---- model ----
|
||||
|
||||
type ToolStatus = "running" | "done" | "error" | "aborted";
|
||||
|
||||
export interface ToolNode {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
parent: string | null;
|
||||
status: ToolStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
result: string;
|
||||
children: ToolNode[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
type TurnStatus = "running" | "done" | "interrupted" | "error";
|
||||
|
||||
export interface TurnUsage {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_creation?: number;
|
||||
cost_usd?: number | null;
|
||||
duration_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
id: string;
|
||||
origin: string;
|
||||
status: TurnStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
usage: TurnUsage | null;
|
||||
resultSubtype: string | null;
|
||||
roots: ToolNode[];
|
||||
nodes: Map<string, ToolNode>;
|
||||
says: string[];
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
state: "off" | "connecting" | "open" | "retrying" | "failed";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const MAX_TURNS = 20;
|
||||
|
||||
// Pure state: bus events in, tree of turns → tool calls → subagent tool
|
||||
// calls out. ``apply`` returns whether anything the panel shows changed,
|
||||
// so the chatty ``stream`` deltas don't trigger re-renders.
|
||||
export class ActivityModel {
|
||||
conversation: ConversationInfo | null = null;
|
||||
turns: Turn[] = []; // newest first
|
||||
connection: Connection = { state: "off" };
|
||||
|
||||
setConversation(info: ConversationInfo): void {
|
||||
this.conversation = info;
|
||||
const now = new Date().toISOString();
|
||||
// The snapshot is the truth about what runs *now*. A turn we still
|
||||
// show as running but the server doesn't (we were disconnected when
|
||||
// it ended, or the gateway restarted) is over; its ``turn.end`` is
|
||||
// not coming.
|
||||
for (const turn of this.turns) {
|
||||
if (turn.status !== "running" || turn.id === info.running_turn) continue;
|
||||
turn.status = "interrupted";
|
||||
turn.endedAt = now;
|
||||
for (const node of turn.nodes.values()) {
|
||||
if (node.status === "running") {
|
||||
node.status = "aborted";
|
||||
node.endedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connected mid-turn: the events for this turn started before we
|
||||
// subscribed. Open the turn so its tools have somewhere to land.
|
||||
if (info.running_turn) this.ensureTurn(info.running_turn, now, null);
|
||||
}
|
||||
|
||||
apply(ev: BusEvent): boolean {
|
||||
const ts = typeof ev.ts === "string" ? ev.ts : new Date().toISOString();
|
||||
const turnId = typeof ev.turn_id === "string" ? ev.turn_id : null;
|
||||
const parent =
|
||||
typeof ev.parent_tool_use_id === "string" ? ev.parent_tool_use_id : null;
|
||||
switch (ev.type) {
|
||||
case "turn.start": {
|
||||
if (!turnId) return false;
|
||||
const turn = this.ensureTurn(turnId, ts, str(ev.origin));
|
||||
turn.status = "running";
|
||||
turn.startedAt = ts;
|
||||
return true;
|
||||
}
|
||||
case "stream": {
|
||||
// Only the opening of a tool_use block is interesting here: it
|
||||
// gives us id + name before the full ``tool`` event arrives with
|
||||
// the input, so long-running tools show up as soon as they start.
|
||||
if (!turnId) return false;
|
||||
const raw = ev.event;
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
const sdk = raw as Record<string, unknown>;
|
||||
if (sdk.type !== "content_block_start") return false;
|
||||
const block = sdk.content_block as Record<string, unknown> | undefined;
|
||||
if (!block || block.type !== "tool_use") return false;
|
||||
if (typeof block.id !== "string") return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const existed = turn.nodes.has(block.id);
|
||||
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
|
||||
return !existed;
|
||||
}
|
||||
case "tool": {
|
||||
if (!turnId || typeof ev.tool_use_id !== "string") return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const node = this.ensureNode(
|
||||
turn,
|
||||
ev.tool_use_id,
|
||||
str(ev.name) ?? "?",
|
||||
parent,
|
||||
ts,
|
||||
);
|
||||
node.input = ev.input ?? null;
|
||||
return true;
|
||||
}
|
||||
case "tool.result": {
|
||||
if (!turnId || typeof ev.tool_use_id !== "string") return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const node = this.ensureNode(turn, ev.tool_use_id, "?", parent, ts);
|
||||
node.status = ev.is_error ? "error" : "done";
|
||||
node.endedAt = ts;
|
||||
node.result = str(ev.content) ?? "";
|
||||
return true;
|
||||
}
|
||||
case "result": {
|
||||
if (!turnId) return false;
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
turn.resultSubtype = str(ev.subtype);
|
||||
if (ev.is_error) turn.status = "error";
|
||||
return true;
|
||||
}
|
||||
case "turn.end": {
|
||||
if (!turnId) return false;
|
||||
const turn = this.ensureTurn(turnId, ts, str(ev.origin));
|
||||
const stop = str(ev.stop);
|
||||
turn.status =
|
||||
stop === "end_turn"
|
||||
? turn.status === "error"
|
||||
? "error"
|
||||
: "done"
|
||||
: stop === "interrupted"
|
||||
? "interrupted"
|
||||
: "error";
|
||||
turn.endedAt = ts;
|
||||
turn.usage =
|
||||
ev.usage && typeof ev.usage === "object"
|
||||
? (ev.usage as TurnUsage)
|
||||
: null;
|
||||
for (const node of turn.nodes.values()) {
|
||||
if (node.status === "running") {
|
||||
node.status = "aborted";
|
||||
node.endedAt = ts;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "say": {
|
||||
if (typeof ev.text !== "string") return false;
|
||||
const turn = turnId
|
||||
? this.ensureTurn(turnId, ts, null)
|
||||
: this.turns[0];
|
||||
if (!turn) return false;
|
||||
turn.says.push(ev.text);
|
||||
return true;
|
||||
}
|
||||
case "conversation.updated":
|
||||
case "conversation.created": {
|
||||
if (!this.conversation || ev.id !== this.conversation.id) return false;
|
||||
this.conversation = {
|
||||
...this.conversation,
|
||||
...(ev as unknown as Partial<ConversationInfo>),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureTurn(id: string, ts: string, origin: string | null): Turn {
|
||||
let turn = this.turns.find((t) => t.id === id);
|
||||
if (!turn) {
|
||||
turn = {
|
||||
id,
|
||||
origin: origin ?? "?",
|
||||
status: "running",
|
||||
startedAt: ts,
|
||||
endedAt: null,
|
||||
usage: null,
|
||||
resultSubtype: null,
|
||||
roots: [],
|
||||
nodes: new Map(),
|
||||
says: [],
|
||||
};
|
||||
this.turns.unshift(turn);
|
||||
if (this.turns.length > MAX_TURNS) this.turns.length = MAX_TURNS;
|
||||
} else if (origin && turn.origin === "?") {
|
||||
turn.origin = origin;
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private ensureNode(
|
||||
turn: Turn,
|
||||
id: string,
|
||||
name: string,
|
||||
parent: string | null,
|
||||
ts: string,
|
||||
): ToolNode {
|
||||
let node = turn.nodes.get(id);
|
||||
if (node) {
|
||||
if (node.name === "?" && name !== "?") node.name = name;
|
||||
return node;
|
||||
}
|
||||
node = {
|
||||
id,
|
||||
name,
|
||||
input: null,
|
||||
parent,
|
||||
status: "running",
|
||||
startedAt: ts,
|
||||
endedAt: null,
|
||||
result: "",
|
||||
children: [],
|
||||
expanded: false,
|
||||
};
|
||||
turn.nodes.set(id, node);
|
||||
if (parent) {
|
||||
// A subagent's tool call: hang it under the Task/Agent tool that
|
||||
// spawned it. Events can arrive before that tool's own ``tool``
|
||||
// event (the SDK streams the child first), so create a placeholder.
|
||||
this.ensureNode(turn, parent, "?", null, ts).children.push(node);
|
||||
} else {
|
||||
turn.roots.push(node);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
// ---- presentation helpers ----
|
||||
|
||||
function pick(input: unknown, ...keys: string[]): string | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const v = obj[key];
|
||||
if (typeof v === "string" && v.trim()) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clip(s: string, max: number): string {
|
||||
const one = s.replace(/\s+/g, " ").trim();
|
||||
return one.length > max ? one.slice(0, max - 1) + "…" : one;
|
||||
}
|
||||
|
||||
export function summarizeInput(name: string, input: unknown): string {
|
||||
if (input == null) return "";
|
||||
let s: string | null = null;
|
||||
switch (name) {
|
||||
case "Bash":
|
||||
s = pick(input, "command", "description");
|
||||
break;
|
||||
case "Read":
|
||||
case "Edit":
|
||||
case "Write":
|
||||
case "MultiEdit":
|
||||
case "NotebookEdit":
|
||||
s = pick(input, "file_path", "notebook_path");
|
||||
break;
|
||||
case "Grep":
|
||||
case "Glob":
|
||||
s = pick(input, "pattern");
|
||||
break;
|
||||
case "Task":
|
||||
case "Agent":
|
||||
s = pick(input, "description", "prompt");
|
||||
break;
|
||||
case "WebFetch":
|
||||
s = pick(input, "url");
|
||||
break;
|
||||
case "WebSearch":
|
||||
s = pick(input, "query");
|
||||
break;
|
||||
case "Skill":
|
||||
s = pick(input, "skill");
|
||||
break;
|
||||
default:
|
||||
s = null;
|
||||
}
|
||||
if (s === null) {
|
||||
if (typeof input === "object") {
|
||||
const first = Object.values(input as Record<string, unknown>).find(
|
||||
(v) => typeof v === "string" && v.trim(),
|
||||
);
|
||||
s = typeof first === "string" ? first : JSON.stringify(input);
|
||||
} else {
|
||||
s = String(input);
|
||||
}
|
||||
}
|
||||
return clip(s, 140);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.round((ms % 60_000) / 1000);
|
||||
return `${m}m${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number | undefined): string {
|
||||
if (n == null) return "-";
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
|
||||
}
|
||||
|
||||
function fmtUsage(u: TurnUsage): string {
|
||||
const parts = [
|
||||
`in ${fmtTokens(u.input)}`,
|
||||
`out ${fmtTokens(u.output)}`,
|
||||
`cache ${fmtTokens(u.cache_read)}/${fmtTokens(u.cache_creation)}`,
|
||||
];
|
||||
if (typeof u.cost_usd === "number") parts.push(`$${u.cost_usd.toFixed(4)}`);
|
||||
if (typeof u.duration_ms === "number") parts.push(fmtDuration(u.duration_ms));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function elapsed(from: string, to: string | null): number {
|
||||
const a = new Date(from).getTime();
|
||||
const b = to ? new Date(to).getTime() : Date.now();
|
||||
return Number.isNaN(a) || Number.isNaN(b) ? 0 : Math.max(0, b - a);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const timer = window.setTimeout(done, ms);
|
||||
function done() {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
const BACKOFF_MIN_MS = 1000;
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
|
||||
// ---- view ----
|
||||
|
||||
export class ActivityView extends ItemView {
|
||||
private readonly plugin: BeaverPlugin;
|
||||
private model = new ActivityModel();
|
||||
private abort: AbortController | null = null;
|
||||
private conversationId: string | null = null;
|
||||
private file: TFile | null = null;
|
||||
private root: HTMLElement | null = null;
|
||||
private renderQueued = false;
|
||||
private ticker: number | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return ACTIVITY_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return "Beaver: activity";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return "activity";
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.contentEl.empty();
|
||||
this.root = this.contentEl.createDiv({ cls: "beaver-activity" });
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-open", (file) => {
|
||||
// ``null`` means the active leaf isn't a file (e.g. this panel
|
||||
// got focus) — keep following the last note rather than drop it.
|
||||
if (file) this.follow(file);
|
||||
}),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("changed", (file) => {
|
||||
// The note we follow just got (or lost) its conversation_id —
|
||||
// typically right after the first send. Re-resolve.
|
||||
if (this.file && file.path === this.file.path) this.follow(file);
|
||||
}),
|
||||
);
|
||||
// Once a second while a turn runs, so "running for 12s" ticks.
|
||||
this.ticker = window.setInterval(() => {
|
||||
if (this.model.turns.some((t) => t.status === "running")) this.render();
|
||||
}, 1000);
|
||||
this.registerInterval(this.ticker);
|
||||
this.follow(this.app.workspace.getActiveFile());
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
this.unsubscribe();
|
||||
this.root = null;
|
||||
}
|
||||
|
||||
// Follow ``file``: subscribe to the conversation its frontmatter names,
|
||||
// or show why we can't. Cheap when nothing changed.
|
||||
follow(file: TFile | null): void {
|
||||
this.file = file;
|
||||
const id = this.plugin.conversationIdOf(file);
|
||||
if (id === this.conversationId) {
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
this.unsubscribe();
|
||||
this.conversationId = id;
|
||||
this.model = new ActivityModel();
|
||||
if (id) this.subscribe(id);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
private subscribe(id: string): void {
|
||||
const controller = new AbortController();
|
||||
this.abort = controller;
|
||||
void this.loop(id, controller.signal).catch((err) => {
|
||||
console.error("Beaver activity: subscription loop died", err);
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribe(): void {
|
||||
this.abort?.abort();
|
||||
this.abort = null;
|
||||
this.model.connection = { state: "off" };
|
||||
}
|
||||
|
||||
private async loop(id: string, signal: AbortSignal): Promise<void> {
|
||||
let delay = BACKOFF_MIN_MS;
|
||||
while (!signal.aborted) {
|
||||
this.model.connection = {
|
||||
state: delay === BACKOFF_MIN_MS ? "connecting" : "retrying",
|
||||
detail: this.model.connection.detail,
|
||||
};
|
||||
this.scheduleRender();
|
||||
try {
|
||||
// Snapshot first: status/title and, if a turn is already
|
||||
// running, its id, so events that follow land in the right place.
|
||||
const info = await getConversation(this.plugin.settings, id);
|
||||
if (signal.aborted) return;
|
||||
this.model.setConversation(info);
|
||||
this.scheduleRender();
|
||||
await streamConversationEvents(
|
||||
this.plugin.settings,
|
||||
id,
|
||||
(ev) => {
|
||||
if (ev.type === "hello") {
|
||||
delay = BACKOFF_MIN_MS;
|
||||
this.model.connection = { state: "open" };
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
if (this.model.apply(ev)) this.scheduleRender();
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
this.model.connection = { state: "retrying", detail: "stream closed" };
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
if (
|
||||
err instanceof BeaverApiError &&
|
||||
(err.status === 401 || err.status === 403 || err.status === 404)
|
||||
) {
|
||||
// Not transient: wrong token/scope or unknown conversation.
|
||||
// Retrying would only spam the gateway; the user has to act.
|
||||
this.model.connection = {
|
||||
state: "failed",
|
||||
detail: `${err.status}: ${err.message}`,
|
||||
};
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
this.model.connection = {
|
||||
state: "retrying",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
this.scheduleRender();
|
||||
await sleep(delay + Math.random() * 250, signal);
|
||||
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.renderQueued) return;
|
||||
this.renderQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.renderQueued = false;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const root = this.root;
|
||||
if (!root) return;
|
||||
root.empty();
|
||||
this.renderHeader(root);
|
||||
const body = root.createDiv({ cls: "beaver-activity-body" });
|
||||
if (!this.file) {
|
||||
body.createDiv({
|
||||
cls: "beaver-activity-empty",
|
||||
text: "Open a note with conversation_id in its frontmatter.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.conversationId) {
|
||||
body.createDiv({
|
||||
cls: "beaver-activity-empty",
|
||||
text: `«${this.file.basename}» has no conversation_id in its frontmatter.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.model.turns.length === 0) {
|
||||
body.createDiv({
|
||||
cls: "beaver-activity-empty",
|
||||
text:
|
||||
this.model.connection.state === "open"
|
||||
? "No activity yet - waiting for the next turn."
|
||||
: "Connecting…",
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const turn of this.model.turns) this.renderTurn(body, turn);
|
||||
}
|
||||
|
||||
private renderHeader(root: HTMLElement): void {
|
||||
const head = root.createDiv({ cls: "beaver-activity-head" });
|
||||
const conv = this.model.conversation;
|
||||
const title = head.createDiv({ cls: "beaver-activity-title" });
|
||||
title.createSpan({
|
||||
text:
|
||||
conv?.title ||
|
||||
this.file?.basename ||
|
||||
this.conversationId ||
|
||||
"Beaver: activity",
|
||||
});
|
||||
const conn = this.model.connection;
|
||||
const dot = title.createSpan({
|
||||
cls: `beaver-activity-conn is-${conn.state}`,
|
||||
attr: { "aria-label": conn.detail ?? conn.state, title: conn.detail ?? conn.state },
|
||||
});
|
||||
dot.setText(conn.state);
|
||||
|
||||
const meta = head.createDiv({ cls: "beaver-activity-meta" });
|
||||
if (conv) {
|
||||
meta.createSpan({ text: conv.agent });
|
||||
meta.createSpan({ text: conv.kind });
|
||||
meta.createSpan({ text: conv.status });
|
||||
if (conv.running_turn) meta.createSpan({ cls: "is-running", text: "turn running" });
|
||||
if (conv.pending_question) meta.createSpan({ cls: "is-pending", text: "question pending" });
|
||||
} else if (this.conversationId) {
|
||||
meta.createSpan({ text: this.conversationId });
|
||||
}
|
||||
if (conn.state === "failed" || conn.state === "retrying") {
|
||||
head.createDiv({
|
||||
cls: `beaver-activity-note is-${conn.state}`,
|
||||
text: conn.detail ?? conn.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderTurn(parent: HTMLElement, turn: Turn): void {
|
||||
const el = parent.createDiv({ cls: `beaver-activity-turn is-${turn.status}` });
|
||||
const line = el.createDiv({ cls: "beaver-activity-turn-line" });
|
||||
line.createSpan({ cls: "beaver-activity-status", text: turn.status });
|
||||
line.createSpan({ cls: "beaver-activity-origin", text: turn.origin });
|
||||
line.createSpan({ cls: "beaver-activity-time", text: fmtTime(turn.startedAt) });
|
||||
const dur = turn.usage?.duration_ms ?? elapsed(turn.startedAt, turn.endedAt);
|
||||
line.createSpan({ cls: "beaver-activity-duration", text: fmtDuration(dur) });
|
||||
if (turn.usage) {
|
||||
el.createDiv({ cls: "beaver-activity-usage", text: fmtUsage(turn.usage) });
|
||||
}
|
||||
for (const text of turn.says) {
|
||||
const say = el.createDiv({ cls: "beaver-activity-say" });
|
||||
const icon = say.createSpan({ cls: "beaver-activity-icon" });
|
||||
setIcon(icon, "message-square");
|
||||
say.createSpan({ text: clip(text, 300) });
|
||||
}
|
||||
if (turn.roots.length > 0) {
|
||||
const tree = el.createDiv({ cls: "beaver-activity-tree" });
|
||||
for (const node of turn.roots) this.renderNode(tree, node);
|
||||
}
|
||||
}
|
||||
|
||||
private renderNode(parent: HTMLElement, node: ToolNode): void {
|
||||
const el = parent.createDiv({ cls: `beaver-activity-tool is-${node.status}` });
|
||||
const row = el.createDiv({ cls: "beaver-activity-tool-row" });
|
||||
row.createSpan({ cls: "beaver-activity-dot" });
|
||||
row.createSpan({ cls: "beaver-activity-tool-name", text: node.name });
|
||||
const summary = summarizeInput(node.name, node.input);
|
||||
if (summary) row.createSpan({ cls: "beaver-activity-tool-summary", text: summary });
|
||||
const dur = elapsed(node.startedAt, node.endedAt);
|
||||
if (node.status !== "running" || dur >= 2000) {
|
||||
row.createSpan({ cls: "beaver-activity-duration", text: fmtDuration(dur) });
|
||||
}
|
||||
row.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
node.expanded = !node.expanded;
|
||||
this.render();
|
||||
});
|
||||
if (node.expanded) {
|
||||
const details = el.createDiv({ cls: "beaver-activity-tool-details" });
|
||||
if (node.input != null) {
|
||||
details.createEl("pre", {
|
||||
text: JSON.stringify(node.input, null, 2),
|
||||
});
|
||||
}
|
||||
if (node.result) {
|
||||
details.createEl("pre", { cls: "is-result", text: node.result });
|
||||
}
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
const kids = el.createDiv({ cls: "beaver-activity-children" });
|
||||
for (const child of node.children) this.renderNode(kids, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
-124
@@ -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
@@ -4,6 +4,7 @@ import {
|
||||
Notice,
|
||||
Plugin,
|
||||
TFile,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
import {
|
||||
BeaverApiError,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
listAgents,
|
||||
sendChatStream,
|
||||
} from "./api";
|
||||
import { ACTIVITY_VIEW_TYPE, ActivityView } from "./activity";
|
||||
import { pickAgent } from "./agentPicker";
|
||||
import {
|
||||
BeaverSettings,
|
||||
@@ -159,6 +161,21 @@ export default class BeaverPlugin extends Plugin {
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new BeaverSettingsTab(this.app, this));
|
||||
this.registerView(
|
||||
ACTIVITY_VIEW_TYPE,
|
||||
(leaf) => new ActivityView(leaf, this),
|
||||
);
|
||||
|
||||
this.addCommand({
|
||||
id: "open-in-panel",
|
||||
name: "Open chat in panel",
|
||||
checkCallback: (checking) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file || file.extension !== "md") return false;
|
||||
if (!checking) void this.openActivityPanel(file);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "send-selected",
|
||||
@@ -201,6 +218,35 @@ export default class BeaverPlugin extends Plugin {
|
||||
this.lastListFailed = false;
|
||||
}
|
||||
|
||||
// ``conversation_id`` from the note's frontmatter, or null. The panel
|
||||
// and the command both key off this; the gateway writes it after the
|
||||
// first turn (§3.10: frontmatter is only ``agent`` + ``conversation_id``).
|
||||
conversationIdOf(file: TFile | null): string | null {
|
||||
if (!file || file.extension !== "md") return null;
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const raw = cache?.frontmatter?.conversation_id;
|
||||
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
|
||||
}
|
||||
|
||||
// Reveal the activity panel (creating it in the right sidebar on first
|
||||
// use) and point it at ``file``.
|
||||
async openActivityPanel(file: TFile | null): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
let leaf: WorkspaceLeaf | null =
|
||||
workspace.getLeavesOfType(ACTIVITY_VIEW_TYPE)[0] ?? null;
|
||||
if (!leaf) {
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
if (!leaf) {
|
||||
new Notice("Beaver: couldn't open the side panel");
|
||||
return;
|
||||
}
|
||||
await leaf.setViewState({ type: ACTIVITY_VIEW_TYPE, active: true });
|
||||
}
|
||||
await workspace.revealLeaf(leaf);
|
||||
const view = leaf.view;
|
||||
if (view instanceof ActivityView) view.follow(file);
|
||||
}
|
||||
|
||||
private getFrontmatterAgentContext():
|
||||
| { file: TFile; agent: string }
|
||||
| null {
|
||||
|
||||
+50
-10
@@ -1,15 +1,17 @@
|
||||
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
import { listAgents, BeaverApiError } from "./api";
|
||||
import { BeaverApiError, apiBase, listAgents, listApiAgents } from "./api";
|
||||
import type BeaverPlugin from "./main";
|
||||
|
||||
export interface BeaverSettings {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
vaultSubpath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: BeaverSettings = {
|
||||
baseUrl: "http://localhost:62993",
|
||||
apiBaseUrl: "",
|
||||
token: "",
|
||||
vaultSubpath: "",
|
||||
};
|
||||
@@ -18,6 +20,14 @@ export function normalizeSubpath(raw: string): string {
|
||||
return raw.trim().replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof BeaverApiError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
}
|
||||
|
||||
export class BeaverSettingsTab extends PluginSettingTab {
|
||||
plugin: BeaverPlugin;
|
||||
|
||||
@@ -43,9 +53,29 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("API origin")
|
||||
.setDesc(
|
||||
"Origin of the conversations API; `/api/…` is appended to it. " +
|
||||
"Leave empty to derive from Base URL (`…/md` → `…`, port 62993 → 62994). " +
|
||||
"Example: https://beaver.example.com or http://localhost:62994.",
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("(derived)")
|
||||
.setValue(this.plugin.settings.apiBaseUrl)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.apiBaseUrl = value.trim().replace(/\/+$/, "");
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Bearer token")
|
||||
.setDesc("Token with the `messages` scope.")
|
||||
.setDesc(
|
||||
"Token with the `messages` scope for sending and the `api` scope " +
|
||||
"for the activity panel (or a `*` bootstrap token).",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "password";
|
||||
text
|
||||
@@ -75,7 +105,7 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Test connection")
|
||||
.setDesc("Calls GET /agents and reports the count.")
|
||||
.setDesc("Calls GET /agents on the markdown frontend and reports the count.")
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("Test").onClick(async () => {
|
||||
try {
|
||||
@@ -83,13 +113,23 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
||||
this.plugin.cacheAgents(agents);
|
||||
new Notice(`Beaver: found ${agents.length} agents`);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof BeaverApiError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
new Notice(`Beaver: ${msg}`, 8000);
|
||||
new Notice(`Beaver: ${errorText(err)}`, 8000);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Test API")
|
||||
.setDesc("Calls GET /api/agents on the API origin (needs the `api` scope).")
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("Test").onClick(async () => {
|
||||
try {
|
||||
const agents = await listApiAgents(this.plugin.settings);
|
||||
new Notice(
|
||||
`Beaver: API at ${apiBase(this.plugin.settings)} - ${agents.length} agents`,
|
||||
);
|
||||
} catch (err) {
|
||||
new Notice(`Beaver: ${errorText(err)}`, 8000);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Server-Sent Events over a ``fetch`` body. ``EventSource`` can't send a
|
||||
// bearer header and ``requestUrl`` buffers the whole response, so every
|
||||
// SSE consumer in the plugin (chat streaming, the activity panel) goes
|
||||
// through this reader. Frames are ``event: <name>\ndata: <json>\n\n``;
|
||||
// ``data`` may span several lines per the spec, comment lines (``:
|
||||
// keepalive``) are dropped.
|
||||
|
||||
export interface SseFrame {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export function parseSseFrame(frame: string): SseFrame | null {
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const rawLine of frame.split("\n")) {
|
||||
const line = rawLine.replace(/\r$/, "");
|
||||
if (!line || line.startsWith(":")) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const field = line.slice(0, colon);
|
||||
let value = line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
if (field === "event") event = value;
|
||||
else if (field === "data") dataLines.push(value);
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return { event, data: dataLines.join("\n") };
|
||||
}
|
||||
|
||||
// Reads ``body`` to the end (or until ``onFrame`` returns ``"stop"``),
|
||||
// delivering complete frames in order. Partial frames are buffered
|
||||
// across reads. Aborting the underlying fetch rejects ``reader.read()``
|
||||
// with an ``AbortError`` that propagates to the caller.
|
||||
export async function readSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onFrame: (frame: SseFrame) => "continue" | "stop",
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let done = false;
|
||||
try {
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
done = chunk.done;
|
||||
if (chunk.value) buf += decoder.decode(chunk.value, { stream: !done });
|
||||
let sep = buf.indexOf("\n\n");
|
||||
while (sep >= 0) {
|
||||
const frame = parseSseFrame(buf.slice(0, sep));
|
||||
buf = buf.slice(sep + 2);
|
||||
if (frame && onFrame(frame) === "stop") return;
|
||||
sep = buf.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user