224 lines
6.9 KiB
TypeScript
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";
|
|
}
|