import type { AdminRegisterPayload, AdminSettingsResponse, AdminStats, AuthResponse, CreateWorldPayload, CreateWorldResponse, EditWorldPayload, EditWorldResponse, EmbeddingsProbeResult, EmbeddingsTestResult, HealthResponse, IterateResponse, LlmLog, LlmLogDetail, LlmTestResult, LlmToolsTestResult, LoginPayload, Paginated, PresetListItem, RecreateCollectionsResult, RegisterPayload, RetryResponse, SessionState, UploadIconResult, User, World, WorldListItem, WorldPreset, } from "@/types"; // Use VITE_API_BASE_URL if set (for local `npm run dev` pointing at a separate // backend); otherwise default to relative "/api" which works behind the nginx // reverse proxy in the Docker deployment. const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") || "/api"; const ACCESS_TOKEN_KEY = "airpg_access_token"; const REFRESH_TOKEN_KEY = "airpg_refresh_token"; export class ApiError extends Error { status: number; details?: unknown; constructor(status: number, message: string, details?: unknown) { super(message); this.name = "ApiError"; this.status = status; this.details = details; } } export function getAccessToken(): string | null { try { return localStorage.getItem(ACCESS_TOKEN_KEY); } catch { return null; } } export function getRefreshToken(): string | null { try { return localStorage.getItem(REFRESH_TOKEN_KEY); } catch { return null; } } export function setTokens(access: string, refresh?: string): void { try { localStorage.setItem(ACCESS_TOKEN_KEY, access); if (refresh) localStorage.setItem(REFRESH_TOKEN_KEY, refresh); } catch { /* ignore */ } } export function clearTokens(): void { try { localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); } catch { /* ignore */ } } type QueryValue = string | number | boolean | undefined | null; type Query = Record; function buildUrl(path: string, query?: Query): string { const url = `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; if (!query) return url; const params = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null && v !== "") params.append(k, String(v)); } const qs = params.toString(); return qs ? `${url}?${qs}` : url; } function toQuery(obj: Record): Query { return obj; } interface RequestOptions { method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; query?: Query; formData?: FormData; raw?: boolean; } async function parseResponse(res: Response): Promise { if (res.status === 204) return undefined as T; const text = await res.text(); if (!text) return undefined as T; try { return JSON.parse(text) as T; } catch { return text as unknown as T; } } let unauthorizedHandler: (() => void) | null = null; export function setUnauthorizedHandler(handler: () => void): void { unauthorizedHandler = handler; } async function refreshAccessToken(): Promise { const refresh = getRefreshToken(); if (!refresh) return null; try { const res = await fetch(`${BASE_URL}/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refresh }), }); if (!res.ok) return null; const data = await parseResponse(res); setTokens(data.access_token, data.refresh_token); return data.access_token; } catch { return null; } } export async function request(path: string, opts: RequestOptions = {}): Promise { const { method = "GET", body, query, formData, raw } = opts; const url = buildUrl(path, query); const doFetch = (token: string | null): Promise => { const headers: Record = {}; if (token) headers.Authorization = `Bearer ${token}`; if (formData) { // Let the browser set the multipart Content-Type with boundary return fetch(url, { method, headers, body: formData }); } if (body !== undefined) headers["Content-Type"] = "application/json"; return fetch(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); }; let res = await doFetch(getAccessToken()); // Try token refresh on 401 if (res.status === 401 && getRefreshToken()) { const newToken = await refreshAccessToken(); if (newToken) { res = await doFetch(newToken); } } if (res.status === 401) { clearTokens(); unauthorizedHandler?.(); const data = (await parseResponse<{ detail?: string }>(res).catch(() => ({}))) as { detail?: string }; throw new ApiError(401, data?.detail || "Unauthorized"); } if (!res.ok) { const data = (await parseResponse<{ detail?: string; message?: string }>(res).catch(() => ({}))) as { detail?: string; message?: string }; const message = data?.detail || data?.message || `Request failed (${res.status})`; throw new ApiError(res.status, message, data); } if (raw) return res as unknown as T; return parseResponse(res); } // ===== Auth API ===== export const AuthApi = { register: (payload: RegisterPayload) => request("/register", { method: "POST", body: payload }), registerAdmin: (payload: AdminRegisterPayload) => request("/register/admin", { method: "POST", body: payload }), login: (payload: LoginPayload) => request("/auth/login", { method: "POST", body: payload }), refresh: (refreshToken: string) => request("/auth/refresh", { method: "POST", body: { refresh_token: refreshToken } }), logout: () => request("/auth/logout", { method: "POST" }), me: () => request("/auth/me"), }; // ===== Worlds API ===== export type ListWorldsQuery = { page?: number; per_page?: number; status_filter?: string; }; export const WorldsApi = { list: (query: ListWorldsQuery = {}) => request>("/worlds", { query: { ...query } }), get: (id: string) => request(`/worlds/${id}`), create: (payload: CreateWorldPayload) => request("/worlds", { method: "POST", body: payload }), update: (id: string, payload: Partial) => request(`/worlds/${id}`, { method: "PATCH", body: payload }), remove: (id: string) => request(`/worlds/${id}`, { method: "DELETE" }), edit: (id: string, payload: EditWorldPayload) => request(`/worlds/${id}/edit`, { method: "POST", body: payload }), }; // ===== Sessions API ===== export const SessionsApi = { state: (worldId: string) => request(`/sessions/worlds/${worldId}/state`), iterate: (worldId: string, action: string, actionSource: string) => request(`/sessions/worlds/${worldId}/iterate`, { method: "POST", body: { action, action_source: actionSource }, }), retry: (worldId: string) => request(`/sessions/worlds/${worldId}/retry`, { method: "POST" }), rollback: (worldId: string) => request(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }), // SSE stream URLs (used by SSE client) iterateStreamUrl: (worldId: string, stepId: string) => buildUrl(`/sessions/worlds/${worldId}/iterate/stream`, { step_id: stepId }), builderStreamUrl: (worldId: string) => buildUrl(`/sessions/worlds/${worldId}/builder/stream`), editorStreamUrl: (worldId: string, instruction: string) => buildUrl(`/sessions/worlds/${worldId}/editor/stream`, { instruction }), }; // ===== Presets API ===== export const PresetsApi = { list: () => request>("/presets"), get: (id: string) => request(`/presets/${id}`), create: (payload: Omit) => request("/presets", { method: "POST", body: payload }), update: (id: string, payload: Partial) => request(`/presets/${id}`, { method: "PATCH", body: payload }), remove: (id: string) => request(`/presets/${id}`, { method: "DELETE" }), }; // ===== Admin API ===== export type LlmLogsQuery = { world_id?: string; stage?: string; status_filter?: string; page?: number; per_page?: number; }; export const AdminApi = { settings: () => request("/admin/settings"), updateSettings: (settings: Record) => request("/admin/settings", { method: "PATCH", body: { settings } }), llmLogs: (query: LlmLogsQuery = {}) => request>("/admin/llm-logs", { query: { ...query } }), llmLog: (id: string) => request(`/admin/llm-logs/${id}`), users: () => request>("/admin/users"), updateUser: (id: string, payload: { is_admin?: boolean; is_active?: boolean }) => request(`/admin/users/${id}`, { method: "PATCH", body: payload }), stats: () => request("/admin/stats"), testLlm: (apiUrl: string, apiKey: string, model: string) => request( "/admin/test/llm", { method: "POST", query: { api_url: apiUrl, api_key: apiKey, model } }, ), testLlmTools: (params: Record) => request("/admin/test/llm-tools", { method: "POST", query: params }), testEmbeddings: (params: Record) => request("/admin/test/embeddings", { method: "POST", query: params }), probeDimension: (params: Record) => request( "/admin/test/embeddings/probe-dimension", { method: "POST", query: params }, ), recreateCollections: () => request("/admin/embeddings/recreate-collections", { method: "POST" }), uploadIcon: (file: File, kind: "favicon" | "logo" | "og_image") => { const fd = new FormData(); fd.append("file", file); fd.append("kind", kind); return request("/admin/upload-icon", { method: "POST", formData: fd }); }, }; export const MiscApi = { health: () => request("/health"), };