feat: streaming via extension
This commit is contained in:
+132
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user