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.
This commit is contained in:
Will Miao
2025-07-25 10:04:18 +08:00
parent 692796db46
commit d83fad6abc
15 changed files with 927 additions and 928 deletions

View File

@@ -1038,6 +1038,7 @@ class ModelRouteUtils:
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'new_file_path': new_file_path, 'new_file_path': new_file_path,
'new_preview_path': config.get_preview_static_url(new_preview),
'renamed_files': renamed_files, 'renamed_files': renamed_files,
'reload_required': False 'reload_required': False
}) })

169
static/js/api/apiConfig.js Normal file
View File

@@ -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'
};

View File

@@ -1,97 +1,64 @@
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.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 = {}) { * Universal API client for all model types
const { */
modelType = 'lora', class ModelApiClient {
page = 1, constructor(modelType = null) {
pageSize = 100, this.modelType = modelType || getCurrentModelType();
endpoint = '/api/loras' this.apiConfig = getCompleteApiConfig(this.modelType);
} = options; }
/**
* 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(); 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 { try {
const params = new URLSearchParams({ const params = this._buildQueryParams({
page: page, page,
page_size: pageSize || pageState.pageSize || 20, page_size: actualPageSize,
sort_by: pageState.sortBy sort_by: pageState.sortBy
}); }, pageState);
if (pageState.activeFolder !== null) { const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`);
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) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters if there's a search term
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());
if (pageState.searchOptions.tags !== undefined) {
params.append('search_tags', pageState.searchOptions.tags.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
// Add filter parameters if active
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);
});
}
}
// Add model-specific parameters
if (modelType === 'lora') {
// Check for recipe-based filtering parameters from session storage
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) {
try {
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
params.append('lora_hashes', filterLoraHashes.join(','));
}
} catch (error) {
console.error('Error parsing lora hashes from session storage:', error);
}
}
}
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`); throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
@@ -106,262 +73,295 @@ export async function fetchModelsPage(options = {}) {
}; };
} catch (error) { } catch (error) {
console.error(`Error fetching ${modelType}s:`, error); console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error);
showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error'); showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
throw error; throw error;
} }
} }
/** /**
* Reset and reload models using virtual scrolling * Delete a model
* @param {Object} options - Operation options
* @returns {Promise<Object>} The fetch result
*/ */
export async function resetAndReloadWithVirtualScroll(options = {}) { async deleteModel(filePath) {
const {
modelType = 'lora',
updateFolders = false,
fetchPageFunction
} = options;
const pageState = getCurrentPageState();
try { try {
pageState.isLoading = true; state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`);
// Reset page counter const response = await fetch(this.apiConfig.endpoints.delete, {
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;
}
}
/**
* Load more models using virtual scrolling
* @param {Object} options - Operation options
* @returns {Promise<Object>} 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;
}
}
// 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 `<div class="tag ${isActive ? 'active' : ''}" data-folder="${folder}">${folder}</div>`;
}).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' });
}
});
}
// 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();
}
// 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', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({ file_path: filePath })
},
body: JSON.stringify({
file_path: filePath
})
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to delete ${modelType}: ${response.statusText}`); throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
// If virtual scroller exists, update its data
if (state.virtualScroller) { if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath); 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(`${this.apiConfig.config.displayName} deleted successfully`, 'success');
showToast(`${modelType} deleted successfully`, 'success');
return true; return true;
} else { } else {
throw new Error(data.error || `Failed to delete ${modelType}`); throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
} }
} catch (error) { } catch (error) {
console.error(`Error deleting ${modelType}:`, error); console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error);
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); showToast(`Failed to delete ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
return false; return false;
} finally { } finally {
state.loadingManager.hide(); state.loadingManager.hide();
} }
} }
// 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;
/**
* Exclude a model
*/
async excludeModel(filePath) {
try { try {
state.loadingManager.showSimpleLoading(`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${modelType}s...`); state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`);
// Add fullRebuild parameter to the request const response = await fetch(this.apiConfig.endpoints.exclude, {
const url = new URL(scanEndpoint, window.location.origin); 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); url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
}
if (typeof resetAndReloadFunction === 'function') {
await resetAndReloadFunction(true); // update folders
} }
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
} catch (error) { } catch (error) {
console.error(`Refresh failed:`, error); console.error('Refresh failed:', error);
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error'); showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error');
} finally { } finally {
state.loadingManager.hide(); state.loadingManager.hide();
state.loadingManager.restoreProgressBar(); state.loadingManager.restoreProgressBar();
} }
} }
// Generic fetch from Civitai /**
export async function fetchCivitaiMetadata(options = {}) { * Fetch CivitAI metadata for single model
const { */
modelType = 'lora', async refreshSingleModelMetadata(filePath) {
fetchEndpoint = '/api/fetch-all-civitai', try {
resetAndReloadFunction state.loadingManager.showSimpleLoading('Refreshing metadata...');
} = options;
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; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
try { try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => { const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -383,7 +383,7 @@ export async function fetchCivitaiMetadata(options = {}) {
case 'completed': case 'completed':
loading.setProgress(100); loading.setProgress(100);
loading.setStatus( loading.setStatus(
`Completed: Updated ${data.success} of ${data.processed} ${modelType}s` `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
); );
resolve(); resolve();
break; break;
@@ -404,14 +404,10 @@ export async function fetchCivitaiMetadata(options = {}) {
ws.onerror = reject; ws.onerror = reject;
}); });
const requestBody = modelType === 'checkpoint' const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
? JSON.stringify({ model_type: 'checkpoint' })
: JSON.stringify({});
const response = await fetch(fetchEndpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: requestBody body: JSON.stringify({})
}); });
if (!response.ok) { if (!response.ok) {
@@ -436,198 +432,143 @@ export async function fetchCivitaiMetadata(options = {}) {
initialMessage: 'Connecting...', initialMessage: 'Connecting...',
completionMessage: 'Metadata update complete' completionMessage: 'Metadata update complete'
}); });
} }
// Generic function to refresh single model metadata /**
export async function refreshSingleModelMetadata(filePath, modelType = 'lora') { * Build query parameters for API requests
try { */
state.loadingManager.showSimpleLoading('Refreshing metadata...'); _buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
const endpoint = modelType === 'checkpoint' // Add common parameters
? '/api/checkpoints/fetch-civitai' if (pageState.activeFolder !== null) {
: '/api/loras/fetch-civitai'; params.append('folder', pageState.activeFolder);
}
const response = await fetch(endpoint, { if (pageState.showFavoritesOnly) {
method: 'POST', params.append('favorites_only', 'true');
headers: { }
'Content-Type': 'application/json'
}, // Add letter filter for supported model types
body: JSON.stringify({ file_path: filePath }) if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);
params.append('fuzzy', 'true');
if (pageState.searchOptions) {
params.append('search_filename', pageState.searchOptions.filename.toString());
params.append('search_modelname', pageState.searchOptions.modelname.toString());
if (pageState.searchOptions.tags !== undefined) {
params.append('search_tags', pageState.searchOptions.tags.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
// Add filter parameters
if (pageState.filters) {
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
pageState.filters.tags.forEach(tag => {
params.append('tag', tag);
}); });
if (!response.ok) {
throw new Error('Failed to refresh metadata');
} }
const data = await response.json(); if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
pageState.filters.baseModel.forEach(model => {
if (data.success) { params.append('base_model', model);
// 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'); // Add model-specific parameters
return true; this._addModelSpecificParams(params, pageState);
} else {
throw new Error(data.error || 'Failed to refresh metadata'); 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');
if (filterLoraHash) {
params.append('lora_hash', filterLoraHash);
} else if (filterLoraHashes) {
try {
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
params.append('lora_hashes', filterLoraHashes.join(','));
} }
} catch (error) { } catch (error) {
console.error('Error refreshing metadata:', error); console.error('Error parsing lora hashes from session storage:', error);
showToast(error.message, 'error'); }
return false; }
} finally { }
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
} }
} }
// Generic function to exclude a model // Export factory functions and utilities
export async function excludeModel(filePath, modelType = 'lora') { export function createModelApiClient(modelType = null) {
try { return new ModelApiClient(modelType);
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 let _singletonClient = null;
export async function uploadPreview(filePath, file, modelType = 'lora', nsfwLevel = 0) {
try {
state.loadingManager.showSimpleLoading('Uploading preview...');
const formData = new FormData(); export function getModelApiClient() {
if (!_singletonClient) {
// Prepare common form data _singletonClient = new ModelApiClient();
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();
} }
_singletonClient.setModelType(state.currentPageType);
return _singletonClient;
} }
// Private methods // Legacy compatibility exports
export async function fetchModelsPage(options = {}) {
// Private function to perform the delete operation const { modelType = getCurrentModelType(), ...rest } = options;
async function performDelete(filePath, modelType = 'lora') { const client = createModelApiClient(modelType);
try { return client.fetchModelsPage(rest.page, rest.pageSize);
showToast(`Deleting ${modelType}...`, 'info'); }
const response = await fetch('/api/model/delete', { export async function deleteModel(filePath, modelType = null) {
method: 'POST', const client = createModelApiClient(modelType);
headers: { return client.deleteModel(filePath);
'Content-Type': 'application/json' }
},
body: JSON.stringify({ export async function excludeModel(filePath, modelType = null) {
file_path: filePath, const client = createModelApiClient(modelType);
model_type: modelType return client.excludeModel(filePath);
}) }
});
export async function renameModelFile(filePath, newFileName, modelType = null) {
if (!response.ok) { const client = createModelApiClient(modelType);
throw new Error(`Failed to delete ${modelType}: ${response.status} ${response.statusText}`); return client.renameModelFile(filePath, newFileName);
} }
const data = await response.json(); export async function replaceModelPreview(filePath, modelType = null) {
const client = createModelApiClient(modelType);
if (data.success) { return client.replaceModelPreview(filePath);
// Remove the card from UI }
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) { export async function refreshModels(options = {}) {
card.remove(); const { modelType = getCurrentModelType(), fullRebuild = false } = options;
} const client = createModelApiClient(modelType);
return client.refreshModels(fullRebuild);
showToast(`${modelType} deleted successfully`, 'success'); }
} else {
throw new Error(data.error || `Failed to delete ${modelType}`); export async function refreshSingleModelMetadata(filePath, modelType = null) {
} const client = createModelApiClient(modelType);
} catch (error) { return client.refreshSingleModelMetadata(filePath);
console.error(`Error deleting ${modelType}:`, error); }
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
} export async function fetchCivitaiMetadata(options = {}) {
const { modelType = getCurrentModelType(), resetAndReloadFunction } = options;
const client = createModelApiClient(modelType);
return client.fetchCivitaiMetadata(resetAndReloadFunction);
} }

View File

@@ -1,165 +1,43 @@
import { import { createModelApiClient } from './baseModelApi.js';
fetchModelsPage, import { MODEL_TYPES } from './apiConfig.js';
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
deleteModel as baseDeleteModel,
replaceModelPreview,
fetchCivitaiMetadata,
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state } from '../state/index.js';
/** // Create Checkpoint-specific API client
* Fetch checkpoints with pagination for virtual scrolling const checkpointApiClient = createModelApiClient(MODEL_TYPES.CHECKPOINT);
* @param {number} page - Page number to fetch
* @param {number} pageSize - Number of items per page
* @returns {Promise<Object>} 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'
});
}
/** // Export all common operations using the unified client
* Load more checkpoints with pagination - updated to work with VirtualScroller export const deleteModel = (filePath) => checkpointApiClient.deleteModel(filePath);
* @param {boolean} resetPage - Whether to reset to the first page export const excludeCheckpoint = (filePath) => checkpointApiClient.excludeModel(filePath);
* @param {boolean} updateFolders - Whether to update folder tags export const renameCheckpointFile = (filePath, newFileName) => checkpointApiClient.renameModelFile(filePath, newFileName);
* @returns {Promise<void>} 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) { export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) {
return loadMoreWithVirtualScroll({ return checkpointApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders);
modelType: 'checkpoint',
resetPage,
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
} }
// Reset and reload checkpoints
export async function resetAndReload(updateFolders = false) { export async function resetAndReload(updateFolders = false) {
return resetAndReloadWithVirtualScroll({ return checkpointApiClient.resetAndReloadWithVirtualScroll(updateFolders);
modelType: 'checkpoint',
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
} }
// Refresh checkpoints // Checkpoint-specific functions
export async function refreshCheckpoints(fullRebuild = false) { export async function getCheckpointInfo(name) {
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) {
try { try {
// Show loading indicator const response = await fetch(`${checkpointApiClient.apiConfig.endpoints.specific.info}/${encodeURIComponent(name)}`);
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) { if (!response.ok) {
throw new Error('Failed to save metadata'); throw new Error(`Failed to fetch checkpoint info: ${response.statusText}`);
}
// 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<boolean>} 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<Object>} - 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
})
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
} }
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error renaming checkpoint file:', error); console.error('Error fetching checkpoint info:', error);
throw error; throw error;
} finally {
state.loadingManager.hide();
} }
} }

View File

@@ -1,131 +1,35 @@
import { import { createModelApiClient } from './baseModelApi.js';
fetchModelsPage, import { MODEL_TYPES } from './apiConfig.js';
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
deleteModel as baseDeleteModel,
replaceModelPreview,
fetchCivitaiMetadata,
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state } from '../state/index.js';
/** // Create LoRA-specific API client
* Save model metadata to the server const loraApiClient = createModelApiClient(MODEL_TYPES.LORA);
* @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', { // Export all common operations using the unified client
method: 'POST', export const deleteModel = (filePath) => loraApiClient.deleteModel(filePath);
headers: { export const excludeLora = (filePath) => loraApiClient.excludeModel(filePath);
'Content-Type': 'application/json', export const renameLoraFile = (filePath, newFileName) => loraApiClient.renameModelFile(filePath, newFileName);
}, export const replacePreview = (filePath) => loraApiClient.replaceModelPreview(filePath);
body: JSON.stringify({ export const saveModelMetadata = (filePath, data) => loraApiClient.saveModelMetadata(filePath, data);
file_path: filePath, export const refreshLoras = (fullRebuild = false) => loraApiClient.refreshModels(fullRebuild);
...data export const refreshSingleLoraMetadata = (filePath) => loraApiClient.refreshSingleModelMetadata(filePath);
}) export const fetchCivitai = (resetAndReloadFunction) => loraApiClient.fetchCivitaiMetadata(resetAndReloadFunction);
});
if (!response.ok) { // Pagination functions
throw new Error('Failed to save metadata'); export const fetchLorasPage = (page = 1, pageSize = 100) => loraApiClient.fetchModelsPage(page, pageSize);
}
// Update the virtual scroller with the new data // Virtual scrolling operations
state.virtualScroller.updateSingleItem(filePath, data);
return response.json();
} finally {
// Always hide the loading indicator when done
state.loadingManager.hide();
}
}
/**
* Exclude a lora model from being shown in the UI
* @param {string} filePath - File path of the model to exclude
* @returns {Promise<boolean>} 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<void>}
*/
export async function loadMoreLoras(resetPage = false, updateFolders = false) { export async function loadMoreLoras(resetPage = false, updateFolders = false) {
return loadMoreWithVirtualScroll({ return loraApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders);
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>} 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');
} }
export async function resetAndReload(updateFolders = false) { export async function resetAndReload(updateFolders = false) {
return resetAndReloadWithVirtualScroll({ return loraApiClient.resetAndReloadWithVirtualScroll(updateFolders);
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');
} }
// LoRA-specific functions that don't have common equivalents
export async function fetchModelDescription(modelId, filePath) { export async function fetchModelDescription(modelId, filePath) {
try { 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) { if (!response.ok) {
throw new Error(`Failed to fetch model description: ${response.statusText}`); throw new Error(`Failed to fetch model description: ${response.statusText}`);
@@ -138,38 +42,47 @@ export async function fetchModelDescription(modelId, filePath) {
} }
} }
/** // Move operations (LoRA-specific)
* Rename a LoRA file export async function moveModel(filePath, targetPath) {
* @param {string} filePath - Current file path
* @param {string} newFileName - New file name (without path)
* @returns {Promise<Object>} - Promise that resolves with the server response
*/
export async function renameLoraFile(filePath, newFileName) {
try { try {
// Show loading indicator const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveModel, {
state.loadingManager.showSimpleLoading('Renaming LoRA file...');
const response = await fetch('/api/loras/rename', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
file_path: filePath, file_path: filePath,
new_file_name: newFileName target_path: targetPath
}) })
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`); throw new Error('Failed to move model');
} }
return await response.json(); return await response.json();
} catch (error) { } 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; throw error;
} finally {
// Hide loading indicator
state.loadingManager.hide();
} }
} }

View File

@@ -1,8 +1,4 @@
import { RecipeCard } from '../components/RecipeCard.js'; import { RecipeCard } from '../components/RecipeCard.js';
import {
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll
} from './baseModelApi.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.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<Object>} 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<Object>} 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 * Reset and reload recipes using virtual scrolling
* @param {boolean} updateFolders - Whether to update folder tags * @param {boolean} updateFolders - Whether to update folder tags

View File

@@ -5,18 +5,19 @@ import { loadMoreCheckpoints } from './api/checkpointApi.js';
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
// Initialize the Checkpoints page // Initialize the Checkpoints page
class CheckpointsPageManager { class CheckpointsPageManager {
constructor() { constructor() {
// Initialize page controls // Initialize page controls
this.pageControls = createPageControls('checkpoints'); this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT);
// Initialize checkpoint download manager // Initialize checkpoint download manager
window.checkpointDownloadManager = new CheckpointDownloadManager(); window.checkpointDownloadManager = new CheckpointDownloadManager();
// Initialize the ModelDuplicatesManager // Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints'); this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.CHECKPOINT);
// Expose only necessary functions to global scope // Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions(); this._exposeRequiredGlobalFunctions();

View File

@@ -1,6 +1,7 @@
import { BaseContextMenu } from './BaseContextMenu.js'; import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.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 { showToast } from '../../utils/uiHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js'; import { showExcludeModal } from '../../utils/modalUtils.js';
@@ -19,7 +20,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Implementation needed by the mixin // Implementation needed by the mixin
async saveModelMetadata(filePath, data) { async saveModelMetadata(filePath, data) {
return saveModelMetadata(filePath, data); return getModelApiClient().saveModelMetadata(filePath, data);
} }
handleMenuAction(action) { handleMenuAction(action) {
@@ -28,6 +29,8 @@ export class CheckpointContextMenu extends BaseContextMenu {
return; return;
} }
const apiClient = getModelApiClient();
// Otherwise handle checkpoint-specific actions // Otherwise handle checkpoint-specific actions
switch(action) { switch(action) {
case 'details': case 'details':
@@ -36,7 +39,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
break; break;
case 'replace-preview': case 'replace-preview':
// Add new action for replacing preview images // Add new action for replacing preview images
replaceCheckpointPreview(this.currentCard.dataset.filepath); apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
break; break;
case 'delete': case 'delete':
// Delete checkpoint // Delete checkpoint
@@ -52,14 +55,14 @@ export class CheckpointContextMenu extends BaseContextMenu {
break; break;
case 'refresh-metadata': case 'refresh-metadata':
// Refresh metadata from CivitAI // Refresh metadata from CivitAI
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break; break;
case 'move': case 'move':
// Move to folder (placeholder) // Move to folder (placeholder)
showToast('Move to folder feature coming soon', 'info'); showToast('Move to folder feature coming soon', 'info');
break; break;
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint'); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
} }
} }

View File

@@ -4,8 +4,7 @@ import { showModelModal } from './ModelModal.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; import { getModelApiClient } from '../../api/baseModelApi.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
import { showDeleteModal } from '../../utils/modalUtils.js'; import { showDeleteModal } from '../../utils/modalUtils.js';
// Add global event delegation handlers // Add global event delegation handlers
@@ -32,6 +31,8 @@ function handleModelCardEvent_internal(event, modelType) {
const card = event.target.closest('.lora-card'); const card = event.target.closest('.lora-card');
if (!card) return; if (!card) return;
const apiClient = getModelApiClient();
// Handle specific elements within the card // Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) { if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation(); event.stopPropagation();
@@ -73,13 +74,13 @@ function handleModelCardEvent_internal(event, modelType) {
if (event.target.closest('.fa-trash')) { if (event.target.closest('.fa-trash')) {
event.stopPropagation(); event.stopPropagation();
showDeleteModal(card.dataset.filepath, modelType); showDeleteModal(card.dataset.filepath);
return; return;
} }
if (event.target.closest('.fa-image')) { if (event.target.closest('.fa-image')) {
event.stopPropagation(); event.stopPropagation();
handleReplacePreview(card.dataset.filepath, modelType); apiClient.replaceModelPreview(card.dataset.filepath);
return; return;
} }
@@ -136,9 +137,7 @@ async function toggleFavorite(card, modelType) {
const newFavoriteState = !isFavorite; const newFavoriteState = !isFavorite;
try { try {
// Use the appropriate save function based on model type await apiClient.saveModelMetadata(card.dataset.filepath, {
const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata;
await saveFunction(card.dataset.filepath, {
favorite: newFavoriteState favorite: newFavoriteState
}); });
@@ -179,15 +178,7 @@ function handleCopyAction(card, modelType) {
} }
function handleReplacePreview(filePath, modelType) { function handleReplacePreview(filePath, modelType) {
if (modelType === 'lora') { apiClient.replaceModelPreview(filePath);
replacePreview(filePath);
} else {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}
} }
async function handleExampleImagesAccess(card, modelType) { async function handleExampleImagesAccess(card, modelType) {

View File

@@ -7,6 +7,7 @@ import { BASE_MODELS } from '../../utils/constants.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js'; import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js';
import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
import { getModelApiClient } from '../../api/baseModelApi.js';
/** /**
* Set up model name editing functionality * Set up model name editing functionality
@@ -114,9 +115,7 @@ export function setupModelNameEditing(filePath) {
// Get the file path from the dataset // Get the file path from the dataset
const filePath = this.dataset.filePath; const filePath = this.dataset.filePath;
const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
await saveFunction(filePath, { model_name: newModelName });
showToast('Model name updated successfully', 'success'); showToast('Model name updated successfully', 'success');
} catch (error) { } catch (error) {
@@ -295,9 +294,7 @@ async function saveBaseModel(filePath, originalValue) {
} }
try { try {
const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel });
await saveFunction(filePath, { base_model: newBaseModel });
showToast('Base model updated successfully', 'success'); showToast('Base model updated successfully', 'success');
} catch (error) { } catch (error) {
@@ -417,29 +414,7 @@ export function setupFileNameEditing(filePath) {
// Get the file path from the dataset // Get the file path from the dataset
const filePath = this.dataset.filePath; const filePath = this.dataset.filePath;
let result; await getModelApiClient().renameModelFile(filePath, newFileName);
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');
}
} catch (error) { } catch (error) {
console.error('Error renaming file:', error); console.error('Error renaming file:', error);
this.textContent = originalValue; // Restore original file name this.textContent = originalValue; // Restore original file name

View File

@@ -5,7 +5,7 @@
*/ */
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js'; import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.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 * 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 // Get local file path if available
const useLocalFile = mediaElement.dataset.localSrc && !mediaElement.dataset.localSrc.includes('undefined'); const useLocalFile = mediaElement.dataset.localSrc && !mediaElement.dataset.localSrc.includes('undefined');
const apiClient = getModelApiClient();
if (useLocalFile) { if (useLocalFile) {
// We have a local file, use it directly // 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 }); const file = new File([blob], 'preview.jpg', { type: blob.type });
// Use the existing baseModelApi uploadPreview method with nsfw level // Use the existing baseModelApi uploadPreview method with nsfw level
await uploadPreview(modelFilePath, file, modelType, nsfwLevel); await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel);
} else { } else {
// We need to download the remote file first // We need to download the remote file first
const response = await fetch(mediaElement.src); const response = await fetch(mediaElement.src);
@@ -531,7 +532,7 @@ function initSetPreviewHandlers(container) {
const file = new File([blob], 'preview.jpg', { type: blob.type }); const file = new File([blob], 'preview.jpg', { type: blob.type });
// Use the existing baseModelApi uploadPreview method with nsfw level // Use the existing baseModelApi uploadPreview method with nsfw level
await uploadPreview(modelFilePath, file, modelType, nsfwLevel); await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel);
} }
} catch (error) { } catch (error) {
console.error('Error setting preview:', error); console.error('Error setting preview:', error);

View File

@@ -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 { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { getStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem } from '../utils/storageHelpers.js';
import { updateFolderTags } from '../api/baseModelApi.js';
class MoveManager { class MoveManager {
constructor() { constructor() {
@@ -219,7 +218,7 @@ class MoveManager {
return; return;
} }
const response = await fetch('/api/loras/move_model', { const response = await fetch('/api/move_model', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -257,7 +256,7 @@ class MoveManager {
return; return;
} }
const response = await fetch('/api/loras/move_models_bulk', { const response = await fetch('/api/move_models_bulk', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,16 +1,18 @@
// Create the new hierarchical state structure // Create the new hierarchical state structure
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
// Load settings from localStorage or use defaults // Load settings from localStorage or use defaults
const savedSettings = getStorageItem('settings', { const savedSettings = getStorageItem('settings', {
blurMatureContent: true, blurMatureContent: true,
show_only_sfw: false, 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 loraPreviewVersions = getMapFromStorage('lora_preview_versions');
const checkpointPreviewVersions = getMapFromStorage('checkpoint_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoint_preview_versions');
const embeddingPreviewVersions = getMapFromStorage('embedding_preview_versions');
export const state = { export const state = {
// Global state // Global state
@@ -22,13 +24,13 @@ export const state = {
// Page-specific states // Page-specific states
pages: { pages: {
loras: { [MODEL_TYPES.LORA]: {
currentPage: 1, currentPage: 1,
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
sortBy: 'name', sortBy: 'name',
activeFolder: null, activeFolder: null,
activeLetterFilter: null, // New property for letter filtering activeLetterFilter: null,
previewVersions: loraPreviewVersions, previewVersions: loraPreviewVersions,
searchManager: null, searchManager: null,
searchOptions: { searchOptions: {
@@ -67,10 +69,10 @@ export const state = {
}, },
pageSize: 20, pageSize: 20,
showFavoritesOnly: false, showFavoritesOnly: false,
duplicatesMode: false, // Add flag for duplicates mode duplicatesMode: false,
}, },
checkpoints: { [MODEL_TYPES.CHECKPOINT]: {
currentPage: 1, currentPage: 1,
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
@@ -89,11 +91,34 @@ export const state = {
}, },
showFavoritesOnly: false, showFavoritesOnly: false,
duplicatesMode: 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 // Current active page - use MODEL_TYPES constants
currentPageType: 'loras', currentPageType: MODEL_TYPES.LORA,
// Backward compatibility - proxy properties // Backward compatibility - proxy properties
get currentPage() { return this.pages[this.currentPageType].currentPage; }, get currentPage() { return this.pages[this.currentPageType].currentPage; },

View File

@@ -1,15 +1,13 @@
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { excludeLora, deleteModel as deleteLora } from '../api/loraApi.js'; import { getModelApiClient } from '../api/baseModelApi.js';
import { excludeCheckpoint, deleteCheckpoint } from '../api/checkpointApi.js';
const apiClient = getModelApiClient();
let pendingDeletePath = null; let pendingDeletePath = null;
let pendingModelType = null;
let pendingExcludePath = null; let pendingExcludePath = null;
let pendingExcludeModelType = null;
export function showDeleteModal(filePath, modelType = 'lora') { export function showDeleteModal(filePath) {
pendingDeletePath = filePath; pendingDeletePath = filePath;
pendingModelType = modelType;
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop(); const modelName = card ? card.dataset.name : filePath.split('/').pop();
@@ -29,12 +27,7 @@ export async function confirmDelete() {
if (!pendingDeletePath) return; if (!pendingDeletePath) return;
try { try {
// Use appropriate delete function based on model type await apiClient.deleteModel(pendingDeletePath);
if (pendingModelType === 'checkpoint') {
await deleteCheckpoint(pendingDeletePath);
} else {
await deleteLora(pendingDeletePath);
}
closeDeleteModal(); closeDeleteModal();
@@ -54,9 +47,8 @@ export function closeDeleteModal() {
} }
// Functions for the exclude modal // Functions for the exclude modal
export function showExcludeModal(filePath, modelType = 'lora') { export function showExcludeModal(filePath) {
pendingExcludePath = filePath; pendingExcludePath = filePath;
pendingExcludeModelType = modelType;
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop(); const modelName = card ? card.dataset.name : filePath.split('/').pop();
@@ -82,12 +74,7 @@ export async function confirmExclude() {
if (!pendingExcludePath) return; if (!pendingExcludePath) return;
try { try {
// Use appropriate exclude function based on model type await apiClient.excludeModel(pendingExcludePath);
if (pendingExcludeModelType === 'checkpoint') {
await excludeCheckpoint(pendingExcludePath);
} else {
await excludeLora(pendingExcludePath);
}
closeExcludeModal(); closeExcludeModal();

View File

@@ -616,3 +616,30 @@ export async function openExampleImagesFolder(modelHash) {
return false; 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 `<div class="tag ${isActive ? 'active' : ''}" data-folder="${folder}">${folder}</div>`;
}).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' });
}
}