Spaces:
Running
Running
File size: 5,298 Bytes
a32aee9 f1fa34c a32aee9 c2d8746 a32aee9 f1fa34c a32aee9 f1fa34c a32aee9 f1fa34c a32aee9 f1fa34c a32aee9 c2d8746 a32aee9 f1fa34c c2d8746 f1fa34c 73db263 c2d8746 73db263 a32aee9 f1fa34c c2d8746 a32aee9 c2d8746 a32aee9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | // SSE client for the streaming chat endpoint.
//
// EventSource only supports GET, but /api/chat/stream is a POST carrying the
// api_key + chat history in its body, so we read the stream manually from
// fetch()'s ReadableStream and parse SSE frames ("event:"/"data:" blocks).
import { API_BASE_URL } from "./api";
import { clearSession, getSession, isAccessTokenStale, refreshSession } from "./authStore";
import type { ChatMessage, ChatSource, Reference, WhereFilter } from "./types";
export interface ChatStreamMeta {
references: Reference[];
openalex_used: number;
uploaded_used: number;
reasoning: string;
source: ChatSource;
}
/**
* Completion is signalled by the returned promise settling, not by a callback.
* A separate `onDone` was a second completion channel that every early return
* had to remember to fire — and the one path that could not fire it (abort)
* was exactly the path that left the caller's "streaming" flag stuck on.
*/
export interface ChatStreamHandlers {
onToken: (text: string) => void;
onMeta: (meta: ChatStreamMeta) => void;
onError: (message: string) => void;
}
export interface ChatStreamParams {
query: string;
chatHistory: Pick<ChatMessage, "role" | "content">[];
apiKey: string;
model: string;
where: WhereFilter;
kbOnly: boolean;
signal?: AbortSignal;
}
export async function streamChat(
params: ChatStreamParams,
handlers: ChatStreamHandlers,
): Promise<void> {
const payload = JSON.stringify({
query: params.query,
chat_history: params.chatHistory,
api_key: params.apiKey,
model: params.model,
where: params.where,
kb_only: params.kbOnly,
});
// Refresh proactively — a stream that 401s mid-flight cannot be replayed
// cleanly, so it is much better to renew before opening it.
let session = getSession();
if (session && isAccessTokenStale(session)) {
session = await refreshSession(API_BASE_URL);
}
const open = (token?: string) =>
fetch(`${API_BASE_URL}/api/chat/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: params.signal,
body: payload,
});
let res: Response;
try {
res = await open(session?.accessToken);
if (res.status === 401 && getSession()) {
const refreshed = await refreshSession(API_BASE_URL);
if (refreshed) {
res = await open(refreshed.accessToken);
} else {
clearSession();
handlers.onError("Your session has expired. Please sign in again.");
return;
}
}
} catch {
handlers.onError(`Cannot reach the API at ${API_BASE_URL}. Is the backend running?`); return;
}
if (res.status === 429) {
handlers.onError("You're sending messages too quickly. Please wait a moment and retry."); return;
}
// The backend sleeps when idle; while it reloads its models the proxy returns
// 502/503/504. That is a wait, not a failure — say so rather than showing a
// raw gateway error.
if (res.status === 502 || res.status === 503 || res.status === 504) {
handlers.onError(
"The server is waking up and loading its models. Give it a minute, then send your question again.",
); return;
}
if (!res.ok || !res.body) {
let detail = `Chat request failed (${res.status}).`;
try {
detail = (await res.json())?.message || detail;
} catch {
/* non-JSON body */
}
handlers.onError(detail); return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (frame: string) => {
// A frame is a block separated by a blank line, e.g.
// event: token\ndata: {"text":"..."}
let event = "message";
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) return;
let payload: unknown;
try {
payload = JSON.parse(dataLines.join("\n"));
} catch {
return;
}
switch (event) {
case "token":
handlers.onToken((payload as { text: string }).text ?? "");
break;
case "meta":
handlers.onMeta(payload as ChatStreamMeta);
break;
case "error":
handlers.onError((payload as { message: string }).message ?? "Unknown error.");
break;
// "done" needs no case: the frame simply ends the stream, and the caller
// learns that from the promise settling.
}
};
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
// SSE frames are separated by a blank line ("\n\n").
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
if (frame.trim()) dispatch(frame);
}
}
if (buffer.trim()) dispatch(buffer);
} catch (err) {
if ((err as Error)?.name !== "AbortError") {
handlers.onError("Connection interrupted while streaming the answer.");
}
}
}
|