grapholab / frontend /src /lib /api.ts
Fabio Antonini
feat: per-user OpenAI key + model preferences persisted in DB
413c3b1
/**
* GraphoLab API client.
* All requests go through /api (proxied to http://localhost:8000 in dev).
* Access token is attached automatically; on 401 a refresh is attempted once.
*/
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios"
import { useAuthStore } from "@/store/auth"
export const api = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
})
// ── Request interceptor: attach access token ──────────────────────────────────
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().accessToken
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// ── Response interceptor: auto-refresh on 401 ────────────────────────────────
let _refreshing = false
let _queue: Array<(token: string) => void> = []
api.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
original._retry = true
if (_refreshing) {
return new Promise((resolve) => {
_queue.push((token) => {
original.headers.Authorization = `Bearer ${token}`
resolve(api(original))
})
})
}
_refreshing = true
try {
const { refreshToken, setTokens, logout } = useAuthStore.getState()
if (!refreshToken) { logout(); return Promise.reject(error) }
const res = await axios.post("/api/auth/refresh", { refresh_token: refreshToken })
const { access_token, refresh_token } = res.data
setTokens(access_token, refresh_token)
_queue.forEach((cb) => cb(access_token))
_queue = []
original.headers.Authorization = `Bearer ${access_token}`
return api(original)
} catch {
useAuthStore.getState().logout()
return Promise.reject(error)
} finally {
_refreshing = false
}
}
)
// ── Typed helpers ─────────────────────────────────────────────────────────────
export interface TokenResponse {
access_token: string
refresh_token: string
}
export interface User {
id: number
email: string
full_name: string
role: "admin" | "examiner" | "viewer"
is_active: boolean
organization_id: number | null
}
export interface Project {
id: number
title: string
description: string | null
status: "draft" | "in_progress" | "completed" | "archived"
owner_id: number
document_count: number
}
export interface Document {
id: number
filename: string
content_type: string
size_bytes: number
storage_key: string
}
export interface Analysis {
id: number
analysis_type: string
result_text: string | null
result_storage_key: string | null
project_id: number
document_id: number | null
}
// Auth
export const authApi = {
login: (email: string, password: string) =>
api.post<TokenResponse>("/auth/login", new URLSearchParams({ username: email, password }), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
}),
refresh: (refreshToken: string) =>
api.post<TokenResponse>("/auth/refresh", { refresh_token: refreshToken }),
logout: () => api.post("/auth/logout"),
generateResetToken: (userId: number) =>
api.post<{ token: string }>("/auth/reset-password/generate", { user_id: userId }),
confirmReset: (token: string, newPassword: string) =>
api.post("/auth/reset-password/confirm", { token, new_password: newPassword }),
}
// Users
export const usersApi = {
me: () => api.get<User>("/users/me"),
list: () => api.get<User[]>("/users/"),
create: (data: { email: string; full_name: string; password: string; role?: string }) =>
api.post<User>("/users/", data),
updateMe: (data: { full_name?: string; current_password?: string; new_password?: string }) =>
api.put<User>("/users/me", data),
deactivate: (id: number) => api.delete(`/users/${id}`),
// Per-user settings (OpenAI key + model preferences stored in DB)
getSettings: () => api.get<{
openai_key_configured: boolean
rag_model: string | null
vlm_model: string | null
ocr_model: string | null
embed_model: string | null
}>("/users/me/settings"),
saveOpenAIKey: (openai_api_key: string) =>
api.put<{ openai_key_configured: boolean }>("/users/me/settings", { openai_api_key }),
deleteOpenAIKey: () => api.delete("/users/me/settings/openai-key"),
saveModelPreferences: (prefs: {
rag_model?: string
vlm_model?: string
ocr_model?: string
embed_model?: string
}) => api.patch("/users/me/settings/models", prefs),
}
// Projects
export const projectsApi = {
list: () => api.get<Project[]>("/projects/"),
create: (data: { title: string; description?: string }) => api.post<Project>("/projects/", data),
get: (id: number) => api.get<Project>(`/projects/${id}`),
update: (id: number, data: Partial<Project>) => api.put<Project>(`/projects/${id}`, data),
delete: (id: number) => api.delete(`/projects/${id}`),
uploadDocument: (projectId: number, file: File) => {
const fd = new FormData()
fd.append("file", file)
return api.post<Document>(`/projects/${projectId}/documents`, fd, {
headers: { "Content-Type": "multipart/form-data" },
})
},
listDocuments: (projectId: number) => api.get<Document[]>(`/projects/${projectId}/documents`),
deleteDocument: (projectId: number, docId: number) =>
api.delete(`/projects/${projectId}/documents/${docId}`),
}
// Analysis
export const analysisApi = {
run: (type: string, projectId: number, documentId: number) => {
const pathMap: Record<string, string> = { writer_identification: "writer" }
const path = pathMap[type] ?? type.replaceAll("_", "-")
return api.post<Analysis>(`/analysis/${path}`, { project_id: projectId, document_id: documentId })
},
runSignatureVerification: (projectId: number, documentId: number, referenceDocumentId: number) =>
api.post<Analysis>("/analysis/signature-verification", { project_id: projectId, document_id: documentId, reference_document_id: referenceDocumentId }),
runPipeline: (projectId: number, documentId: number, referenceDocumentId?: number) =>
api.post<Analysis>("/analysis/pipeline", { project_id: projectId, document_id: documentId, reference_document_id: referenceDocumentId ?? null }),
list: (projectId: number) => api.get<Analysis[]>(`/analysis/project/${projectId}`),
clearAll: (projectId: number) => api.delete(`/analysis/project/${projectId}`),
deleteOne: (analysisId: number) => api.delete(`/analysis/${analysisId}`),
imageUrl: (analysisId: number) => `/api/analysis/${analysisId}/image`,
}
// Audit
export interface AuditLogEntry {
id: number
timestamp: string
user_id: number | null
user_email: string
action: string
resource_type: string | null
resource_id: number | null
detail: string | null
ip_address: string | null
}
export interface AuditPage {
total: number
items: AuditLogEntry[]
}
export const auditApi = {
list: (page = 1, pageSize = 50, action?: string, userEmail?: string) => {
const params: Record<string, string | number> = { page, page_size: pageSize }
if (action) params.action = action
if (userEmail) params.user_email = userEmail
return api.get<AuditPage>("/audit/", { params })
},
}
// Compliance
export interface ComplianceBlock {
num: number
name: string
verdict: "✅" | "⚠️" | "❌" | null
motivazione: string
suggerimento: string | null
}
export const complianceApi = {
status: () => api.get<{ ollama_reachable: boolean }>("/compliance/status"),
pdf: (data: {
filename: string
blocks: ComplianceBlock[]
conformi: number
parziali: number
mancanti: number
judgment: string
}) => api.post("/compliance/pdf", data, { responseType: "blob" }),
}
// Agent Projects
export interface AgentProject {
id: number
title: string
owner_id: number
chat_count: number
}
export interface AgentChat {
id: number
project_id: number
title: string
created_at: string
}
export interface AgentMessage {
id: number
role: "user" | "assistant"
content: string
file_ids: number[]
created_at: string
}
export interface AgentChatDetail extends AgentChat {
messages: AgentMessage[]
}
export const agentProjectsApi = {
listProjects: () => api.get<AgentProject[]>("/agent/projects/"),
createProject: (title: string) => api.post<AgentProject>("/agent/projects/", { title }),
deleteProject: (projectId: number) => api.delete(`/agent/projects/${projectId}`),
listChats: (projectId: number) => api.get<AgentChat[]>(`/agent/projects/${projectId}/chats`),
createChat: (projectId: number) => api.post<AgentChat>(`/agent/projects/${projectId}/chats`),
getChat: (chatId: number) => api.get<AgentChatDetail>(`/agent/chats/${chatId}`),
deleteChat: (chatId: number) => api.delete(`/agent/chats/${chatId}`),
listDocuments: (projectId: number) => api.get<Document[]>(`/agent/projects/${projectId}/documents`),
deleteDocument: (projectId: number, docId: number) =>
api.delete(`/agent/projects/${projectId}/documents/${docId}`),
}
// RAG
export const ragApi = {
status: () => api.get<{
ollama_reachable: boolean
models: string[]
openai_available: boolean
openai_llm_models: string[]
openai_vlm_models: string[]
openai_embed_models: string[]
}>("/rag/status"),
getModel: () => api.get<{ model: string }>("/rag/model"),
setModel: (model: string) => api.put<{ model: string; detail: string }>("/rag/model", { model }),
getOcrModel: () => api.get<{ ocr_model: string }>("/rag/ocr-model"),
setOcrModel: (model: string) => api.put<{ ocr_model: string; detail: string }>("/rag/ocr-model", { model }),
getVlmModel: () => api.get<{ vlm_model: string }>("/rag/vlm-model"),
setVlmModel: (model: string) => api.put<{ vlm_model: string; detail: string }>("/rag/vlm-model", { model }),
getOpenAIKeyStatus: () => api.get<{ configured: boolean }>("/rag/openai-key"),
getEmbedModel: () => api.get<{ embed_model: string }>("/rag/embed-model"),
setEmbedModel: (model: string) => api.put<{ embed_model: string; detail: string }>("/rag/embed-model", { model }),
listDocs: () => api.get<{ filename: string; chunks: number }[]>("/rag/docs"),
addDoc: (file: File) => {
const fd = new FormData()
fd.append("file", file)
return api.post("/rag/docs", fd, { headers: { "Content-Type": "multipart/form-data" } })
},
removeDoc: (filename: string) => api.delete(`/rag/docs/${encodeURIComponent(filename)}`),
}