Muhammad Usman
feat(phase-7): implement FastAPI backend, React SPA frontend, and Docker deployment
b9b47ab
Raw History Blame Contribute Delete
5.76 kB
/**
* HTTP client for the research API.
*
* The backend returns a uniform error envelope ({error, message, ...}) and never
* rewrites the messages raised by the core Python modules, so this layer surfaces
* `message` verbatim and keeps the structured fields the UI needs to tell the
* different failure modes apart.
*/
const BASE_URL = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
export class ApiError extends Error {
constructor(status, payload) {
super(payload?.message || 'Request failed.');
this.name = 'ApiError';
this.status = status;
this.code = payload?.error || 'unknown_error';
this.retryAfterSeconds = payload?.retry_after_seconds ?? null;
this.remainingLockoutSeconds = payload?.remaining_lockout_seconds ?? null;
// 'personal' vs 'global' for daily quota failures — two different situations.
this.capScope = payload?.cap_scope ?? null;
}
}
async function parseBody(response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}
async function request(path, { method = 'GET', body, token, signal } = {}) {
const headers = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers.Authorization = `Bearer ${token}`;
let response;
try {
response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new ApiError(0, { error: 'network_error', message: 'Could not reach the research service.' });
}
const payload = await parseBody(response);
if (!response.ok) throw new ApiError(response.status, payload);
return payload;
}
/* ===== Auth ================================================================= */
export const register = (username, password, email) =>
request('/auth/register', { method: 'POST', body: { username, password, email: email || null } });
export const login = (username, password) =>
request('/auth/login', { method: 'POST', body: { username, password } });
export const forgotPassword = (identifier) =>
request('/auth/forgot-password', { method: 'POST', body: { identifier } });
export const resetPassword = (token, newPassword) =>
request('/auth/reset-password', { method: 'POST', body: { token, new_password: newPassword } });
/* ===== Quota & history ====================================================== */
export const getQuota = ({ token, guestId, signal } = {}) => {
const qs = !token && guestId ? `?session_id=${encodeURIComponent(guestId)}` : '';
return request(`/quota${qs}`, { token, signal });
};
export const listHistory = (token, signal) => request('/history', { token, signal });
export const getHistoryItem = (token, sessionId, signal) =>
request(`/history/${encodeURIComponent(sessionId)}`, { token, signal });
export const deleteHistoryItem = (token, sessionId) =>
request(`/history/${encodeURIComponent(sessionId)}`, { method: 'DELETE', token });
export const getHealth = (signal) => request('/health', { signal });
/* ===== Research stream ====================================================== */
/**
* Open the research SSE stream.
*
* `fetch` is used rather than `EventSource` for two reasons: EventSource cannot send
* an Authorization header (the access token must never travel in a URL), and it cannot
* surface the HTTP status of a rejected request — which is exactly how the backend
* reports a spent quota or an invalid query before the stream opens.
*
* @param {object} args
* @param {string} args.query The research question.
* @param {string} [args.token] Bearer token for an authenticated run.
* @param {string} [args.guestId] Scoped guest id for an unauthenticated run.
* @param {AbortSignal} [args.signal]
* @param {(event: string, data: object) => void} args.onEvent
*/
export async function streamResearch({ query, token, guestId, signal, onEvent }) {
const params = new URLSearchParams({ query });
if (!token && guestId) params.set('session_id', guestId);
const headers = { Accept: 'text/event-stream' };
if (token) headers.Authorization = `Bearer ${token}`;
let response;
try {
response = await fetch(`${BASE_URL}/research/stream?${params.toString()}`, { headers, signal });
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new ApiError(0, { error: 'network_error', message: 'Could not reach the research service.' });
}
if (!response.ok) {
throw new ApiError(response.status, await parseBody(response));
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary;
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const parsed = parseFrame(frame);
if (parsed) onEvent(parsed.event, parsed.data);
}
}
}
function parseFrame(frame) {
const trimmed = frame.trim();
// ':' prefixed frames are keep-alive comments.
if (!trimmed || trimmed.startsWith(':')) return null;
let event = 'message';
const dataLines = [];
for (const line of trimmed.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) return null;
try {
return { event, data: JSON.parse(dataLines.join('')) };
} catch {
return null;
}
}