feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests
This commit is contained in:
-657
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user