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;
  }
}