rebase
This commit is contained in:
301
frontend/src/lib/api.ts
Normal file
301
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
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";
|
||||
|
||||
const BASE_URL = "/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<string, QueryValue>;
|
||||
|
||||
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<string, QueryValue>): Query {
|
||||
return obj;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
body?: unknown;
|
||||
query?: Query;
|
||||
formData?: FormData;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(res: Response): Promise<T> {
|
||||
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<string | null> {
|
||||
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<AuthResponse>(res);
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
return data.access_token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, query, formData, raw } = opts;
|
||||
const url = buildUrl(path, query);
|
||||
|
||||
const doFetch = (token: string | null): Promise<Response> => {
|
||||
const headers: Record<string, string> = {};
|
||||
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<T>(res);
|
||||
}
|
||||
|
||||
// ===== Auth API =====
|
||||
export const AuthApi = {
|
||||
register: (payload: RegisterPayload) =>
|
||||
request<User>("/register", { method: "POST", body: payload }),
|
||||
registerAdmin: (payload: AdminRegisterPayload) =>
|
||||
request<User>("/register/admin", { method: "POST", body: payload }),
|
||||
login: (payload: LoginPayload) =>
|
||||
request<AuthResponse>("/auth/login", { method: "POST", body: payload }),
|
||||
refresh: (refreshToken: string) =>
|
||||
request<AuthResponse>("/auth/refresh", { method: "POST", body: { refresh_token: refreshToken } }),
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
me: () => request<User>("/auth/me"),
|
||||
};
|
||||
|
||||
// ===== Worlds API =====
|
||||
export type ListWorldsQuery = {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
status_filter?: string;
|
||||
};
|
||||
|
||||
export const WorldsApi = {
|
||||
list: (query: ListWorldsQuery = {}) =>
|
||||
request<Paginated<WorldListItem>>("/worlds", { query: { ...query } }),
|
||||
get: (id: string) => request<World>(`/worlds/${id}`),
|
||||
create: (payload: CreateWorldPayload) =>
|
||||
request<CreateWorldResponse>("/worlds", { method: "POST", body: payload }),
|
||||
update: (id: string, payload: Partial<World>) =>
|
||||
request<World>(`/worlds/${id}`, { method: "PATCH", body: payload }),
|
||||
remove: (id: string) => request<void>(`/worlds/${id}`, { method: "DELETE" }),
|
||||
edit: (id: string, payload: EditWorldPayload) =>
|
||||
request<EditWorldResponse>(`/worlds/${id}/edit`, { method: "POST", body: payload }),
|
||||
};
|
||||
|
||||
// ===== Sessions API =====
|
||||
export const SessionsApi = {
|
||||
state: (worldId: string) =>
|
||||
request<SessionState>(`/sessions/worlds/${worldId}/state`),
|
||||
iterate: (worldId: string, action: string, actionSource: string) =>
|
||||
request<IterateResponse>(`/sessions/worlds/${worldId}/iterate`, {
|
||||
method: "POST",
|
||||
body: { action, action_source: actionSource },
|
||||
}),
|
||||
retry: (worldId: string) =>
|
||||
request<RetryResponse>(`/sessions/worlds/${worldId}/retry`, { method: "POST" }),
|
||||
rollback: (worldId: string) =>
|
||||
request<void>(`/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<Paginated<PresetListItem>>("/presets"),
|
||||
get: (id: string) => request<WorldPreset>(`/presets/${id}`),
|
||||
create: (payload: Omit<WorldPreset, "id" | "created_at" | "status" | "version">) =>
|
||||
request<WorldPreset>("/presets", { method: "POST", body: payload }),
|
||||
update: (id: string, payload: Partial<WorldPreset>) =>
|
||||
request<WorldPreset>(`/presets/${id}`, { method: "PATCH", body: payload }),
|
||||
remove: (id: string) => request<void>(`/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<AdminSettingsResponse>("/admin/settings"),
|
||||
updateSettings: (settings: Record<string, string>) =>
|
||||
request<AdminSettingsResponse>("/admin/settings", { method: "PATCH", body: { settings } }),
|
||||
llmLogs: (query: LlmLogsQuery = {}) =>
|
||||
request<Paginated<LlmLog>>("/admin/llm-logs", { query: { ...query } }),
|
||||
llmLog: (id: string) => request<LlmLogDetail>(`/admin/llm-logs/${id}`),
|
||||
users: () => request<Paginated<User>>("/admin/users"),
|
||||
updateUser: (id: string, payload: { is_admin?: boolean; is_active?: boolean }) =>
|
||||
request<User>(`/admin/users/${id}`, { method: "PATCH", body: payload }),
|
||||
stats: () => request<AdminStats>("/admin/stats"),
|
||||
testLlm: (apiUrl: string, apiKey: string, model: string) =>
|
||||
request<LlmTestResult>(
|
||||
"/admin/test/llm",
|
||||
{ method: "POST", query: { api_url: apiUrl, api_key: apiKey, model } },
|
||||
),
|
||||
testLlmTools: (params: Record<string, string>) =>
|
||||
request<LlmToolsTestResult>("/admin/test/llm-tools", { method: "POST", query: params }),
|
||||
testEmbeddings: (params: Record<string, string>) =>
|
||||
request<EmbeddingsTestResult>("/admin/test/embeddings", { method: "POST", query: params }),
|
||||
probeDimension: (params: Record<string, string>) =>
|
||||
request<EmbeddingsProbeResult>(
|
||||
"/admin/test/embeddings/probe-dimension",
|
||||
{ method: "POST", query: params },
|
||||
),
|
||||
recreateCollections: () =>
|
||||
request<RecreateCollectionsResult>("/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<UploadIconResult>("/admin/upload-icon", { method: "POST", formData: fd });
|
||||
},
|
||||
};
|
||||
|
||||
export const MiscApi = {
|
||||
health: () => request<HealthResponse>("/health"),
|
||||
};
|
||||
Reference in New Issue
Block a user