From d83fad6abce901f3b760dd80a4f90949b8295135 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 10:04:18 +0800 Subject: [PATCH] Refactor API structure to unify model operations - Introduced MODEL_TYPES and MODEL_CONFIG for centralized model type management. - Created a unified API client for checkpoints and loras to streamline operations. - Updated all API calls in checkpointApi.js and loraApi.js to use the new client. - Simplified context menus and model card operations to leverage the unified API client. - Enhanced state management to accommodate new model types and their configurations. - Added virtual scrolling functions for recipes and improved loading states. - Refactored modal utilities to handle model exclusion and deletion generically. - Improved error handling and user feedback across various operations. --- py/utils/routes_common.py | 1 + static/js/api/apiConfig.js | 169 +++ static/js/api/baseModelApi.js | 1049 ++++++++--------- static/js/api/checkpointApi.js | 172 +-- static/js/api/loraApi.js | 185 +-- static/js/api/recipeApi.js | 96 +- static/js/checkpoints.js | 5 +- .../ContextMenu/CheckpointContextMenu.js | 13 +- static/js/components/shared/ModelCard.js | 23 +- static/js/components/shared/ModelMetadata.js | 33 +- .../components/shared/showcase/MediaUtils.js | 7 +- static/js/managers/MoveManager.js | 7 +- static/js/state/index.js | 41 +- static/js/utils/modalUtils.js | 27 +- static/js/utils/uiHelpers.js | 27 + 15 files changed, 927 insertions(+), 928 deletions(-) create mode 100644 static/js/api/apiConfig.js diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 99f679cd..4e5c9807 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -1038,6 +1038,7 @@ class ModelRouteUtils: return web.json_response({ 'success': True, 'new_file_path': new_file_path, + 'new_preview_path': config.get_preview_static_url(new_preview), 'renamed_files': renamed_files, 'reload_required': False }) diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js new file mode 100644 index 00000000..6f02033f --- /dev/null +++ b/static/js/api/apiConfig.js @@ -0,0 +1,169 @@ +import { state } from '../state/index.js'; + +/** + * API Configuration + * Centralized configuration for all model types and their endpoints + */ + +// Model type definitions +export const MODEL_TYPES = { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings' // Future model type +}; + +// Base API configuration for each model type +export const MODEL_CONFIG = { + [MODEL_TYPES.LORA]: { + displayName: 'LoRA', + singularName: 'lora', + defaultPageSize: 100, + supportsLetterFilter: true, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'loras.html' + }, + [MODEL_TYPES.CHECKPOINT]: { + displayName: 'Checkpoint', + singularName: 'checkpoint', + defaultPageSize: 50, + supportsLetterFilter: false, + supportsBulkOperations: true, + supportsMove: false, + templateName: 'checkpoints.html' + }, + [MODEL_TYPES.EMBEDDING]: { + displayName: 'Embedding', + singularName: 'embedding', + defaultPageSize: 100, + supportsLetterFilter: true, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'embeddings.html' + } +}; + +/** + * Generate API endpoints for a given model type + * @param {string} modelType - The model type (e.g., 'loras', 'checkpoints') + * @returns {Object} Object containing all API endpoints for the model type + */ +export function getApiEndpoints(modelType) { + if (!Object.values(MODEL_TYPES).includes(modelType)) { + throw new Error(`Invalid model type: ${modelType}`); + } + + return { + // Base CRUD operations + list: `/api/${modelType}`, + delete: `/api/${modelType}/delete`, + exclude: `/api/${modelType}/exclude`, + rename: `/api/${modelType}/rename`, + save: `/api/${modelType}/save-metadata`, + + // Bulk operations + bulkDelete: `/api/${modelType}/bulk-delete`, + + // CivitAI integration + fetchCivitai: `/api/${modelType}/fetch-civitai`, + fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`, + relinkCivitai: `/api/${modelType}/relink-civitai`, + civitaiVersions: `/api/${modelType}/civitai/versions`, + + // Preview management + replacePreview: `/api/${modelType}/replace-preview`, + + // Query operations + scan: `/api/${modelType}/scan`, + topTags: `/api/${modelType}/top-tags`, + baseModels: `/api/${modelType}/base-models`, + roots: `/api/${modelType}/roots`, + folders: `/api/${modelType}/folders`, + duplicates: `/api/${modelType}/find-duplicates`, + conflicts: `/api/${modelType}/find-filename-conflicts`, + verify: `/api/${modelType}/verify-duplicates`, + + // Model-specific endpoints (will be merged with specific configs) + specific: {} + }; +} + +/** + * Model-specific endpoint configurations + */ +export const MODEL_SPECIFIC_ENDPOINTS = { + [MODEL_TYPES.LORA]: { + letterCounts: `/api/${MODEL_TYPES.LORA}/letter-counts`, + notes: `/api/${MODEL_TYPES.LORA}/get-notes`, + triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`, + previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`, + civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`, + modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`, + moveModel: `/api/${MODEL_TYPES.LORA}/move_model`, + moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`, + getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`, + civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`, + civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash` + }, + [MODEL_TYPES.CHECKPOINT]: { + info: `/api/${MODEL_TYPES.CHECKPOINT}/info` + }, + [MODEL_TYPES.EMBEDDING]: { + // Future embedding-specific endpoints + } +}; + +/** + * Get complete API configuration for a model type + * @param {string} modelType - The model type + * @returns {Object} Complete API configuration + */ +export function getCompleteApiConfig(modelType) { + const baseEndpoints = getApiEndpoints(modelType); + const specificEndpoints = MODEL_SPECIFIC_ENDPOINTS[modelType] || {}; + const config = MODEL_CONFIG[modelType]; + + return { + modelType, + config, + endpoints: { + ...baseEndpoints, + specific: specificEndpoints + } + }; +} + +/** + * Validate if a model type is supported + * @param {string} modelType - The model type to validate + * @returns {boolean} True if valid, false otherwise + */ +export function isValidModelType(modelType) { + return Object.values(MODEL_TYPES).includes(modelType); +} + +/** + * Get model type from current page or explicit parameter + * @param {string} [explicitType] - Explicitly provided model type + * @returns {string} The model type + */ +export function getCurrentModelType(explicitType = null) { + if (explicitType && isValidModelType(explicitType)) { + return explicitType; + } + + return state.currentPageType || MODEL_TYPES.LORA; +} + +// Download API endpoints (shared across all model types) +export const DOWNLOAD_ENDPOINTS = { + download: '/api/download-model', + downloadGet: '/api/download-model-get', + cancelGet: '/api/cancel-download-get', + progress: '/api/download-progress' +}; + +// WebSocket endpoints +export const WS_ENDPOINTS = { + fetchProgress: '/ws/fetch-progress' +}; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index fe915f86..116521ed 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,45 +1,464 @@ import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; +import { + getCompleteApiConfig, + getCurrentModelType, + isValidModelType, + DOWNLOAD_ENDPOINTS, + WS_ENDPOINTS +} from './apiConfig.js'; -// New method for virtual scrolling fetch -export async function fetchModelsPage(options = {}) { - const { - modelType = 'lora', - page = 1, - pageSize = 100, - endpoint = '/api/loras' - } = options; +/** + * Universal API client for all model types + */ +class ModelApiClient { + constructor(modelType = null) { + this.modelType = modelType || getCurrentModelType(); + this.apiConfig = getCompleteApiConfig(this.modelType); + } - const pageState = getCurrentPageState(); - - try { - const params = new URLSearchParams({ - page: page, - page_size: pageSize || pageState.pageSize || 20, - sort_by: pageState.sortBy - }); + /** + * Set the model type for this client instance + * @param {string} modelType - The model type to use + */ + setModelType(modelType) { + if (!isValidModelType(modelType)) { + throw new Error(`Invalid model type: ${modelType}`); + } + this.modelType = modelType; + this.apiConfig = getCompleteApiConfig(modelType); + } + + /** + * Get the current page state for this model type + */ + getPageState() { + const currentType = state.currentPageType; + // Temporarily switch to get the right page state + state.currentPageType = this.modelType; + const pageState = getCurrentPageState(); + state.currentPageType = currentType; // Restore + return pageState; + } + + /** + * Fetch models with pagination + */ + async fetchModelsPage(page = 1, pageSize = null) { + const pageState = this.getPageState(); + const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; + try { + const params = this._buildQueryParams({ + page, + page_size: actualPageSize, + sort_by: pageState.sortBy + }, pageState); + + const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); + } + + const data = await response.json(); + + return { + items: data.items, + totalItems: data.total, + totalPages: data.total_pages, + currentPage: page, + hasMore: page < data.total_pages, + folders: data.folders + }; + + } catch (error) { + console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error); + showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error'); + throw error; + } + } + + /** + * Delete a model + */ + async deleteModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.delete, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast(`${this.apiConfig.config.displayName} deleted successfully`, 'success'); + return true; + } else { + throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`); + } + } catch (error) { + console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error); + showToast(`Failed to delete ${this.apiConfig.config.singularName}: ${error.message}`, 'error'); + return false; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Exclude a model + */ + async excludeModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.exclude, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to exclude ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast(`${this.apiConfig.config.displayName} excluded successfully`, 'success'); + return true; + } else { + throw new Error(data.error || `Failed to exclude ${this.apiConfig.config.singularName}`); + } + } catch (error) { + console.error(`Error excluding ${this.apiConfig.config.singularName}:`, error); + showToast(`Failed to exclude ${this.apiConfig.config.singularName}: ${error.message}`, 'error'); + return false; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Rename a model file + */ + async renameModelFile(filePath, newFileName) { + try { + state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); + + const response = await fetch(this.apiConfig.endpoints.rename, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + new_file_name: newFileName + }) + }); + + const result = await response.json(); + + if (result.success) { + state.virtualScroller.updateSingleItem(filePath, { + file_name: newFileName, + file_path: result.new_file_path, + preview_url: result.new_preview_path + }); + + showToast('File name updated successfully', 'success'); + } else { + showToast('Failed to rename file: ' + (result.error || 'Unknown error'), 'error'); + } + + return result; + } catch (error) { + console.error(`Error renaming ${this.apiConfig.config.singularName} file:`, error); + throw error; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Replace model preview + */ + replaceModelPreview(filePath) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*,video/mp4'; + + input.onchange = async () => { + if (!input.files || !input.files[0]) return; + + const file = input.files[0]; + await this.uploadPreview(filePath, file); + }; + + input.click(); + } + + /** + * Upload preview image + */ + async uploadPreview(filePath, file, nsfwLevel = 0) { + try { + state.loadingManager.showSimpleLoading('Uploading preview...'); + + const formData = new FormData(); + formData.append('preview_file', file); + formData.append('model_path', filePath); + formData.append('nsfw_level', nsfwLevel.toString()); + + const response = await fetch(this.apiConfig.endpoints.replacePreview, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + const pageState = this.getPageState(); + + // Update the version timestamp + const timestamp = Date.now(); + if (pageState.previewVersions) { + pageState.previewVersions.set(filePath, timestamp); + + const storageKey = `${this.modelType}_preview_versions`; + saveMapToStorage(storageKey, pageState.previewVersions); + } + + const updateData = { + preview_url: data.preview_url, + preview_nsfw_level: data.preview_nsfw_level + }; + + state.virtualScroller.updateSingleItem(filePath, updateData); + showToast('Preview updated successfully', 'success'); + } catch (error) { + console.error('Error uploading preview:', error); + showToast('Failed to upload preview image', 'error'); + } finally { + state.loadingManager.hide(); + } + } + + /** + * Save model metadata + */ + async saveModelMetadata(filePath, data) { + try { + state.loadingManager.showSimpleLoading('Saving metadata...'); + + const response = await fetch(this.apiConfig.endpoints.save, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + state.virtualScroller.updateSingleItem(filePath, data); + return response.json(); + } finally { + state.loadingManager.hide(); + } + } + + /** + * Refresh models (scan) + */ + async refreshModels(fullRebuild = false) { + try { + state.loadingManager.showSimpleLoading( + `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...` + ); + + const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); + url.searchParams.append('full_rebuild', fullRebuild); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); + } + + showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); + } catch (error) { + console.error('Refresh failed:', error); + showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error'); + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } + } + + /** + * Fetch CivitAI metadata for single model + */ + async refreshSingleModelMetadata(filePath) { + try { + state.loadingManager.showSimpleLoading('Refreshing metadata...'); + + const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error('Failed to refresh metadata'); + } + + const data = await response.json(); + + if (data.success) { + if (data.metadata && state.virtualScroller) { + state.virtualScroller.updateSingleItem(filePath, data.metadata); + } + + showToast('Metadata refreshed successfully', 'success'); + return true; + } else { + throw new Error(data.error || 'Failed to refresh metadata'); + } + } catch (error) { + console.error('Error refreshing metadata:', error); + showToast(error.message, 'error'); + return false; + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } + } + + /** + * Fetch CivitAI metadata for all models + */ + async fetchCivitaiMetadata(resetAndReloadFunction) { + let ws = null; + + await state.loadingManager.showWithProgress(async (loading) => { + try { + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); + + const operationComplete = new Promise((resolve, reject) => { + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch(data.status) { + case 'started': + loading.setStatus('Starting metadata fetch...'); + break; + + case 'processing': + const percent = ((data.processed / data.total) * 100).toFixed(1); + loading.setProgress(percent); + loading.setStatus( + `Processing (${data.processed}/${data.total}) ${data.current_name}` + ); + break; + + case 'completed': + loading.setProgress(100); + loading.setStatus( + `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` + ); + resolve(); + break; + + case 'error': + reject(new Error(data.error)); + break; + } + }; + + ws.onerror = (error) => { + reject(new Error('WebSocket error: ' + error.message)); + }; + }); + + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + + const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + if (!response.ok) { + throw new Error('Failed to fetch metadata'); + } + + await operationComplete; + + if (typeof resetAndReloadFunction === 'function') { + await resetAndReloadFunction(); + } + + } catch (error) { + console.error('Error fetching metadata:', error); + showToast('Failed to fetch metadata: ' + error.message, 'error'); + } finally { + if (ws) { + ws.close(); + } + } + }, { + initialMessage: 'Connecting...', + completionMessage: 'Metadata update complete' + }); + } + + /** + * Build query parameters for API requests + */ + _buildQueryParams(baseParams, pageState) { + const params = new URLSearchParams(baseParams); + + // Add common parameters if (pageState.activeFolder !== null) { params.append('folder', pageState.activeFolder); } - // Add favorites filter parameter if enabled if (pageState.showFavoritesOnly) { params.append('favorites_only', 'true'); } - // Add active letter filter if set - if (pageState.activeLetterFilter) { + // Add letter filter for supported model types + if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { params.append('first_letter', pageState.activeLetterFilter); } - // Add search parameters if there's a search term + // Add search parameters if (pageState.filters?.search) { params.append('search', pageState.filters.search); params.append('fuzzy', 'true'); - // Add search option parameters if available if (pageState.searchOptions) { params.append('search_filename', pageState.searchOptions.filename.toString()); params.append('search_modelname', pageState.searchOptions.modelname.toString()); @@ -50,16 +469,14 @@ export async function fetchModelsPage(options = {}) { } } - // Add filter parameters if active + // Add filter parameters if (pageState.filters) { - // Handle tags filters if (pageState.filters.tags && pageState.filters.tags.length > 0) { pageState.filters.tags.forEach(tag => { params.append('tag', tag); }); } - // Handle base model filters if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { pageState.filters.baseModel.forEach(model => { params.append('base_model', model); @@ -68,17 +485,23 @@ export async function fetchModelsPage(options = {}) { } // Add model-specific parameters - if (modelType === 'lora') { - // Check for recipe-based filtering parameters from session storage + this._addModelSpecificParams(params, pageState); + + return params; + } + + /** + * Add model-specific parameters to query + */ + _addModelSpecificParams(params, pageState) { + // Override in specific implementations or handle via configuration + if (this.modelType === 'loras') { const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); - // Add hash filter parameter if present if (filterLoraHash) { params.append('lora_hash', filterLoraHash); - } - // Add multiple hashes filter if present - else if (filterLoraHashes) { + } else if (filterLoraHashes) { try { if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) { params.append('lora_hashes', filterLoraHashes.join(',')); @@ -88,546 +511,64 @@ export async function fetchModelsPage(options = {}) { } } } - - const response = await fetch(`${endpoint}?${params}`); - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.statusText}`); - } - - const data = await response.json(); - - return { - items: data.items, - totalItems: data.total, - totalPages: data.total_pages, - currentPage: page, - hasMore: page < data.total_pages, - folders: data.folders - }; - - } catch (error) { - console.error(`Error fetching ${modelType}s:`, error); - showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error'); - throw error; } } -/** - * Reset and reload models using virtual scrolling - * @param {Object} options - Operation options - * @returns {Promise} The fetch result - */ -export async function resetAndReloadWithVirtualScroll(options = {}) { - const { - modelType = 'lora', - updateFolders = false, - fetchPageFunction - } = options; - - const pageState = getCurrentPageState(); - - try { - pageState.isLoading = true; - - // Reset page counter - pageState.currentPage = 1; - - // Fetch the first page - const result = await fetchPageFunction(1, pageState.pageSize || 50); - - // Update the virtual scroller - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page will be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error(`Error reloading ${modelType}s:`, error); - showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); - throw error; - } finally { - pageState.isLoading = false; +// Export factory functions and utilities +export function createModelApiClient(modelType = null) { + return new ModelApiClient(modelType); +} + +let _singletonClient = null; + +export function getModelApiClient() { + if (!_singletonClient) { + _singletonClient = new ModelApiClient(); } + _singletonClient.setModelType(state.currentPageType); + return _singletonClient; } -/** - * Load more models using virtual scrolling - * @param {Object} options - Operation options - * @returns {Promise} The fetch result - */ -export async function loadMoreWithVirtualScroll(options = {}) { - const { - modelType = 'lora', - resetPage = false, - updateFolders = false, - fetchPageFunction - } = options; - - const pageState = getCurrentPageState(); - - try { - // Start loading state - pageState.isLoading = true; - - // Reset to first page if requested - if (resetPage) { - pageState.currentPage = 1; - } - - // Fetch the first page of data - const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); - - // Update virtual scroller with the new data - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page to load would be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error(`Error loading ${modelType}s:`, error); - showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); - throw error; - } finally { - pageState.isLoading = false; - } +// Legacy compatibility exports +export async function fetchModelsPage(options = {}) { + const { modelType = getCurrentModelType(), ...rest } = options; + const client = createModelApiClient(modelType); + return client.fetchModelsPage(rest.page, rest.pageSize); } -// Update folder tags in the UI -export function updateFolderTags(folders) { - const folderTagsContainer = document.querySelector('.folder-tags'); - if (!folderTagsContainer) return; - - // Keep track of currently selected folder - const pageState = getCurrentPageState(); - const currentFolder = pageState.activeFolder; - - // Create HTML for folder tags - const tagsHTML = folders.map(folder => { - const isActive = folder === currentFolder; - return `
${folder}
`; - }).join(''); - - // Update the container - folderTagsContainer.innerHTML = tagsHTML; - - // Reattach click handlers and ensure the active tag is visible - const tags = folderTagsContainer.querySelectorAll('.tag'); - tags.forEach(tag => { - if (typeof toggleFolder === 'function') { - tag.addEventListener('click', toggleFolder); - } - if (tag.dataset.folder === currentFolder) { - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); +export async function deleteModel(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.deleteModel(filePath); } -// Generic function to replace a model preview -export function replaceModelPreview(filePath, modelType = 'lora') { - // Open file picker - const input = document.createElement('input'); - input.type = 'file'; - input.accept ='image/*,video/mp4'; - - input.onchange = async function() { - if (!input.files || !input.files[0]) return; - - const file = input.files[0]; - await uploadPreview(filePath, file, modelType); - }; - - input.click(); +export async function excludeModel(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.excludeModel(filePath); } -// Delete a model (generic) -export async function deleteModel(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading(`Deleting ${modelType}...`); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/delete' - : '/api/loras/delete'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath - }) - }); - - if (!response.ok) { - throw new Error(`Failed to delete ${modelType}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // If virtual scroller exists, update its data - if (state.virtualScroller) { - state.virtualScroller.removeItemByFilePath(filePath); - } else { - // Legacy approach: remove the card from UI directly - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - } - - showToast(`${modelType} deleted successfully`, 'success'); - return true; - } else { - throw new Error(data.error || `Failed to delete ${modelType}`); - } - } catch (error) { - console.error(`Error deleting ${modelType}:`, error); - showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); - return false; - } finally { - state.loadingManager.hide(); - } +export async function renameModelFile(filePath, newFileName, modelType = null) { + const client = createModelApiClient(modelType); + return client.renameModelFile(filePath, newFileName); +} + +export async function replaceModelPreview(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.replaceModelPreview(filePath); } -// Generic function to refresh models export async function refreshModels(options = {}) { - const { - modelType = 'lora', - scanEndpoint = '/api/loras/scan', - resetAndReloadFunction, - fullRebuild = false // New parameter with default value false - } = options; - - try { - state.loadingManager.showSimpleLoading(`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${modelType}s...`); - - // Add fullRebuild parameter to the request - const url = new URL(scanEndpoint, window.location.origin); - url.searchParams.append('full_rebuild', fullRebuild); - - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`); - } - - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(true); // update folders - } - - showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); - } catch (error) { - console.error(`Refresh failed:`, error); - showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error'); - } finally { - state.loadingManager.hide(); - state.loadingManager.restoreProgressBar(); - } + const { modelType = getCurrentModelType(), fullRebuild = false } = options; + const client = createModelApiClient(modelType); + return client.refreshModels(fullRebuild); +} + +export async function refreshSingleModelMetadata(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.refreshSingleModelMetadata(filePath); } -// Generic fetch from Civitai export async function fetchCivitaiMetadata(options = {}) { - const { - modelType = 'lora', - fetchEndpoint = '/api/fetch-all-civitai', - resetAndReloadFunction - } = options; - - let ws = null; - - await state.loadingManager.showWithProgress(async (loading) => { - try { - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); - - const operationComplete = new Promise((resolve, reject) => { - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch(data.status) { - case 'started': - loading.setStatus('Starting metadata fetch...'); - break; - - case 'processing': - const percent = ((data.processed / data.total) * 100).toFixed(1); - loading.setProgress(percent); - loading.setStatus( - `Processing (${data.processed}/${data.total}) ${data.current_name}` - ); - break; - - case 'completed': - loading.setProgress(100); - loading.setStatus( - `Completed: Updated ${data.success} of ${data.processed} ${modelType}s` - ); - resolve(); - break; - - case 'error': - reject(new Error(data.error)); - break; - } - }; - - ws.onerror = (error) => { - reject(new Error('WebSocket error: ' + error.message)); - }; - }); - - await new Promise((resolve, reject) => { - ws.onopen = resolve; - ws.onerror = reject; - }); - - const requestBody = modelType === 'checkpoint' - ? JSON.stringify({ model_type: 'checkpoint' }) - : JSON.stringify({}); - - const response = await fetch(fetchEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: requestBody - }); - - if (!response.ok) { - throw new Error('Failed to fetch metadata'); - } - - await operationComplete; - - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(); - } - - } catch (error) { - console.error('Error fetching metadata:', error); - showToast('Failed to fetch metadata: ' + error.message, 'error'); - } finally { - if (ws) { - ws.close(); - } - } - }, { - initialMessage: 'Connecting...', - completionMessage: 'Metadata update complete' - }); -} - -// Generic function to refresh single model metadata -export async function refreshSingleModelMetadata(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading('Refreshing metadata...'); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/fetch-civitai' - : '/api/loras/fetch-civitai'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ file_path: filePath }) - }); - - if (!response.ok) { - throw new Error('Failed to refresh metadata'); - } - - const data = await response.json(); - - if (data.success) { - // Use the returned metadata to update just this single item - if (data.metadata && state.virtualScroller) { - state.virtualScroller.updateSingleItem(filePath, data.metadata); - } - - showToast('Metadata refreshed successfully', 'success'); - return true; - } else { - throw new Error(data.error || 'Failed to refresh metadata'); - } - } catch (error) { - console.error('Error refreshing metadata:', error); - showToast(error.message, 'error'); - return false; - } finally { - state.loadingManager.hide(); - state.loadingManager.restoreProgressBar(); - } -} - -// Generic function to exclude a model -export async function excludeModel(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading(`Excluding ${modelType}...`); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/exclude' - : '/api/loras/exclude'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath - }) - }); - - if (!response.ok) { - throw new Error(`Failed to exclude ${modelType}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // If virtual scroller exists, update its data - if (state.virtualScroller) { - state.virtualScroller.removeItemByFilePath(filePath); - } else { - // Legacy approach: remove the card from UI directly - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - } - - showToast(`${modelType} excluded successfully`, 'success'); - return true; - } else { - throw new Error(data.error || `Failed to exclude ${modelType}`); - } - } catch (error) { - console.error(`Error excluding ${modelType}:`, error); - showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error'); - return false; - } finally { - state.loadingManager.hide(); - } -} - -// Upload a preview image -export async function uploadPreview(filePath, file, modelType = 'lora', nsfwLevel = 0) { - try { - state.loadingManager.showSimpleLoading('Uploading preview...'); - - const formData = new FormData(); - - // Prepare common form data - formData.append('preview_file', file); - formData.append('model_path', filePath); - formData.append('nsfw_level', nsfwLevel.toString()); // Add nsfw_level parameter - - // Set endpoint based on model type - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/replace-preview' - : '/api/loras/replace_preview'; - - const response = await fetch(endpoint, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error('Upload failed'); - } - - const data = await response.json(); - - // Get the current page's previewVersions Map based on model type - const pageType = modelType === 'checkpoint' ? 'checkpoints' : 'loras'; - const previewVersions = state.pages[pageType].previewVersions; - - // Update the version timestamp - const timestamp = Date.now(); - if (previewVersions) { - previewVersions.set(filePath, timestamp); - - // Save the updated Map to localStorage - const storageKey = modelType === 'checkpoint' ? 'checkpoint_preview_versions' : 'lora_preview_versions'; - saveMapToStorage(storageKey, previewVersions); - } - - const updateData = { - preview_url: data.preview_url, - preview_nsfw_level: data.preview_nsfw_level // Include nsfw level in update data - }; - - state.virtualScroller.updateSingleItem(filePath, updateData); - - showToast('Preview updated successfully', 'success'); - } catch (error) { - console.error('Error uploading preview:', error); - showToast('Failed to upload preview image', 'error'); - } finally { - state.loadingManager.hide(); - } -} - -// Private methods - -// Private function to perform the delete operation -async function performDelete(filePath, modelType = 'lora') { - try { - showToast(`Deleting ${modelType}...`, 'info'); - - const response = await fetch('/api/model/delete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_path: filePath, - model_type: modelType - }) - }); - - if (!response.ok) { - throw new Error(`Failed to delete ${modelType}: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // Remove the card from UI - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - - showToast(`${modelType} deleted successfully`, 'success'); - } else { - throw new Error(data.error || `Failed to delete ${modelType}`); - } - } catch (error) { - console.error(`Error deleting ${modelType}:`, error); - showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); - } + const { modelType = getCurrentModelType(), resetAndReloadFunction } = options; + const client = createModelApiClient(modelType); + return client.fetchCivitaiMetadata(resetAndReloadFunction); } \ No newline at end of file diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index a595b2aa..574572a1 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -1,165 +1,43 @@ -import { - fetchModelsPage, - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll, - refreshModels as baseRefreshModels, - deleteModel as baseDeleteModel, - replaceModelPreview, - fetchCivitaiMetadata, - refreshSingleModelMetadata, - excludeModel as baseExcludeModel -} from './baseModelApi.js'; -import { state } from '../state/index.js'; +import { createModelApiClient } from './baseModelApi.js'; +import { MODEL_TYPES } from './apiConfig.js'; -/** - * Fetch checkpoints with pagination for virtual scrolling - * @param {number} page - Page number to fetch - * @param {number} pageSize - Number of items per page - * @returns {Promise} Object containing items, total count, and pagination info - */ -export async function fetchCheckpointsPage(page = 1, pageSize = 100) { - return fetchModelsPage({ - modelType: 'checkpoint', - page, - pageSize, - endpoint: '/api/checkpoints' - }); -} +// Create Checkpoint-specific API client +const checkpointApiClient = createModelApiClient(MODEL_TYPES.CHECKPOINT); -/** - * Load more checkpoints with pagination - updated to work with VirtualScroller - * @param {boolean} resetPage - Whether to reset to the first page - * @param {boolean} updateFolders - Whether to update folder tags - * @returns {Promise} - */ +// Export all common operations using the unified client +export const deleteModel = (filePath) => checkpointApiClient.deleteModel(filePath); +export const excludeCheckpoint = (filePath) => checkpointApiClient.excludeModel(filePath); +export const renameCheckpointFile = (filePath, newFileName) => checkpointApiClient.renameModelFile(filePath, newFileName); +export const replacePreview = (filePath) => checkpointApiClient.replaceModelPreview(filePath); +export const saveModelMetadata = (filePath, data) => checkpointApiClient.saveModelMetadata(filePath, data); +export const refreshCheckpoints = (fullRebuild = false) => checkpointApiClient.refreshModels(fullRebuild); +export const refreshSingleCheckpointMetadata = (filePath) => checkpointApiClient.refreshSingleModelMetadata(filePath); +export const fetchCivitai = (resetAndReloadFunction) => checkpointApiClient.fetchCivitaiMetadata(resetAndReloadFunction); + +// Pagination functions +export const fetchCheckpointsPage = (page = 1, pageSize = 50) => checkpointApiClient.fetchModelsPage(page, pageSize); + +// Virtual scrolling operations export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) { - return loadMoreWithVirtualScroll({ - modelType: 'checkpoint', - resetPage, - updateFolders, - fetchPageFunction: fetchCheckpointsPage - }); + return checkpointApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders); } -// Reset and reload checkpoints export async function resetAndReload(updateFolders = false) { - return resetAndReloadWithVirtualScroll({ - modelType: 'checkpoint', - updateFolders, - fetchPageFunction: fetchCheckpointsPage - }); + return checkpointApiClient.resetAndReloadWithVirtualScroll(updateFolders); } -// Refresh checkpoints -export async function refreshCheckpoints(fullRebuild = false) { - return baseRefreshModels({ - modelType: 'checkpoint', - scanEndpoint: '/api/checkpoints/scan', - resetAndReloadFunction: resetAndReload, - fullRebuild: fullRebuild - }); -} - -// Delete a checkpoint -export function deleteCheckpoint(filePath) { - return baseDeleteModel(filePath, 'checkpoint'); -} - -// Replace checkpoint preview -export function replaceCheckpointPreview(filePath) { - return replaceModelPreview(filePath, 'checkpoint'); -} - -// Fetch metadata from Civitai for checkpoints -export async function fetchCivitai() { - return fetchCivitaiMetadata({ - modelType: 'checkpoint', - fetchEndpoint: '/api/checkpoints/fetch-all-civitai', - resetAndReloadFunction: resetAndReload - }); -} - -// Refresh single checkpoint metadata -export async function refreshSingleCheckpointMetadata(filePath) { - await refreshSingleModelMetadata(filePath, 'checkpoint'); -} - -/** - * Save model metadata to the server - * @param {string} filePath - Path to the model file - * @param {Object} data - Metadata to save - * @returns {Promise} - Promise that resolves with the server response - */ -export async function saveModelMetadata(filePath, data) { +// Checkpoint-specific functions +export async function getCheckpointInfo(name) { try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Saving metadata...'); - - const response = await fetch('/api/checkpoints/save-metadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - ...data - }) - }); - - if (!response.ok) { - throw new Error('Failed to save metadata'); - } - - // Update the virtual scroller with the new metadata - state.virtualScroller.updateSingleItem(filePath, data); - - return response.json(); - } finally { - // Always hide the loading indicator when done - state.loadingManager.hide(); - } -} - -/** - * Exclude a checkpoint model from being shown in the UI - * @param {string} filePath - File path of the checkpoint to exclude - * @returns {Promise} Promise resolving to success status - */ -export function excludeCheckpoint(filePath) { - return baseExcludeModel(filePath, 'checkpoint'); -} - -/** - * Rename a checkpoint file - * @param {string} filePath - Current file path - * @param {string} newFileName - New file name (without path) - * @returns {Promise} - Promise that resolves with the server response - */ -export async function renameCheckpointFile(filePath, newFileName) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Renaming checkpoint file...'); - - const response = await fetch('/api/checkpoints/rename', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - new_file_name: newFileName - }) - }); + const response = await fetch(`${checkpointApiClient.apiConfig.endpoints.specific.info}/${encodeURIComponent(name)}`); if (!response.ok) { - throw new Error(`Server returned ${response.status}: ${response.statusText}`); + throw new Error(`Failed to fetch checkpoint info: ${response.statusText}`); } return await response.json(); } catch (error) { - console.error('Error renaming checkpoint file:', error); + console.error('Error fetching checkpoint info:', error); throw error; - } finally { - state.loadingManager.hide(); } } \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index a7f0bccd..738571fb 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -1,131 +1,35 @@ -import { - fetchModelsPage, - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll, - refreshModels as baseRefreshModels, - deleteModel as baseDeleteModel, - replaceModelPreview, - fetchCivitaiMetadata, - refreshSingleModelMetadata, - excludeModel as baseExcludeModel -} from './baseModelApi.js'; -import { state } from '../state/index.js'; +import { createModelApiClient } from './baseModelApi.js'; +import { MODEL_TYPES } from './apiConfig.js'; -/** - * Save model metadata to the server - * @param {string} filePath - File path - * @param {Object} data - Data to save - * @returns {Promise} Promise of the save operation - */ -export async function saveModelMetadata(filePath, data) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Saving metadata...'); - - const response = await fetch('/api/loras/save-metadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - ...data - }) - }); +// Create LoRA-specific API client +const loraApiClient = createModelApiClient(MODEL_TYPES.LORA); - if (!response.ok) { - throw new Error('Failed to save metadata'); - } +// Export all common operations using the unified client +export const deleteModel = (filePath) => loraApiClient.deleteModel(filePath); +export const excludeLora = (filePath) => loraApiClient.excludeModel(filePath); +export const renameLoraFile = (filePath, newFileName) => loraApiClient.renameModelFile(filePath, newFileName); +export const replacePreview = (filePath) => loraApiClient.replaceModelPreview(filePath); +export const saveModelMetadata = (filePath, data) => loraApiClient.saveModelMetadata(filePath, data); +export const refreshLoras = (fullRebuild = false) => loraApiClient.refreshModels(fullRebuild); +export const refreshSingleLoraMetadata = (filePath) => loraApiClient.refreshSingleModelMetadata(filePath); +export const fetchCivitai = (resetAndReloadFunction) => loraApiClient.fetchCivitaiMetadata(resetAndReloadFunction); - // Update the virtual scroller with the new data - state.virtualScroller.updateSingleItem(filePath, data); - - return response.json(); - } finally { - // Always hide the loading indicator when done - state.loadingManager.hide(); - } -} +// Pagination functions +export const fetchLorasPage = (page = 1, pageSize = 100) => loraApiClient.fetchModelsPage(page, pageSize); -/** - * Exclude a lora model from being shown in the UI - * @param {string} filePath - File path of the model to exclude - * @returns {Promise} Promise resolving to success status - */ -export async function excludeLora(filePath) { - return baseExcludeModel(filePath, 'lora'); -} - -/** - * Load more loras with pagination - updated to work with VirtualScroller - * @param {boolean} resetPage - Whether to reset to the first page - * @param {boolean} updateFolders - Whether to update folder tags - * @returns {Promise} - */ +// Virtual scrolling operations export async function loadMoreLoras(resetPage = false, updateFolders = false) { - return loadMoreWithVirtualScroll({ - modelType: 'lora', - resetPage, - updateFolders, - fetchPageFunction: fetchLorasPage - }); -} - -/** - * Fetch loras with pagination for virtual scrolling - * @param {number} page - Page number to fetch - * @param {number} pageSize - Number of items per page - * @returns {Promise} Object containing items, total count, and pagination info - */ -export async function fetchLorasPage(page = 1, pageSize = 100) { - return fetchModelsPage({ - modelType: 'lora', - page, - pageSize, - endpoint: '/api/loras' - }); -} - -export async function fetchCivitai() { - return fetchCivitaiMetadata({ - modelType: 'lora', - fetchEndpoint: '/api/loras/fetch-all-civitai', - resetAndReloadFunction: resetAndReload - }); -} - -export async function deleteModel(filePath) { - return baseDeleteModel(filePath, 'lora'); -} - -export async function replacePreview(filePath) { - return replaceModelPreview(filePath, 'lora'); + return loraApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders); } export async function resetAndReload(updateFolders = false) { - return resetAndReloadWithVirtualScroll({ - modelType: 'lora', - updateFolders, - fetchPageFunction: fetchLorasPage - }); -} - -export async function refreshLoras(fullRebuild = false) { - return baseRefreshModels({ - modelType: 'lora', - scanEndpoint: '/api/loras/scan', - resetAndReloadFunction: resetAndReload, - fullRebuild: fullRebuild - }); -} - -export async function refreshSingleLoraMetadata(filePath) { - await refreshSingleModelMetadata(filePath, 'lora'); + return loraApiClient.resetAndReloadWithVirtualScroll(updateFolders); } +// LoRA-specific functions that don't have common equivalents export async function fetchModelDescription(modelId, filePath) { try { - const response = await fetch(`/api/loras/model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + const response = await fetch(`${loraApiClient.apiConfig.endpoints.specific.modelDescription}?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); if (!response.ok) { throw new Error(`Failed to fetch model description: ${response.statusText}`); @@ -138,38 +42,47 @@ export async function fetchModelDescription(modelId, filePath) { } } -/** - * Rename a LoRA file - * @param {string} filePath - Current file path - * @param {string} newFileName - New file name (without path) - * @returns {Promise} - Promise that resolves with the server response - */ -export async function renameLoraFile(filePath, newFileName) { +// Move operations (LoRA-specific) +export async function moveModel(filePath, targetPath) { try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Renaming LoRA file...'); - - const response = await fetch('/api/loras/rename', { + const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveModel, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: filePath, - new_file_name: newFileName + target_path: targetPath }) }); if (!response.ok) { - throw new Error(`Server returned ${response.status}: ${response.statusText}`); + throw new Error('Failed to move model'); } return await response.json(); } catch (error) { - console.error('Error renaming LoRA file:', error); + console.error('Error moving model:', error); + throw error; + } +} + +export async function moveModelsBulk(filePaths, targetPath) { + try { + const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveBulk, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_paths: filePaths, + target_path: targetPath + }) + }); + + if (!response.ok) { + throw new Error('Failed to move models'); + } + + return await response.json(); + } catch (error) { + console.error('Error moving models in bulk:', error); throw error; - } finally { - // Hide loading indicator - state.loadingManager.hide(); } } \ No newline at end of file diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 0072879d..e9e83ca7 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -1,8 +1,4 @@ import { RecipeCard } from '../components/RecipeCard.js'; -import { - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll -} from './baseModelApi.js'; import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; @@ -98,6 +94,98 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } } +/** + * Reset and reload models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function resetAndReloadWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + pageState.isLoading = true; + + // Reset page counter + pageState.currentPage = 1; + + // Fetch the first page + const result = await fetchPageFunction(1, pageState.pageSize || 50); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page will be 2 + + return result; + } catch (error) { + console.error(`Error reloading ${modelType}s:`, error); + showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + } +} + +/** + * Load more models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function loadMoreWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + resetPage = false, + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + // Start loading state + pageState.isLoading = true; + + // Reset to first page if requested + if (resetPage) { + pageState.currentPage = 1; + } + + // Fetch the first page of data + const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); + + // Update virtual scroller with the new data + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page to load would be 2 + + return result; + } catch (error) { + console.error(`Error loading ${modelType}s:`, error); + showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + } +} + /** * Reset and reload recipes using virtual scrolling * @param {boolean} updateFolders - Whether to update folder tags diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 68b5cb8b..69ab6224 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -5,18 +5,19 @@ import { loadMoreCheckpoints } from './api/checkpointApi.js'; import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { MODEL_TYPES } from './api/apiConfig.js'; // Initialize the Checkpoints page class CheckpointsPageManager { constructor() { // Initialize page controls - this.pageControls = createPageControls('checkpoints'); + this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT); // Initialize checkpoint download manager window.checkpointDownloadManager = new CheckpointDownloadManager(); // Initialize the ModelDuplicatesManager - this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints'); + this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.CHECKPOINT); // Expose only necessary functions to global scope this._exposeRequiredGlobalFunctions(); diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 16ab4b44..8e589b51 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -1,6 +1,7 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; -import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview, resetAndReload } from '../../api/checkpointApi.js'; +import { resetAndReload } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { showToast } from '../../utils/uiHelpers.js'; import { showExcludeModal } from '../../utils/modalUtils.js'; @@ -19,7 +20,7 @@ export class CheckpointContextMenu extends BaseContextMenu { // Implementation needed by the mixin async saveModelMetadata(filePath, data) { - return saveModelMetadata(filePath, data); + return getModelApiClient().saveModelMetadata(filePath, data); } handleMenuAction(action) { @@ -28,6 +29,8 @@ export class CheckpointContextMenu extends BaseContextMenu { return; } + const apiClient = getModelApiClient(); + // Otherwise handle checkpoint-specific actions switch(action) { case 'details': @@ -36,7 +39,7 @@ export class CheckpointContextMenu extends BaseContextMenu { break; case 'replace-preview': // Add new action for replacing preview images - replaceCheckpointPreview(this.currentCard.dataset.filepath); + apiClient.replaceModelPreview(this.currentCard.dataset.filepath); break; case 'delete': // Delete checkpoint @@ -52,14 +55,14 @@ export class CheckpointContextMenu extends BaseContextMenu { break; case 'refresh-metadata': // Refresh metadata from CivitAI - refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); + apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); break; case 'move': // Move to folder (placeholder) showToast('Move to folder feature coming soon', 'info'); break; case 'exclude': - showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint'); + showExcludeModal(this.currentCard.dataset.filepath); break; } } diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index d0f5a125..7c4409e5 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -4,8 +4,7 @@ import { showModelModal } from './ModelModal.js'; import { bulkManager } from '../../managers/BulkManager.js'; import { modalManager } from '../../managers/ModalManager.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; -import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { showDeleteModal } from '../../utils/modalUtils.js'; // Add global event delegation handlers @@ -31,6 +30,8 @@ function handleModelCardEvent_internal(event, modelType) { // Find the closest card element const card = event.target.closest('.lora-card'); if (!card) return; + + const apiClient = getModelApiClient(); // Handle specific elements within the card if (event.target.closest('.toggle-blur-btn')) { @@ -73,13 +74,13 @@ function handleModelCardEvent_internal(event, modelType) { if (event.target.closest('.fa-trash')) { event.stopPropagation(); - showDeleteModal(card.dataset.filepath, modelType); + showDeleteModal(card.dataset.filepath); return; } if (event.target.closest('.fa-image')) { event.stopPropagation(); - handleReplacePreview(card.dataset.filepath, modelType); + apiClient.replaceModelPreview(card.dataset.filepath); return; } @@ -136,9 +137,7 @@ async function toggleFavorite(card, modelType) { const newFavoriteState = !isFavorite; try { - // Use the appropriate save function based on model type - const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; - await saveFunction(card.dataset.filepath, { + await apiClient.saveModelMetadata(card.dataset.filepath, { favorite: newFavoriteState }); @@ -179,15 +178,7 @@ function handleCopyAction(card, modelType) { } function handleReplacePreview(filePath, modelType) { - if (modelType === 'lora') { - replacePreview(filePath); - } else { - if (window.replaceCheckpointPreview) { - window.replaceCheckpointPreview(filePath); - } else { - apiReplaceCheckpointPreview(filePath); - } - } + apiClient.replaceModelPreview(filePath); } async function handleExampleImagesAccess(card, modelType) { diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index d6cdf1f4..27f433df 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -7,6 +7,7 @@ import { BASE_MODELS } from '../../utils/constants.js'; import { state } from '../../state/index.js'; import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js'; import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Set up model name editing functionality @@ -114,9 +115,7 @@ export function setupModelNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - - await saveFunction(filePath, { model_name: newModelName }); + await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName }); showToast('Model name updated successfully', 'success'); } catch (error) { @@ -295,9 +294,7 @@ async function saveBaseModel(filePath, originalValue) { } try { - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - - await saveFunction(filePath, { base_model: newBaseModel }); + await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel }); showToast('Base model updated successfully', 'success'); } catch (error) { @@ -417,29 +414,7 @@ export function setupFileNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - let result; - - if (state.currentPageType === 'checkpoints') { - result = await renameCheckpointFile(filePath, newFileName); - } else { - // Use LoRA rename function - result = await renameLoraFile(filePath, newFileName); - } - - if (result.success) { - showToast('File name updated successfully', 'success'); - - // Update virtual scroller if available (mainly for LoRAs) - if (state.virtualScroller && typeof state.virtualScroller.updateSingleItem === 'function') { - const newFilePath = filePath.replace(originalValue, newFileName); - state.virtualScroller.updateSingleItem(filePath, { - file_name: newFileName, - file_path: newFilePath - }); - } - } else { - throw new Error(result.error || 'Unknown error'); - } + await getModelApiClient().renameModelFile(filePath, newFileName); } catch (error) { console.error('Error renaming file:', error); this.textContent = originalValue; // Restore original file name diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js index e7580262..5a19749f 100644 --- a/static/js/components/shared/showcase/MediaUtils.js +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -5,7 +5,7 @@ */ import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; -import { uploadPreview } from '../../../api/baseModelApi.js'; +import { getModelApiClient } from '../../../api/baseModelApi.js'; /** * Try to load local image first, fall back to remote if local fails @@ -515,6 +515,7 @@ function initSetPreviewHandlers(container) { // Get local file path if available const useLocalFile = mediaElement.dataset.localSrc && !mediaElement.dataset.localSrc.includes('undefined'); + const apiClient = getModelApiClient(); if (useLocalFile) { // We have a local file, use it directly @@ -523,7 +524,7 @@ function initSetPreviewHandlers(container) { const file = new File([blob], 'preview.jpg', { type: blob.type }); // Use the existing baseModelApi uploadPreview method with nsfw level - await uploadPreview(modelFilePath, file, modelType, nsfwLevel); + await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); } else { // We need to download the remote file first const response = await fetch(mediaElement.src); @@ -531,7 +532,7 @@ function initSetPreviewHandlers(container) { const file = new File([blob], 'preview.jpg', { type: blob.type }); // Use the existing baseModelApi uploadPreview method with nsfw level - await uploadPreview(modelFilePath, file, modelType, nsfwLevel); + await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); } } catch (error) { console.error('Error setting preview:', error); diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 50f735ae..3798edab 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -1,8 +1,7 @@ -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; -import { updateFolderTags } from '../api/baseModelApi.js'; class MoveManager { constructor() { @@ -219,7 +218,7 @@ class MoveManager { return; } - const response = await fetch('/api/loras/move_model', { + const response = await fetch('/api/move_model', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -257,7 +256,7 @@ class MoveManager { return; } - const response = await fetch('/api/loras/move_models_bulk', { + const response = await fetch('/api/move_models_bulk', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/static/js/state/index.js b/static/js/state/index.js index 8343692d..ac77bbc2 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -1,16 +1,18 @@ // Create the new hierarchical state structure import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; +import { MODEL_TYPES } from '../api/apiConfig.js'; // Load settings from localStorage or use defaults const savedSettings = getStorageItem('settings', { blurMatureContent: true, show_only_sfw: false, - cardInfoDisplay: 'always' // Add default value for card info display + cardInfoDisplay: 'always' }); -// Load preview versions from localStorage +// Load preview versions from localStorage for each model type const loraPreviewVersions = getMapFromStorage('lora_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoint_preview_versions'); +const embeddingPreviewVersions = getMapFromStorage('embedding_preview_versions'); export const state = { // Global state @@ -22,13 +24,13 @@ export const state = { // Page-specific states pages: { - loras: { + [MODEL_TYPES.LORA]: { currentPage: 1, isLoading: false, hasMore: true, sortBy: 'name', activeFolder: null, - activeLetterFilter: null, // New property for letter filtering + activeLetterFilter: null, previewVersions: loraPreviewVersions, searchManager: null, searchOptions: { @@ -67,10 +69,10 @@ export const state = { }, pageSize: 20, showFavoritesOnly: false, - duplicatesMode: false, // Add flag for duplicates mode + duplicatesMode: false, }, - checkpoints: { + [MODEL_TYPES.CHECKPOINT]: { currentPage: 1, isLoading: false, hasMore: true, @@ -89,11 +91,34 @@ export const state = { }, showFavoritesOnly: false, duplicatesMode: false, + }, + + [MODEL_TYPES.EMBEDDING]: { + currentPage: 1, + isLoading: false, + hasMore: true, + sortBy: 'name', + activeFolder: null, + activeLetterFilter: null, + previewVersions: embeddingPreviewVersions, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + recursive: false + }, + filters: { + baseModel: [], + tags: [] + }, + showFavoritesOnly: false, + duplicatesMode: false, } }, - // Current active page - currentPageType: 'loras', + // Current active page - use MODEL_TYPES constants + currentPageType: MODEL_TYPES.LORA, // Backward compatibility - proxy properties get currentPage() { return this.pages[this.currentPageType].currentPage; }, diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index 041a8ee3..512b0c31 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -1,15 +1,13 @@ import { modalManager } from '../managers/ModalManager.js'; -import { excludeLora, deleteModel as deleteLora } from '../api/loraApi.js'; -import { excludeCheckpoint, deleteCheckpoint } from '../api/checkpointApi.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; + +const apiClient = getModelApiClient(); let pendingDeletePath = null; -let pendingModelType = null; let pendingExcludePath = null; -let pendingExcludeModelType = null; -export function showDeleteModal(filePath, modelType = 'lora') { +export function showDeleteModal(filePath) { pendingDeletePath = filePath; - pendingModelType = modelType; const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); @@ -29,12 +27,7 @@ export async function confirmDelete() { if (!pendingDeletePath) return; try { - // Use appropriate delete function based on model type - if (pendingModelType === 'checkpoint') { - await deleteCheckpoint(pendingDeletePath); - } else { - await deleteLora(pendingDeletePath); - } + await apiClient.deleteModel(pendingDeletePath); closeDeleteModal(); @@ -54,9 +47,8 @@ export function closeDeleteModal() { } // Functions for the exclude modal -export function showExcludeModal(filePath, modelType = 'lora') { +export function showExcludeModal(filePath) { pendingExcludePath = filePath; - pendingExcludeModelType = modelType; const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); @@ -82,12 +74,7 @@ export async function confirmExclude() { if (!pendingExcludePath) return; try { - // Use appropriate exclude function based on model type - if (pendingExcludeModelType === 'checkpoint') { - await excludeCheckpoint(pendingExcludePath); - } else { - await excludeLora(pendingExcludePath); - } + await apiClient.excludeModel(pendingExcludePath); closeExcludeModal(); diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index adaf3b66..1d03a01f 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -615,4 +615,31 @@ export async function openExampleImagesFolder(modelHash) { showToast('Failed to open example images folder', 'error'); return false; } +} + +/** + * Update the folder tags display with new folder list + * @param {Array} folders - List of folder names + */ +export function updateFolderTags(folders) { + const folderTagsContainer = document.querySelector('.folder-tags'); + if (!folderTagsContainer) return; + + // Keep track of currently selected folder + const currentFolder = this.pageState.activeFolder; + + // Create HTML for folder tags + const tagsHTML = folders.map(folder => { + const isActive = folder === currentFolder; + return `
${folder}
`; + }).join(''); + + // Update the container + folderTagsContainer.innerHTML = tagsHTML; + + // Scroll active folder into view (no need to reattach click handlers) + const activeTag = folderTagsContainer.querySelector(`.tag[data-folder="${currentFolder}"]`); + if (activeTag) { + activeTag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } } \ No newline at end of file