diff --git a/routes.go b/routes.go index f6f96dc..a124fa1 100644 --- a/routes.go +++ b/routes.go @@ -20,6 +20,49 @@ type collectionList map[string]*rag.PersistentKB var collections = collectionList{} +// APIResponse represents a standardized API response +type APIResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +// APIError represents a detailed error response +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +// Error codes +const ( + ErrCodeNotFound = "NOT_FOUND" + ErrCodeInvalidRequest = "INVALID_REQUEST" + ErrCodeInternalError = "INTERNAL_ERROR" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeConflict = "CONFLICT" +) + +func successResponse(message string, data interface{}) APIResponse { + return APIResponse{ + Success: true, + Message: message, + Data: data, + } +} + +func errorResponse(code string, message string, details string) APIResponse { + return APIResponse{ + Success: false, + Error: &APIError{ + Code: code, + Message: message, + Details: details, + }, + } +} + func newVectorEngine( vectorEngineType string, llmClient *openai.Client, @@ -73,7 +116,7 @@ func registerAPIRoutes(e *echo.Echo, openAIClient *openai.Client, maxChunkingSiz } } - return c.JSON(http.StatusUnauthorized, errorMessage("Unauthorized")) + return c.JSON(http.StatusUnauthorized, errorResponse(ErrCodeUnauthorized, "Unauthorized", "Invalid or missing API key")) } }) } @@ -99,7 +142,7 @@ func createCollection(collections collectionList, client *openai.Client, embeddi r := new(request) if err := c.Bind(r); err != nil { - return c.JSON(http.StatusBadRequest, errorMessage("Invalid request")) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Invalid request", err.Error())) } collection := newVectorEngine(vectorEngine, client, openAIBaseURL, openAIKey, r.Name, collectionDBPath, embeddingModel, maxChunkingSize) @@ -108,7 +151,11 @@ func createCollection(collections collectionList, client *openai.Client, embeddi // Register the new collection with the source manager sourceManager.RegisterCollection(r.Name, collection) - return c.JSON(http.StatusCreated, collection) + response := successResponse("Collection created successfully", map[string]interface{}{ + "name": r.Name, + "created_at": time.Now().Format(time.RFC3339), + }) + return c.JSON(http.StatusCreated, response) } } @@ -117,7 +164,7 @@ func deleteEntryFromCollection(collections collectionList) func(c echo.Context) name := c.Param("name") collection, exists := collections[name] if !exists { - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } type request struct { @@ -126,14 +173,20 @@ func deleteEntryFromCollection(collections collectionList) func(c echo.Context) r := new(request) if err := c.Bind(r); err != nil { - return c.JSON(http.StatusBadRequest, errorMessage("Invalid request")) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Invalid request", err.Error())) } if err := collection.RemoveEntry(r.Entry); err != nil { - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to remove entry: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to remove entry", err.Error())) } - return c.JSON(http.StatusOK, collection.ListDocuments()) + remainingEntries := collection.ListDocuments() + response := successResponse("Entry deleted successfully", map[string]interface{}{ + "deleted_entry": r.Entry, + "remaining_entries": remainingEntries, + "entry_count": len(remainingEntries), + }) + return c.JSON(http.StatusOK, response) } } @@ -142,16 +195,20 @@ func reset(collections collectionList) func(c echo.Context) error { name := c.Param("name") collection, exists := collections[name] if !exists { - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } if err := collection.Reset(); err != nil { - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to reset collection: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to reset collection", err.Error())) } delete(collections, name) - return nil + response := successResponse("Collection reset successfully", map[string]interface{}{ + "collection": name, + "reset_at": time.Now().Format(time.RFC3339), + }) + return c.JSON(http.StatusOK, response) } } @@ -160,7 +217,7 @@ func search(collections collectionList) func(c echo.Context) error { name := c.Param("name") collection, exists := collections[name] if !exists { - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } type request struct { @@ -170,11 +227,9 @@ func search(collections collectionList) func(c echo.Context) error { r := new(request) if err := c.Bind(r); err != nil { - return c.JSON(http.StatusBadRequest, errorMessage("Invalid request")) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Invalid request", err.Error())) } - fmt.Println(r) - if r.MaxResults == 0 { if len(collection.ListDocuments()) >= 5 { r.MaxResults = 5 @@ -185,26 +240,34 @@ func search(collections collectionList) func(c echo.Context) error { results, err := collection.Search(r.Query, r.MaxResults) if err != nil { - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to search collection: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to search collection", err.Error())) } - return c.JSON(http.StatusOK, results) + response := successResponse("Search completed successfully", map[string]interface{}{ + "query": r.Query, + "max_results": r.MaxResults, + "results": results, + "count": len(results), + }) + return c.JSON(http.StatusOK, response) } } -func errorMessage(message string) map[string]string { - return map[string]string{"error": message} -} - func listFiles(collections collectionList) func(c echo.Context) error { return func(c echo.Context) error { name := c.Param("name") collection, exists := collections[name] if !exists { - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } - return c.JSON(http.StatusOK, collection.ListDocuments()) + entries := collection.ListDocuments() + response := successResponse("Entries retrieved successfully", map[string]interface{}{ + "collection": name, + "entries": entries, + "count": len(entries), + }) + return c.JSON(http.StatusOK, response) } } @@ -215,19 +278,19 @@ func uploadFile(collections collectionList, fileAssets string) func(c echo.Conte collection, exists := collections[name] if !exists { xlog.Error("Collection not found") - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } file, err := c.FormFile("file") if err != nil { xlog.Error("Failed to read file", err) - return c.JSON(http.StatusBadRequest, errorMessage("Failed to read file: "+err.Error())) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Failed to read file", err.Error())) } f, err := file.Open() if err != nil { xlog.Error("Failed to open file", err) - return c.JSON(http.StatusBadRequest, errorMessage("Failed to open file: "+err.Error())) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Failed to open file", err.Error())) } defer f.Close() @@ -235,35 +298,45 @@ func uploadFile(collections collectionList, fileAssets string) func(c echo.Conte out, err := os.Create(filePath) if err != nil { xlog.Error("Failed to create file", err) - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to create file "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to create file", err.Error())) } defer out.Close() _, err = io.Copy(out, f) if err != nil { xlog.Error("Failed to copy file", err) - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to copy file: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to copy file", err.Error())) } if collection.EntryExists(file.Filename) { xlog.Info("Entry already exists") - return c.JSON(http.StatusBadRequest, errorMessage("Entry already exists")) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeConflict, "Entry already exists", fmt.Sprintf("File '%s' has already been uploaded to collection '%s'", file.Filename, name))) } // Save the file to disk err = collection.Store(filePath, map[string]string{}) if err != nil { xlog.Error("Failed to store file", err) - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to store file: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to store file", err.Error())) } - return c.JSON(http.StatusOK, collection) + response := successResponse("File uploaded successfully", map[string]interface{}{ + "filename": file.Filename, + "collection": name, + "uploaded_at": time.Now().Format(time.RFC3339), + }) + return c.JSON(http.StatusOK, response) } } // listCollections returns all collections func listCollections(c echo.Context) error { - return c.JSON(http.StatusOK, rag.ListAllCollections(collectionDBPath)) + collectionsList := rag.ListAllCollections(collectionDBPath) + response := successResponse("Collections retrieved successfully", map[string]interface{}{ + "collections": collectionsList, + "count": len(collectionsList), + }) + return c.JSON(http.StatusOK, response) } // registerExternalSource handles registering an external source for a collection @@ -272,7 +345,7 @@ func registerExternalSource(collections collectionList) func(c echo.Context) err name := c.Param("name") collection, exists := collections[name] if !exists { - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } type request struct { @@ -282,7 +355,7 @@ func registerExternalSource(collections collectionList) func(c echo.Context) err r := new(request) if err := c.Bind(r); err != nil { - return c.JSON(http.StatusBadRequest, errorMessage("Invalid request")) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Invalid request", err.Error())) } if r.UpdateInterval < 1 { @@ -294,10 +367,15 @@ func registerExternalSource(collections collectionList) func(c echo.Context) err // Add the source to the manager if err := sourceManager.AddSource(name, r.URL, time.Duration(r.UpdateInterval)*time.Minute); err != nil { - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to register source: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to register source", err.Error())) } - return c.JSON(http.StatusOK, map[string]string{"message": "External source registered successfully"}) + response := successResponse("External source registered successfully", map[string]interface{}{ + "collection": name, + "url": r.URL, + "update_interval": r.UpdateInterval, + }) + return c.JSON(http.StatusOK, response) } } @@ -312,14 +390,18 @@ func removeExternalSource(collections collectionList) func(c echo.Context) error r := new(request) if err := c.Bind(r); err != nil { - return c.JSON(http.StatusBadRequest, errorMessage("Invalid request")) + return c.JSON(http.StatusBadRequest, errorResponse(ErrCodeInvalidRequest, "Invalid request", err.Error())) } if err := sourceManager.RemoveSource(name, r.URL); err != nil { - return c.JSON(http.StatusInternalServerError, errorMessage("Failed to remove source: "+err.Error())) + return c.JSON(http.StatusInternalServerError, errorResponse(ErrCodeInternalError, "Failed to remove source", err.Error())) } - return c.JSON(http.StatusOK, map[string]string{"message": "External source removed successfully"}) + response := successResponse("External source removed successfully", map[string]interface{}{ + "collection": name, + "url": r.URL, + }) + return c.JSON(http.StatusOK, response) } } @@ -329,22 +411,27 @@ func listSources(collections collectionList) func(c echo.Context) error { name := c.Param("name") collection, exists := collections[name] if !exists { - return c.JSON(http.StatusNotFound, errorMessage("Collection not found")) + return c.JSON(http.StatusNotFound, errorResponse(ErrCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name))) } // Get sources from the collection sources := collection.GetExternalSources() // Convert sources to a more frontend-friendly format - response := []map[string]interface{}{} + sourcesList := []map[string]interface{}{} for _, source := range sources { - response = append(response, map[string]interface{}{ + sourcesList = append(sourcesList, map[string]interface{}{ "url": source.URL, "update_interval": int(source.UpdateInterval.Minutes()), "last_update": source.LastUpdate.Format(time.RFC3339), }) } + response := successResponse("Sources retrieved successfully", map[string]interface{}{ + "collection": name, + "sources": sourcesList, + "count": len(sourcesList), + }) return c.JSON(http.StatusOK, response) } } diff --git a/static/js/collectionManager.js b/static/js/collectionManager.js index 466613c..4f3158c 100644 --- a/static/js/collectionManager.js +++ b/static/js/collectionManager.js @@ -46,14 +46,13 @@ function appRouter() { fetchCollections() { return fetch('/api/collections') - .then(response => { - if (!response.ok) throw new Error('Failed to fetch collections'); - return response.json(); - }) + .then(response => handleAPIResponse(response)) .then(data => { - if (Array.isArray(data)) { - this.collections = data; - return data; + // Extract collections from the data field + const collectionsList = data.data?.collections || []; + if (Array.isArray(collectionsList)) { + this.collections = collectionsList; + return collectionsList; } else { this.collections = []; console.error('collections data:', data); @@ -62,7 +61,7 @@ function appRouter() { }) .catch(error => { console.error('Error fetching collections:', error); - this.showToast('error', 'Failed to fetch collections'); + this.showToast('error', error.message || 'Failed to fetch collections'); return []; }); }, @@ -104,6 +103,20 @@ function getRouter() { return routerElement ? Alpine.$data(routerElement) : null; } +// Utility function to handle API responses consistently +function handleAPIResponse(response) { + return response.json().then(data => { + if (!response.ok || (data.success === false)) { + // Extract error details + const error = new Error(data.error?.message || 'Operation failed'); + error.code = data.error?.code; + error.details = data.error?.details; + throw error; + } + return data; + }); +} + // Search Page Component function searchPage() { return { @@ -118,6 +131,7 @@ function searchPage() { }, get collections() { + const router = getRouter(); return router ? router.collections : []; }, @@ -145,25 +159,14 @@ function searchPage() { max_results: maxResultsVal }) }) - .then(response => { - if (!response.ok) { - return response.text().then(text => { - try { - const data = JSON.parse(text); - throw new Error(data.error || data.message || 'Search failed with status: ' + response.status); - } catch (e) { - throw new Error(text || 'Search failed with status: ' + response.status); - } - }); - } - return response.json(); - }) + .then(response => handleAPIResponse(response)) .then(data => { - if (data.length === 0) { + const results = data.data?.results || []; + if (results.length === 0) { this.searchResults = ['No results found for query: "' + this.searchQuery + '"']; return; } - this.searchResults = data.map(item => JSON.stringify(item, null, 2)); + this.searchResults = results.map(item => JSON.stringify(item, null, 2)); }) .catch(error => { console.error('Error searching collection:', error); @@ -176,6 +179,7 @@ function searchPage() { }, showToast(type, message) { + const router = getRouter(); if (router) router.showToast(type, message); } @@ -193,6 +197,7 @@ function collectionsPage() { }, get collections() { + const router = getRouter(); return router ? router.collections : []; }, @@ -204,23 +209,21 @@ function collectionsPage() { } this.loading.create = true; + fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: this.newCollectionName }) }) - .then(response => { - if (!response.ok) throw new Error('Failed to create collection'); - return response.json(); - }) - .then(() => { - this.showToast('success', `Collection "${this.newCollectionName}" created successfully`); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || `Collection "${this.newCollectionName}" created successfully`); this.newCollectionName = ''; this.fetchCollections(); }) .catch(error => { console.error('Error creating collection:', error); - this.showToast('error', 'Failed to create collection'); + this.showToast('error', error.message || 'Failed to create collection'); }) .finally(() => { this.loading.create = false; @@ -262,18 +265,19 @@ function collectionsPage() { resetCollection(collectionName) { this.loading.reset = collectionName; + fetch(`/api/collections/${collectionName}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) - .then(response => { - if (!response.ok) throw new Error('Reset failed'); - this.showToast('success', `Collection "${collectionName}" has been reset successfully`); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || `Collection "${collectionName}" has been reset successfully`); this.fetchCollections(); }) .catch(error => { console.error('Error resetting collection:', error); - this.showToast('error', `Failed to reset collection: ${error.message}`); + this.showToast('error', error.message || `Failed to reset collection`); }) .finally(() => { this.loading.reset = false; @@ -281,6 +285,7 @@ function collectionsPage() { }, showToast(type, message) { + const router = getRouter(); if (router) router.showToast(type, message); }, @@ -301,6 +306,7 @@ function uploadPage() { }, get collections() { + const router = getRouter(); return router ? router.collections : []; }, @@ -320,19 +326,20 @@ function uploadPage() { formData.append('file', fileInput.files[0]); this.loading.upload = true; + fetch(`/api/collections/${this.selectedCollection}/upload`, { method: 'POST', body: formData }) - .then(response => { - if (!response.ok) throw new Error('Upload failed'); - this.showToast('success', 'File uploaded successfully'); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || 'File uploaded successfully'); fileInput.value = ''; this.fileName = ''; }) .catch(error => { console.error('Error uploading file:', error); - this.showToast('error', 'Failed to upload file'); + this.showToast('error', error.message || 'Failed to upload file'); }) .finally(() => { this.loading.upload = false; @@ -340,6 +347,7 @@ function uploadPage() { }, showToast(type, message) { + const router = getRouter(); if (router) router.showToast(type, message); } @@ -360,6 +368,7 @@ function sourcesPage() { }, get collections() { + const router = getRouter(); return router ? router.collections : []; }, @@ -370,17 +379,15 @@ function sourcesPage() { this.loading.sources = true; this.sources = []; + fetch(`/api/collections/${this.selectedSourceCollection}/sources`) - .then(response => { - if (!response.ok) throw new Error('Failed to list sources'); - return response.json(); - }) + .then(response => handleAPIResponse(response)) .then(data => { - this.sources = data; + this.sources = data.data?.sources || []; }) .catch(error => { console.error('Error listing sources:', error); - this.showToast('error', 'Failed to fetch sources'); + this.showToast('error', error.message || 'Failed to fetch sources'); }) .finally(() => { this.loading.sources = false; @@ -404,6 +411,7 @@ function sourcesPage() { } this.loading.addSource = true; + fetch(`/api/collections/${this.selectedSourceCollection}/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -412,19 +420,16 @@ function sourcesPage() { update_interval: interval }) }) - .then(response => { - if (!response.ok) throw new Error('Failed to add source'); - return response.json(); - }) - .then(() => { - this.showToast('success', 'Source added successfully'); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || 'Source added successfully'); this.newSourceURL = ''; this.newSourceInterval = 60; this.listSources(); }) .catch(error => { console.error('Error adding source:', error); - this.showToast('error', 'Failed to add source'); + this.showToast('error', error.message || 'Failed to add source'); }) .finally(() => { this.loading.addSource = false; @@ -438,22 +443,20 @@ function sourcesPage() { } this.loading.removeSource = url; + fetch(`/api/collections/${this.selectedSourceCollection}/sources`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url }) }) - .then(response => { - if (!response.ok) throw new Error('Failed to remove source'); - return response.json(); - }) - .then(() => { - this.showToast('success', 'Source removed successfully'); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || 'Source removed successfully'); this.listSources(); }) .catch(error => { console.error('Error removing source:', error); - this.showToast('error', 'Failed to remove source'); + this.showToast('error', error.message || 'Failed to remove source'); }) .finally(() => { this.loading.removeSource = false; @@ -461,6 +464,7 @@ function sourcesPage() { }, showToast(type, message) { + const router = getRouter(); if (router) router.showToast(type, message); } @@ -479,6 +483,7 @@ function entriesPage() { }, get collections() { + const router = getRouter(); return router ? router.collections : []; }, @@ -488,17 +493,15 @@ function entriesPage() { this.loading.entries = true; this.entries = []; + fetch(`/api/collections/${this.selectedListCollection}/entries`) - .then(response => { - if (!response.ok) throw new Error('Failed to list entries'); - return response.json(); - }) + .then(response => handleAPIResponse(response)) .then(data => { - this.entries = data; + this.entries = data.data?.entries || []; }) .catch(error => { console.error('Error listing entries:', error); - this.showToast('error', 'Failed to fetch entries'); + this.showToast('error', error.message || 'Failed to fetch entries'); }) .finally(() => { this.loading.entries = false; @@ -512,19 +515,20 @@ function entriesPage() { } this.loading.delete = entry; + fetch(`/api/collections/${this.selectedListCollection}/entry/delete`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entry: entry }) }) - .then(response => { - if (!response.ok) throw new Error('Deletion failed'); - this.showToast('success', 'Entry deleted successfully'); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || 'Entry deleted successfully'); this.listEntries(); }) .catch(error => { console.error('Error deleting entry:', error); - this.showToast('error', 'Failed to delete entry'); + this.showToast('error', error.message || 'Failed to delete entry'); }) .finally(() => { this.loading.delete = false; @@ -556,20 +560,21 @@ function entriesPage() { resetCollection(collectionName) { this.loading.reset = collectionName; + fetch(`/api/collections/${collectionName}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) - .then(response => { - if (!response.ok) throw new Error('Reset failed'); - this.showToast('success', `Collection "${collectionName}" has been reset successfully`); + .then(response => handleAPIResponse(response)) + .then(data => { + this.showToast('success', data.message || `Collection "${collectionName}" has been reset successfully`); if (collectionName === this.selectedListCollection) { this.listEntries(); } }) .catch(error => { console.error('Error resetting collection:', error); - this.showToast('error', `Failed to reset collection: ${error.message}`); + this.showToast('error', error.message || `Failed to reset collection`); }) .finally(() => { this.loading.reset = false; @@ -577,6 +582,7 @@ function entriesPage() { }, showToast(type, message) { + const router = getRouter(); if (router) router.showToast(type, message); }