import axios, { type InternalAxiosRequestConfig } from 'axios'; const CSRF_COOKIE_NAME = 'csrf_token'; const CSRF_HEADER_NAME = 'X-CSRF-Token'; function getCookieValue(name: string): string { if (typeof document === 'undefined') { return ''; } const encodedName = `${encodeURIComponent(name)}=`; const cookie = document.cookie .split('; ') .find((item) => item.startsWith(encodedName)); return cookie ? decodeURIComponent(cookie.slice(encodedName.length)) : ''; } function shouldAttachCsrfToken(config: InternalAxiosRequestConfig): boolean { const method = String(config.method || 'get').toUpperCase(); if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { return false; } const headers = config.headers ?? {}; if ('X-API-Key' in headers || 'x-api-key' in headers) { return false; } return !String(config.url || '').includes('/auth/csrf'); } function setRequestHeader(config: InternalAxiosRequestConfig, key: string, value: string) { if (!config.headers) { config.headers = {}; } if (typeof (config.headers as { set?: (header: string, headerValue: string) => void }).set === 'function') { (config.headers as { set: (header: string, headerValue: string) => void }).set(key, value); return; } (config.headers as Record)[key] = value; } const csrfBootstrapClient = axios.create({ baseURL: '/api', timeout: 15000, withCredentials: true, headers: { Accept: 'application/json', }, }); const api = axios.create({ baseURL: '/api', timeout: 120000, // 2 minute timeout for file processing withCredentials: true, headers: { Accept: 'application/json', }, }); // Request interceptor for logging api.interceptors.request.use( async (config) => { if (!shouldAttachCsrfToken(config)) { return config; } let csrfToken = getCookieValue(CSRF_COOKIE_NAME); if (!csrfToken) { await csrfBootstrapClient.get('/auth/csrf'); csrfToken = getCookieValue(CSRF_COOKIE_NAME); } if (csrfToken) { setRequestHeader(config, CSRF_HEADER_NAME, csrfToken); } return config; }, (error) => Promise.reject(error) ); // Response interceptor for error handling api.interceptors.response.use( (response) => response, (error) => { if (error.response) { if (error.response.status === 429) { return Promise.reject(new Error('Too many requests. Please wait a moment and try again.')); } const responseData = error.response.data; const message = responseData?.error || responseData?.message || (typeof responseData === 'string' && responseData.trim() ? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : null) || `Request failed (${error.response.status}).`; return Promise.reject(new Error(message)); } if (error.request) { return Promise.reject(new Error('Network error. Please check your connection.')); } return Promise.reject(error); } ); // --- API Functions --- export interface TaskResponse { task_id: string; message: string; } export interface TaskStatus { task_id: string; state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE'; progress?: string; result?: TaskResult; error?: string; } export interface TaskResult { status: 'completed' | 'failed'; download_url?: string; filename?: string; error?: string; original_size?: number; compressed_size?: number; reduction_percent?: number; width?: number; height?: number; output_size?: number; duration?: number; fps?: number; format?: string; // Flowchart-specific fields procedures?: Array<{ id: string; title: string; description: string; pages: number[]; step_count: number }>; flowcharts?: Array<{ id: string; procedureId: string; title: string; steps: Array<{ id: string; type: string; title: string; description: string; connections: string[] }> }>; pages?: Array<{ page: number; text: string }>; procedures_count?: number; total_pages?: number; // OCR-specific fields text?: string; char_count?: number; // AI PDF fields reply?: string; summary?: string; translation?: string; target_language?: string; pages_analyzed?: number; // Table extraction fields tables?: Array<{ page: number; table_index: number; headers: string[]; rows: string[][] }>; tables_found?: number; } export interface AuthUser { id: number; email: string; plan: string; role: 'user' | 'admin' | string; is_allowlisted_admin?: boolean; created_at: string; } interface AuthResponse { message: string; user: AuthUser; } interface AuthSessionResponse { authenticated: boolean; user: AuthUser | null; } interface HistoryResponse { items: HistoryEntry[]; } export interface HistoryEntry { id: number; tool: string; original_filename: string | null; output_filename: string | null; status: 'completed' | 'failed' | string; download_url: string | null; metadata: Record; created_at: string; } export interface AssistantHistoryMessage { role: 'user' | 'assistant'; content: string; } export interface AssistantChatRequest { message: string; session_id?: string; fingerprint: string; tool_slug?: string; page_url?: string; locale?: string; history?: AssistantHistoryMessage[]; } export interface AssistantChatResponse { session_id: string; reply: string; stored: boolean; } interface AssistantStreamHandlers { onSession?: (sessionId: string) => void; onChunk?: (chunk: string) => void; } interface AssistantStreamEvent { event: string; data: Record; } function parseAssistantStreamEvent(rawEvent: string): AssistantStreamEvent | null { const lines = rawEvent.split(/\r?\n/); let event = 'message'; const dataLines: string[] = []; for (const line of lines) { if (!line) { continue; } if (line.startsWith('event:')) { event = line.slice(6).trim(); continue; } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trim()); } } if (!dataLines.length) { return null; } return { event, data: JSON.parse(dataLines.join('\n')) as Record, }; } function normalizeStreamError(status: number, bodyText: string): Error { if (!bodyText.trim()) { return new Error(`Request failed (${status}).`); } try { const parsed = JSON.parse(bodyText) as { error?: string; message?: string }; return new Error(parsed.error || parsed.message || `Request failed (${status}).`); } catch { return new Error(bodyText.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()); } } /** * Upload a file and start a processing task. */ export async function uploadFile( endpoint: string, file: File, extraData?: Record, onProgress?: (percent: number) => void ): Promise { const formData = new FormData(); formData.append('file', file); if (extraData) { Object.entries(extraData).forEach(([key, value]) => { formData.append(key, value); }); } const response = await api.post(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (event) => { if (event.total && onProgress) { const percent = Math.round((event.loaded / event.total) * 100); onProgress(percent); } }, }); return response.data; } /** * Upload multiple files and start a processing task. */ export async function uploadFiles( endpoint: string, files: File[], fileField = 'files', extraData?: Record, onProgress?: (percent: number) => void ): Promise { const formData = new FormData(); files.forEach((file) => formData.append(fileField, file)); if (extraData) { Object.entries(extraData).forEach(([key, value]) => { formData.append(key, value); }); } const response = await api.post(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (event) => { if (event.total && onProgress) { const percent = Math.round((event.loaded / event.total) * 100); onProgress(percent); } }, }); return response.data; } /** * Start a task endpoint that does not require file upload. */ export async function startTask(endpoint: string): Promise { const response = await api.post(endpoint); return response.data; } /** * Create a new account and return the authenticated user. */ export async function registerUser(email: string, password: string): Promise { const response = await api.post('/auth/register', { email, password }); return response.data.user; } /** * Sign in and return the authenticated user. */ export async function loginUser(email: string, password: string): Promise { const response = await api.post('/auth/login', { email, password }); return response.data.user; } /** * End the current authenticated session. */ export async function logoutUser(): Promise { await api.post('/auth/logout'); } /** * Return the current authenticated user, if any. */ export async function getCurrentUser(): Promise { const response = await api.get('/auth/me'); return response.data.user; } /** * Return recent authenticated file history. */ export async function getHistory(limit = 50): Promise { const response = await api.get('/history', { params: { limit }, }); return response.data.items; } /** * Poll task status. */ export async function getTaskStatus(taskId: string): Promise { const response = await api.get(`/tasks/${taskId}/status`); return response.data; } export function getApiClient() { return api; } /** * Send one message to the site assistant. */ export async function chatWithAssistant( payload: AssistantChatRequest ): Promise { const response = await api.post('/assistant/chat', payload); return response.data; } /** * Stream one assistant response incrementally over SSE. */ export async function streamAssistantChat( payload: AssistantChatRequest, handlers: AssistantStreamHandlers = {} ): Promise { const response = await fetch('/api/assistant/chat/stream', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', }, body: JSON.stringify(payload), }); if (!response.ok) { const bodyText = await response.text(); throw normalizeStreamError(response.status, bodyText); } if (!response.body) { throw new Error('Streaming is not supported by this browser.'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let finalResponse: AssistantChatResponse | null = null; while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); let boundary = buffer.indexOf('\n\n'); while (boundary !== -1) { const rawEvent = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const parsedEvent = parseAssistantStreamEvent(rawEvent); if (parsedEvent?.event === 'session') { const sessionId = parsedEvent.data.session_id; if (typeof sessionId === 'string') { handlers.onSession?.(sessionId); } } if (parsedEvent?.event === 'chunk') { const chunk = parsedEvent.data.content; if (typeof chunk === 'string' && chunk) { handlers.onChunk?.(chunk); } } if (parsedEvent?.event === 'done') { const sessionId = parsedEvent.data.session_id; const reply = parsedEvent.data.reply; const stored = parsedEvent.data.stored; if ( typeof sessionId === 'string' && typeof reply === 'string' && typeof stored === 'boolean' ) { finalResponse = { session_id: sessionId, reply, stored, }; } } boundary = buffer.indexOf('\n\n'); } if (done) { break; } } if (!finalResponse) { throw new Error('Assistant stream ended unexpectedly.'); } return finalResponse; } /** * Check API health. */ export async function checkHealth(): Promise { try { const response = await api.get('/health'); return response.data.status === 'healthy'; } catch { return false; } } export interface PublicStatsSummary { total_files_processed: number; completed_files: number; failed_files: number; success_rate: number; files_last_24h: number; average_rating: number; rating_count: number; top_tools: Array<{ tool: string; count: number }>; } /** * Return public site stats used for social proof and developer onboarding. */ export async function getPublicStats(): Promise { const response = await api.get('/stats/summary'); return response.data; } export interface InternalAdminUser { id: number; email: string; plan: 'free' | 'pro' | string; role: 'user' | 'admin' | string; is_allowlisted_admin: boolean; created_at: string; total_tasks: number; completed_tasks: number; failed_tasks: number; active_api_keys: number; } export interface InternalAdminContact { id: number; name: string; email: string; category: string; subject: string | null; message: string; created_at: string; is_read: boolean; } export interface InternalAdminOverview { users: { total: number; pro: number; free: number; }; processing: { total_files_processed: number; completed_files: number; failed_files: number; files_last_24h: number; success_rate: number; }; ratings: { average_rating: number; rating_count: number; }; ai_cost: { month: string; total_usd: number; budget_usd: number; percent_used: number; }; contacts: { total_messages: number; unread_messages: number; recent: InternalAdminContact[]; }; top_tools: Array<{ tool: string; total_runs: number; failed_runs: number; }>; recent_failures: Array<{ id: number; user_id: number | null; email: string | null; tool: string; original_filename: string | null; created_at: string; metadata: Record; }>; recent_users: Array<{ id: number; email: string; plan: string; created_at: string; total_tasks: number; active_api_keys: number; }>; } export async function getInternalAdminOverview(): Promise { const response = await api.get('/internal/admin/overview'); return response.data; } export async function listInternalAdminUsers(query = '', limit = 25): Promise { const response = await api.get<{ items: InternalAdminUser[] }>('/internal/admin/users', { params: { query, limit, }, }); return response.data.items; } export async function getInternalAdminContacts(page = 1, perPage = 20): Promise<{ items: InternalAdminContact[]; page: number; per_page: number; total: number; unread: number; }> { const response = await api.get<{ items: InternalAdminContact[]; page: number; per_page: number; total: number; unread: number; }>('/internal/admin/contacts', { params: { page, per_page: perPage, }, }); return response.data; } export async function markInternalAdminContactRead(messageId: number): Promise { await api.post(`/internal/admin/contacts/${messageId}/read`); } export async function updateInternalAdminUserPlan( userId: number, plan: 'free' | 'pro' ): Promise { const response = await api.post<{ message: string; user: AuthUser }>( `/internal/admin/users/${userId}/plan`, { plan } ); return response.data.user; } export async function updateInternalAdminUserRole( userId: number, role: 'user' | 'admin' ): Promise { const response = await api.post<{ message: string; user: AuthUser }>( `/internal/admin/users/${userId}/role`, { role } ); return response.data.user; } // --- Account / Usage / API Keys --- export interface UsageSummary { plan: string; period_month: string; ads_enabled: boolean; history_limit: number; file_limits_mb: { pdf: number; word: number; image: number; video: number; homepageSmartUpload: number; }; web_quota: { used: number; limit: number | null }; api_quota: { used: number; limit: number | null }; } export interface ApiKey { id: number; name: string; key_prefix: string; last_used_at: string | null; revoked_at: string | null; created_at: string; raw_key?: string; // only present on creation } /** * Return the current user's plan, quota, and file-limit summary. */ export async function getUsage(): Promise { const response = await api.get('/account/usage'); return response.data; } /** * Return all API keys for the authenticated pro user. */ export async function getApiKeys(): Promise { const response = await api.get<{ items: ApiKey[] }>('/account/api-keys'); return response.data.items; } /** * Create a new API key with the given name. Returns the key including raw_key once. */ export async function createApiKey(name: string): Promise { const response = await api.post('/account/api-keys', { name }); return response.data; } /** * Revoke one API key by id. */ export async function revokeApiKey(keyId: number): Promise { await api.delete(`/account/api-keys/${keyId}`); } export default api;