Spaces:
Running
Running
| // Enhanced HF Service with Versioning and Standardized Responses | |
| const CACHE_TTL = 60 * 1000; // 60 seconds (aligned with backend HF cache) | |
| class HFService { | |
| constructor() { | |
| this.apiBase = null; | |
| this.apiBasePromise = null; | |
| this.cache = new Map(); | |
| this.retryLimit = 3; | |
| this.retryDelay = 1000; | |
| } | |
| async getApiBase() { | |
| if (this.apiBase) return this.apiBase; | |
| if (this.apiBasePromise) return this.apiBasePromise; | |
| this.apiBasePromise = (async () => { | |
| const configuredBase = window.__DOCVAULT_API_BASE__; | |
| const remoteFallbackBase = window.__DOCVAULT_REMOTE_API_BASE__; | |
| const { protocol, hostname, origin, port } = window.location; | |
| const host = hostname || '127.0.0.1'; | |
| const candidates = []; | |
| if (configuredBase && typeof configuredBase === 'string') { | |
| candidates.push(configuredBase.replace(/\/$/, '')); | |
| } | |
| candidates.push(`${origin}/api`); | |
| if (port !== '5000') candidates.push(`${protocol}//${host}:5000/api`); | |
| if (port !== '7860') candidates.push(`${protocol}//${host}:7860/api`); | |
| if (remoteFallbackBase && typeof remoteFallbackBase === 'string') { | |
| candidates.push(remoteFallbackBase.replace(/\/$/, '')); | |
| } | |
| const uniqueCandidates = [...new Set(candidates)]; | |
| for (const candidate of uniqueCandidates) { | |
| try { | |
| const response = await fetch(`${candidate}/health`, { | |
| method: 'GET', | |
| headers: { 'X-User-ID': 'default_user' } | |
| }); | |
| if (response.ok) { | |
| this.apiBase = candidate; | |
| return candidate; | |
| } | |
| } catch (err) { | |
| // Try the next candidate quietly. | |
| } | |
| } | |
| throw new Error('DocVault backend is unreachable. Expected /api/health on the current host, ports 5000/7860, or the configured remote Space API.'); | |
| })(); | |
| return this.apiBasePromise; | |
| } | |
| async fetchWithRetry(url, options = {}, retries = this.retryLimit) { | |
| try { | |
| const response = await fetch(url, options); | |
| if (!response.ok) { | |
| if (response.status >= 500 && retries > 0) throw new Error('Server error'); | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(errorData.error || `Request failed: ${response.status}`); | |
| } | |
| return response; | |
| } catch (err) { | |
| if (retries > 0) { | |
| const delay = this.retryDelay * Math.pow(2, this.retryLimit - retries); | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| return this.fetchWithRetry(url, options, retries - 1); | |
| } | |
| throw err; | |
| } | |
| } | |
| async listFiles(path = '') { | |
| const cacheKey = `list-${path}`; | |
| const cached = this.cache.get(cacheKey); | |
| if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) { | |
| return cached.data; | |
| } | |
| const queryPath = path ? `?folder_path=${encodeURIComponent(path)}` : ''; | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/list${queryPath}`; | |
| const res = await this.fetchWithRetry(url, { headers: { 'X-User-ID': 'default_user' } }); | |
| const data = await res.json(); | |
| const result = { files: [], folders: [] }; | |
| // Validate response structure | |
| if (!data || typeof data !== 'object' || data.success !== true) { | |
| console.warn('Invalid API response structure:', data); | |
| this.cache.set(cacheKey, { data: result, timestamp: Date.now() }); | |
| return result; | |
| } | |
| if (Array.isArray(data.files)) { | |
| for (const item of data.files) { | |
| result.files.push({ | |
| path: item.path || '', | |
| name: item.name || 'unnamed', | |
| size: item.size || 0, | |
| type: 'file', | |
| lastModified: item.modified_at, | |
| storage: item.storage | |
| }); | |
| } | |
| } | |
| if (Array.isArray(data.folders)) { | |
| for (const item of data.folders) { | |
| result.folders.push({ | |
| path: item.path || '', | |
| name: item.name || 'unnamed', | |
| type: 'folder', | |
| storage: item.storage | |
| }); | |
| } | |
| } | |
| this.cache.set(cacheKey, { data: result, timestamp: Date.now() }); | |
| return result; | |
| } | |
| async uploadFile(file, destPath) { | |
| const formData = new FormData(); | |
| // standardized folder path extraction | |
| const folderPath = destPath.includes('/') ? destPath.substring(0, destPath.lastIndexOf('/')) : ''; | |
| const filename = file instanceof File ? file.name : destPath.split('/').pop(); | |
| const fileBlob = file instanceof File ? file : new Blob([file.content || '']); | |
| formData.append('folder_path', folderPath); | |
| formData.append('file', fileBlob, filename); | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/upload`; | |
| const res = await this.fetchWithRetry(url, { | |
| method: 'POST', | |
| headers: { 'X-User-ID': 'default_user' }, | |
| body: formData | |
| }); | |
| this.clearCache(); | |
| return await res.json(); | |
| } | |
| async createFolder(folderPath) { | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/create-folder`; | |
| const res = await this.fetchWithRetry(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, | |
| body: JSON.stringify({ folder_path: folderPath }), | |
| }); | |
| this.clearCache(); | |
| return await res.json(); | |
| } | |
| async deleteFile(path) { | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/delete`; | |
| const res = await this.fetchWithRetry(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, | |
| body: JSON.stringify({ path, type: 'file' }), | |
| }); | |
| this.clearCache(); | |
| const data = await res.json(); | |
| if (!data.success) { | |
| throw new Error(data.error || 'Failed to delete file'); | |
| } | |
| return data; | |
| } | |
| async deleteFolder(folderPath) { | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/delete`; | |
| const res = await this.fetchWithRetry(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, | |
| body: JSON.stringify({ path: folderPath, type: 'folder' }), | |
| }); | |
| this.clearCache(); | |
| const data = await res.json(); | |
| if (!data.success) { | |
| throw new Error(data.error || 'Failed to delete folder'); | |
| } | |
| return data; | |
| } | |
| async getHistory(path) { | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/history?path=${encodeURIComponent(path)}`; | |
| const res = await this.fetchWithRetry(url, { headers: { 'X-User-ID': 'default_user' } }); | |
| const data = await res.json(); | |
| if (!data || !data.success) { | |
| console.warn('Failed to get history:', data?.error || 'Unknown error'); | |
| return []; | |
| } | |
| return Array.isArray(data.history) ? data.history : []; | |
| } | |
| async restoreVersion(path, revision, asCopy = false) { | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/restore`; | |
| const res = await this.fetchWithRetry(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, | |
| body: JSON.stringify({ path, revision, as_copy: asCopy }), | |
| }); | |
| this.clearCache(); | |
| return await res.json(); | |
| } | |
| async renameItem(itemPath, newName) { | |
| const apiBase = await this.getApiBase(); | |
| const url = `${apiBase}/rename`; | |
| const res = await this.fetchWithRetry(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, | |
| body: JSON.stringify({ item_path: itemPath, new_name: newName }), | |
| }); | |
| this.clearCache(); | |
| return await res.json(); | |
| } | |
| async listLinks() { | |
| const apiBase = await this.getApiBase(); | |
| const res = await this.fetchWithRetry(`${apiBase}/links/list`, { | |
| headers: { 'X-User-ID': 'default_user' } | |
| }); | |
| const data = await res.json(); | |
| return Array.isArray(data.links) ? data.links : []; | |
| } | |
| async addLink(payload) { | |
| const apiBase = await this.getApiBase(); | |
| const res = await this.fetchWithRetry(`${apiBase}/links/add`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-User-ID': 'default_user' | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| return await res.json(); | |
| } | |
| async updateLink(payload) { | |
| const apiBase = await this.getApiBase(); | |
| const res = await this.fetchWithRetry(`${apiBase}/links/update`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-User-ID': 'default_user' | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| return await res.json(); | |
| } | |
| async deleteLink(linkId) { | |
| const apiBase = await this.getApiBase(); | |
| const res = await this.fetchWithRetry(`${apiBase}/links/delete`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-User-ID': 'default_user' | |
| }, | |
| body: JSON.stringify({ link_id: linkId }) | |
| }); | |
| return await res.json(); | |
| } | |
| clearCache() { | |
| this.cache.clear(); | |
| } | |
| } | |
| export const hfService = new HFService(); | |