feat: streaming via extension

This commit is contained in:
hh
2026-05-22 00:18:39 +02:00
parent 93b52f0621
commit d2e9d5911c
2 changed files with 301 additions and 17 deletions
+132
View File
@@ -89,3 +89,135 @@ export async function sendChat(
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";
}