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 { 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 & { path: string }, ): Promise { 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 { 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 { return (await call(settings, { path: "/chat", method: "POST", contentType: "application/json", body: JSON.stringify(req), })) as ChatResponse; } export interface StreamChatCallbacks { // Fires for every intermediate snapshot (full file content as the // gateway would have written it, frontmatter included). Caller is // 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; // Fires exactly once at end-of-turn (success or nothing_to_do). onDone(response: ChatResponse): void; } // Server-Sent Events arrive as ``event: \ndata: \n\n`` // frames. We can't use ``requestUrl`` (it buffers the whole body), so // SSE is the one place in the plugin that goes through native // ``fetch``. CORS is allowed by the gateway's ``CORSMiddleware``; auth // is the same bearer token as the other endpoints. export async function sendChatStream( settings: BeaverSettings, req: ChatRequest, cb: StreamChatCallbacks, signal?: AbortSignal, ): Promise { const url = `${baseUrl(settings)}/chat/stream`; const headers: Record = { ...authHeader(settings), "Content-Type": "application/json", Accept: "text/event-stream", }; const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(req), signal, }); if (!res.ok || !res.body) { // Drain the body so we can surface a useful detail. 409 in // particular returns JSON; the rest may be JSON or plain text. const text = await res.text().catch(() => ""); let detail: unknown = text; try { const parsed = JSON.parse(text) as { detail?: unknown }; detail = parsed && typeof parsed === "object" && "detail" in parsed ? parsed.detail : parsed; } catch { // not JSON; leave detail as the raw text } const msg = typeof detail === "string" && detail ? detail : `HTTP ${res.status}`; throw new BeaverApiError(res.status, msg, detail); } const reader = res.body.getReader(); const decoder = new TextDecoder(); // SSE frames are separated by a blank line (``\n\n``). We buffer // partial frames across reads, then flush full ones in order. let buf = ""; let done = false; while (!done) { const chunk = await reader.read(); done = chunk.done; if (chunk.value) buf += decoder.decode(chunk.value, { stream: !done }); let sep = buf.indexOf("\n\n"); while (sep >= 0) { const frame = buf.slice(0, sep); buf = buf.slice(sep + 2); const handled = handleSseFrame(frame, cb); if (handled === "stop") { // ``done``/``error`` is terminal — stop reading even if the // server sends extra padding before closing. try { await reader.cancel(); } catch { // best-effort cleanup } return; } sep = buf.indexOf("\n\n"); } } } function handleSseFrame( frame: string, cb: StreamChatCallbacks, ): "continue" | "stop" { // SSE lines: ``event: `` / ``data: ``. ``data`` may span // multiple lines (concatenated with ``\n``) per the spec; we honour // that even though the gateway emits single-line ``data:`` today. let event = "message"; const dataLines: string[] = []; for (const rawLine of frame.split("\n")) { const line = rawLine.replace(/\r$/, ""); if (!line || line.startsWith(":")) continue; const colon = line.indexOf(":"); if (colon < 0) continue; const field = line.slice(0, colon); let value = line.slice(colon + 1); if (value.startsWith(" ")) value = value.slice(1); if (field === "event") event = value; else if (field === "data") dataLines.push(value); } if (dataLines.length === 0) return "continue"; let data: unknown; try { data = JSON.parse(dataLines.join("\n")); } catch { return "continue"; } const obj = data as Record; if (event === "delta") { const content = obj.new_content; if (typeof content === "string") cb.onDelta(content); return "continue"; } if (event === "done") { cb.onDone(obj as unknown as ChatResponse); return "stop"; } if (event === "error") { const code = typeof obj.status_code === "number" ? obj.status_code : 500; const detail = typeof obj.detail === "string" ? obj.detail : `HTTP ${code}`; throw new BeaverApiError(code, detail, obj); } return "continue"; }