Files
ai-rpg/frontend/src/lib/api.ts

302 lines
9.7 KiB
TypeScript
Raw Normal View History

2026-06-20 19:13:05 +03:00
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"),
};