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