feat: init
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { App, FuzzySuggestModal } from "obsidian";
|
||||
|
||||
export class AgentPickerModal extends FuzzySuggestModal<string> {
|
||||
private agents: string[];
|
||||
private resolve: (agent: string | null) => void;
|
||||
private resolved = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
agents: string[],
|
||||
resolve: (agent: string | null) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.agents = agents;
|
||||
this.resolve = resolve;
|
||||
this.setPlaceholder("Pick a Beaver agent…");
|
||||
}
|
||||
|
||||
getItems(): string[] {
|
||||
return this.agents;
|
||||
}
|
||||
|
||||
getItemText(item: string): string {
|
||||
return item;
|
||||
}
|
||||
|
||||
onChooseItem(item: string): void {
|
||||
this.resolved = true;
|
||||
this.resolve(item);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
super.onClose();
|
||||
if (!this.resolved) this.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function pickAgent(app: App, agents: string[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
new AgentPickerModal(app, agents, resolve).open();
|
||||
});
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { requestUrl, RequestUrlParam } from "obsidian";
|
||||
import type { BeaverSettings } from "./settings";
|
||||
|
||||
export interface ChatRequest {
|
||||
filename: string;
|
||||
content: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
status: "ok" | "nothing_to_do" | "in_progress";
|
||||
reason?: string;
|
||||
agent?: string;
|
||||
turns_appended?: number;
|
||||
new_content?: string;
|
||||
}
|
||||
|
||||
export class BeaverApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
constructor(status: number, message: string, body: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(settings: BeaverSettings): string {
|
||||
const url = settings.baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!url) throw new Error("Beaver: base URL is not configured");
|
||||
return url;
|
||||
}
|
||||
|
||||
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}` };
|
||||
}
|
||||
|
||||
async function call(
|
||||
settings: BeaverSettings,
|
||||
init: Omit<RequestUrlParam, "url"> & { path: string },
|
||||
): Promise<unknown> {
|
||||
const { path, ...rest } = init;
|
||||
const url = `${baseUrl(settings)}${path}`;
|
||||
const headers = {
|
||||
...authHeader(settings),
|
||||
...(rest.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 });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
let detail: unknown;
|
||||
try {
|
||||
detail = res.json;
|
||||
} catch {
|
||||
detail = 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);
|
||||
}
|
||||
return res.json;
|
||||
}
|
||||
|
||||
export async function listAgents(settings: BeaverSettings): Promise<string[]> {
|
||||
const body = (await call(settings, { path: "/agents", method: "GET" })) as {
|
||||
agents?: Array<{ name?: unknown }>;
|
||||
};
|
||||
const list = body.agents ?? [];
|
||||
return list
|
||||
.map((a) => (typeof a?.name === "string" ? a.name : null))
|
||||
.filter((n): n is string => !!n);
|
||||
}
|
||||
|
||||
export async function sendChat(
|
||||
settings: BeaverSettings,
|
||||
req: ChatRequest,
|
||||
): Promise<ChatResponse> {
|
||||
return (await call(settings, {
|
||||
path: "/chat",
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(req),
|
||||
})) as ChatResponse;
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
Editor,
|
||||
MarkdownView,
|
||||
Notice,
|
||||
Plugin,
|
||||
TFile,
|
||||
} from "obsidian";
|
||||
import {
|
||||
BeaverApiError,
|
||||
ChatResponse,
|
||||
listAgents,
|
||||
sendChat,
|
||||
} from "./api";
|
||||
import { pickAgent } from "./agentPicker";
|
||||
import {
|
||||
BeaverSettings,
|
||||
BeaverSettingsTab,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "./settings";
|
||||
|
||||
const AGENT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export default class BeaverPlugin extends Plugin {
|
||||
settings: BeaverSettings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
private cachedAgents: string[] | null = null;
|
||||
private cachedAt = 0;
|
||||
private lastListFailed = false;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new BeaverSettingsTab(this.app, this));
|
||||
|
||||
this.addCommand({
|
||||
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);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData(),
|
||||
);
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
cacheAgents(agents: string[]): void {
|
||||
this.cachedAgents = agents;
|
||||
this.cachedAt = Date.now();
|
||||
this.lastListFailed = false;
|
||||
}
|
||||
|
||||
private getFrontmatterAgentContext():
|
||||
| { file: TFile; agent: string }
|
||||
| null {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file || 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() };
|
||||
}
|
||||
|
||||
private async runDifferent(file: TFile): Promise<void> {
|
||||
const agents = await this.getAgents();
|
||||
if (!agents) return;
|
||||
if (agents.length === 0) {
|
||||
new Notice("Beaver: no agents available");
|
||||
return;
|
||||
}
|
||||
const picked = await pickAgent(this.app, agents);
|
||||
if (!picked) return;
|
||||
await this.dispatch(file, picked);
|
||||
}
|
||||
|
||||
private async getAgents(): Promise<string[] | null> {
|
||||
const fresh =
|
||||
this.cachedAgents &&
|
||||
!this.lastListFailed &&
|
||||
Date.now() - this.cachedAt < AGENT_CACHE_TTL_MS;
|
||||
if (fresh) return this.cachedAgents;
|
||||
try {
|
||||
const agents = await listAgents(this.settings);
|
||||
this.cacheAgents(agents);
|
||||
return agents;
|
||||
} catch (err) {
|
||||
this.lastListFailed = true;
|
||||
this.notifyError("listing agents", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(file: TFile, agent: string): Promise<void> {
|
||||
const { editor, view } = this.findEditorFor(file);
|
||||
const content = editor
|
||||
? editor.getValue()
|
||||
: await this.app.vault.read(file);
|
||||
|
||||
const notice = new Notice(`Beaver: sending to ${agent}…`, 0);
|
||||
let response: ChatResponse;
|
||||
try {
|
||||
response = await sendChat(this.settings, {
|
||||
filename: file.path,
|
||||
content,
|
||||
agent,
|
||||
});
|
||||
} catch (err) {
|
||||
notice.hide();
|
||||
if (err instanceof BeaverApiError && err.status === 409) {
|
||||
new Notice("Beaver: already running for this file", 6000);
|
||||
return;
|
||||
}
|
||||
this.notifyError(`sending to ${agent}`, err);
|
||||
return;
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
|
||||
if (response.status === "nothing_to_do") {
|
||||
new Notice(`Beaver: nothing to do (${response.reason ?? "no reason"})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof response.new_content === "string") {
|
||||
await this.writeBack(file, editor, view, response.new_content);
|
||||
}
|
||||
new Notice(`Beaver: ${agent} replied`);
|
||||
}
|
||||
|
||||
private findEditorFor(file: TFile): {
|
||||
editor: Editor | null;
|
||||
view: MarkdownView | null;
|
||||
} {
|
||||
// Walk open markdown views — we want the editor instance that owns
|
||||
// ``file`` so we can use setValue (keeps the buffer's edit history
|
||||
// intact) instead of falling back to vault.modify.
|
||||
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 { editor: view.editor, view };
|
||||
}
|
||||
}
|
||||
return { editor: null, view: null };
|
||||
}
|
||||
|
||||
private async writeBack(
|
||||
file: TFile,
|
||||
editor: Editor | null,
|
||||
_view: MarkdownView | null,
|
||||
newContent: string,
|
||||
): Promise<void> {
|
||||
if (editor && editor.getValue() !== newContent) {
|
||||
editor.setValue(newContent);
|
||||
return;
|
||||
}
|
||||
if (!editor) {
|
||||
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);
|
||||
console.error("Beaver:", action, err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
import { listAgents, BeaverApiError } from "./api";
|
||||
import type BeaverPlugin from "./main";
|
||||
|
||||
export interface BeaverSettings {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: BeaverSettings = {
|
||||
baseUrl: "http://localhost:62993",
|
||||
token: "",
|
||||
};
|
||||
|
||||
export class BeaverSettingsTab extends PluginSettingTab {
|
||||
plugin: BeaverPlugin;
|
||||
|
||||
constructor(app: App, plugin: BeaverPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Base URL")
|
||||
.setDesc("Markdown frontend root, e.g. http://localhost:62993")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("http://localhost:62993")
|
||||
.setValue(this.plugin.settings.baseUrl)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.baseUrl = value.trim().replace(/\/+$/, "");
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Bearer token")
|
||||
.setDesc("Token with the `messages` scope.")
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "password";
|
||||
text
|
||||
.setPlaceholder("paste token")
|
||||
.setValue(this.plugin.settings.token)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.token = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Test connection")
|
||||
.setDesc("Calls GET /agents and reports the count.")
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("Test").onClick(async () => {
|
||||
try {
|
||||
const agents = await listAgents(this.plugin.settings);
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user