hmc-rag / frontend /src /lib /api.ts
webmuppet
Refactor: replace Streamlit with React+Vite SPA + FastAPI server
b5c0df4
Raw
History Blame Contribute Delete
4.41 kB
import type { Message, QueryResult } from "../types";
const BASE_URL = "";
/**
* POST /api/auth/login
*
* Sends the shared password to the API and returns a signed JWT on success.
* Throws an error with message "Invalid password" on HTTP 401.
* Throws a generic error for any other non-200 response.
*/
export async function login(password: string): Promise<{ token: string }> {
const response = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (response.status === 401) {
throw new Error("Invalid password");
}
if (!response.ok) {
throw new Error(`Login failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<{ token: string }>;
}
/**
* POST /api/query/stream
*
* Opens a streaming SSE connection to the query endpoint and dispatches
* events to the provided callbacks:
* - onToken(text) — called for each `token` SSE event
* - onDone(meta) — called once when the `done` SSE event is received
* - onError(msg) — called if an `error` SSE event is received
*
* Uses fetch + ReadableStream so the JWT can be sent in the Authorization
* header (EventSource does not support custom headers).
*/
export async function streamQuery(
payload: {
query: string;
history: Message[];
profession: string | null;
language: string | null;
},
token: string,
signal: AbortSignal,
onToken: (text: string) => void,
onDone: (metadata: QueryResult) => void,
onError: (message: string) => void
): Promise<void> {
const response = await fetch(`${BASE_URL}/api/query/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
signal,
});
if (response.status === 401) {
// Throw a recognisable message so useStream can skip retries and
// App.tsx can call handle401() to clear the token and show the gate.
throw new Error("Unauthorized");
}
if (!response.ok) {
throw new Error(
`Stream request failed: ${response.status} ${response.statusText}`
);
}
if (!response.body) {
throw new Error("Response body is null — streaming not supported");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
// Buffer for incomplete lines that span chunk boundaries
let lineBuffer = "";
// The most recently seen `event:` line value
let currentEvent = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
lineBuffer += decoder.decode(value, { stream: true });
// Process all complete lines (terminated by \n)
const lines = lineBuffer.split("\n");
// Keep the last (potentially incomplete) segment in the buffer
lineBuffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd(); // strip trailing \r if present
if (line === "") {
// Blank line = end of SSE message; reset event type
currentEvent = "";
continue;
}
if (line.startsWith("event:")) {
currentEvent = line.slice("event:".length).trim();
continue;
}
if (line.startsWith("data:")) {
const jsonStr = line.slice("data:".length).trim();
try {
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
switch (currentEvent) {
case "token": {
const text = parsed["text"];
if (typeof text === "string") {
onToken(text);
}
break;
}
case "done": {
onDone(parsed as unknown as QueryResult);
break;
}
case "error": {
const message = parsed["message"];
onError(typeof message === "string" ? message : jsonStr);
return; // Close immediately on error event
}
default:
// Unknown event type — ignore
break;
}
} catch {
// Malformed JSON in data line — skip silently
}
}
}
}
} finally {
reader.releaseLock();
}
}