Files
2026-05-22 00:18:39 +02:00

224 lines
6.9 KiB
TypeScript

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;
}
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: <name>\ndata: <json>\n\n``
// frames. We can't use ``requestUrl`` (it buffers the whole body), so
// SSE is the one place in the plugin that goes through native
// ``fetch``. CORS is allowed by the gateway's ``CORSMiddleware``; auth
// is the same bearer token as the other endpoints.
export async function sendChatStream(
settings: BeaverSettings,
req: ChatRequest,
cb: StreamChatCallbacks,
signal?: AbortSignal,
): Promise<void> {
const url = `${baseUrl(settings)}/chat/stream`;
const headers: Record<string, string> = {
...authHeader(settings),
"Content-Type": "application/json",
Accept: "text/event-stream",
};
const res = await fetch(url, {
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: <name>`` / ``data: <json>``. ``data`` may span
// multiple lines (concatenated with ``\n``) per the spec; we honour
// that even though the gateway emits single-line ``data:`` today.
let event = "message";
const dataLines: string[] = [];
for (const rawLine of frame.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (!line || line.startsWith(":")) continue;
const colon = line.indexOf(":");
if (colon < 0) continue;
const field = line.slice(0, colon);
let value = line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") event = value;
else if (field === "data") dataLines.push(value);
}
if (dataLines.length === 0) return "continue";
let data: unknown;
try {
data = JSON.parse(dataLines.join("\n"));
} catch {
return "continue";
}
const obj = data as Record<string, unknown>;
if (event === "delta") {
const content = obj.new_content;
if (typeof content === "string") cb.onDelta(content);
return "continue";
}
if (event === "done") {
cb.onDone(obj as unknown as ChatResponse);
return "stop";
}
if (event === "error") {
const code =
typeof obj.status_code === "number" ? obj.status_code : 500;
const detail =
typeof obj.detail === "string" ? obj.detail : `HTTP ${code}`;
throw new BeaverApiError(code, detail, obj);
}
return "continue";
}