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 { 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; 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(); } }