File size: 5,760 Bytes
b9b47ab | 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 | /**
* 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;
}
}
|