Files
ComfyUI-Lora-Manager/static/js/api/baseModelApi.js
Will Miao c09100c22e feat: implement tag filtering with include/exclude states
- Update frontend tag filter to cycle through include/exclude/clear states
- Add backend support for tag_include and tag_exclude query parameters
- Maintain backward compatibility with legacy tag parameter
- Store tag states as dictionary with 'include'/'exclude' values
- Update test matrix documentation to reflect new tag behavior

The changes enable more granular tag filtering where users can now explicitly include or exclude specific tags, rather than just adding tags to a simple inclusion list. This provides better control over search results and improves the filtering user experience.
2025-11-08 11:45:31 +08:00

1297 lines
52 KiB
JavaScript

import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import {
getCompleteApiConfig,
getCurrentModelType,
isValidModelType,
DOWNLOAD_ENDPOINTS,
WS_ENDPOINTS
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
import { sidebarManager } from '../components/SidebarManager.js';
/**
* Abstract base class for all model API clients
*/
export class BaseModelApiClient {
constructor(modelType = null) {
if (this.constructor === BaseModelApiClient) {
throw new Error("BaseModelApiClient is abstract and cannot be instantiated directly");
}
this.modelType = modelType || getCurrentModelType();
this.apiConfig = getCompleteApiConfig(this.modelType);
}
/**
* 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;
}
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('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
throw error;
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState();
try {
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`);
pageState.isLoading = true;
if (resetPage) {
pageState.currentPage = 1; // Reset to first page
}
const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize);
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
pageState.hasMore = result.hasMore;
pageState.currentPage = pageState.currentPage + 1;
if (updateFolders) {
sidebarManager.refresh();
}
return result;
} catch (error) {
console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error);
showToast('toast.api.reloadFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
throw error;
} finally {
pageState.isLoading = false;
state.loadingManager.hide();
}
}
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('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, '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('toast.api.deleteFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
return false;
} finally {
state.loadingManager.hide();
}
}
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('toast.api.excludeSuccess', { type: this.apiConfig.config.displayName }, '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('toast.api.excludeFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
return false;
} finally {
state.loadingManager.hide();
}
}
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('toast.api.fileNameUpdated', {}, 'success');
} else {
showToast('toast.api.fileRenameFailed', { error: 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();
}
}
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();
}
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();
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('toast.api.previewUpdated', {}, 'success');
} catch (error) {
console.error('Error uploading preview:', error);
showToast('toast.api.previewUploadFailed', {}, 'error');
} finally {
state.loadingManager.hide();
}
}
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();
}
}
async addTags(filePath, data) {
try {
const response = await fetch(this.apiConfig.endpoints.addTags, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to add tags');
}
const result = await response.json();
if (result.success && result.tags) {
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
}
return result;
} catch (error) {
console.error('Error adding tags:', error);
throw error;
}
}
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}`);
}
resetAndReload(true);
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) {
console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
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('toast.api.metadataRefreshed', {}, 'success');
return true;
} else {
throw new Error(data.error || 'Failed to refresh metadata');
}
} catch (error) {
console.error('Error refreshing metadata:', error);
showToast('toast.api.metadataRefreshFailed', { message: error.message }, 'error');
return false;
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
async fetchCivitaiMetadata() {
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));
};
});
// Wait for WebSocket connection to establish
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');
}
// Wait for the operation to complete via WebSocket
await operationComplete;
resetAndReload(false);
showToast('toast.api.metadataUpdateComplete', {}, 'success');
} catch (error) {
console.error('Error fetching metadata:', error);
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
} finally {
if (ws) {
ws.close();
}
}
}, {
initialMessage: 'Connecting...',
completionMessage: 'Metadata update complete'
});
}
async refreshBulkModelMetadata(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const totalItems = filePaths.length;
let processedCount = 0;
let successCount = 0;
let failedItems = [];
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
try {
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const fileName = filePath.split('/').pop();
try {
const overallProgress = Math.floor((i / totalItems) * 100);
progressController.updateProgress(
overallProgress,
fileName,
`Processing ${i + 1}/${totalItems}: ${fileName}`
);
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(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
if (data.metadata && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, data.metadata);
}
successCount++;
} else {
throw new Error(data.error || 'Failed to refresh metadata');
}
} catch (error) {
console.error(`Error refreshing metadata for ${fileName}:`, error);
failedItems.push({ filePath, fileName, error: error.message });
}
processedCount++;
}
let completionMessage;
if (successCount === totalItems) {
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
} else if (successCount > 0) {
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
// if (failedItems.length > 0) {
// const failureMessage = failedItems.length <= 3
// ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
// : failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
// `\n(and ${failedItems.length - 3} more)`;
// showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
// }
} else {
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
}
await progressController.complete(completionMessage);
return {
success: successCount > 0,
total: totalItems,
processed: processedCount,
successful: successCount,
failed: failedItems.length,
errors: failedItems
};
} catch (error) {
console.error('Error in bulk metadata refresh:', error);
showToast('toast.api.bulkMetadataFailed', { message: error.message }, 'error');
await progressController.complete('Operation failed');
throw error;
}
}
async refreshUpdatesForModels(modelIds, { force = false } = {}) {
if (!Array.isArray(modelIds) || modelIds.length === 0) {
throw new Error('No model IDs provided');
}
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_ids: modelIds,
force
})
});
let payload = {};
try {
payload = await response.json();
} catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error);
}
if (!response.ok || payload?.success !== true) {
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
}
async fetchCivitaiVersions(modelId, source = null) {
try {
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
if (source) {
const params = new URLSearchParams({ source });
requestUrl = `${requestUrl}?${params.toString()}`;
}
const response = await fetch(requestUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) {
throw new Error(`This model is not a ${this.apiConfig.config.displayName}. Please switch to the appropriate page to download this model type.`);
}
throw new Error('Failed to fetch model versions');
}
return await response.json();
} catch (error) {
console.error('Error fetching Civitai versions:', error);
throw error;
}
}
async fetchModelUpdateVersions(modelId, { refresh = false, force = false } = {}) {
try {
const params = new URLSearchParams();
if (refresh) params.append('refresh', 'true');
if (force) params.append('force', 'true');
const query = params.toString();
const requestUrl = `${this.apiConfig.endpoints.modelUpdateVersions}/${modelId}${query ? `?${query}` : ''}`;
const response = await fetch(requestUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to fetch model versions');
}
return await response.json();
} catch (error) {
console.error('Error fetching model update versions:', error);
throw error;
}
}
async setModelUpdateIgnore(modelId, shouldIgnore) {
try {
const response = await fetch(this.apiConfig.endpoints.ignoreModelUpdate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId,
shouldIgnore,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to update model ignore status');
}
return await response.json();
} catch (error) {
console.error('Error updating model ignore status:', error);
throw error;
}
}
async setVersionUpdateIgnore(modelId, versionId, shouldIgnore) {
try {
const response = await fetch(this.apiConfig.endpoints.ignoreVersionUpdate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId,
versionId,
shouldIgnore,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to update version ignore status');
}
return await response.json();
} catch (error) {
console.error('Error updating version ignore status:', error);
throw error;
}
}
async fetchModelRoots() {
try {
const response = await fetch(this.apiConfig.endpoints.roots);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} roots`);
}
return await response.json();
} catch (error) {
console.error('Error fetching model roots:', error);
throw error;
}
}
async fetchModelFolders() {
try {
const response = await fetch(this.apiConfig.endpoints.folders);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`);
}
return await response.json();
} catch (error) {
console.error('Error fetching model folders:', error);
throw error;
}
}
async fetchUnifiedFolderTree() {
try {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error(`Failed to fetch unified folder tree`);
}
return await response.json();
} catch (error) {
console.error('Error fetching unified folder tree:', error);
throw error;
}
}
async fetchFolderTree(modelRoot) {
try {
const params = new URLSearchParams({ model_root: modelRoot });
const response = await fetch(`${this.apiConfig.endpoints.folderTree}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch folder tree for root: ${modelRoot}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching folder tree:', error);
throw error;
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null) {
try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: modelId,
model_version_id: versionId,
model_root: modelRoot,
relative_path: relativePath,
use_default_paths: useDefaultPaths,
download_id: downloadId,
...(source ? { source } : {})
})
});
if (!response.ok) {
throw new Error(await response.text());
}
return await response.json();
} catch (error) {
console.error('Error downloading model:', error);
throw error;
}
}
_buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
if (pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
}
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
if (pageState.showUpdateAvailableOnly) {
params.append('update_available_only', 'true');
}
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
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());
}
if (pageState.searchOptions.creator !== undefined) {
params.append('search_creator', pageState.searchOptions.creator.toString());
}
}
}
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
if (pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
pageState.filters.baseModel.forEach(model => {
params.append('base_model', model);
});
}
// Add license filters
if (pageState.filters.license) {
const licenseFilters = pageState.filters.license;
if (licenseFilters.noCredit) {
// For noCredit filter:
// - 'include' means credit_required=False (no credit required)
// - 'exclude' means credit_required=True (credit required)
if (licenseFilters.noCredit === 'include') {
params.append('credit_required', 'false');
} else if (licenseFilters.noCredit === 'exclude') {
params.append('credit_required', 'true');
}
}
if (licenseFilters.allowSelling) {
// For allowSelling filter:
// - 'include' means allow_selling_generated_content=True
// - 'exclude' means allow_selling_generated_content=False
if (licenseFilters.allowSelling === 'include') {
params.append('allow_selling_generated_content', 'true');
} else if (licenseFilters.allowSelling === 'exclude') {
params.append('allow_selling_generated_content', 'false');
}
}
}
}
this._addModelSpecificParams(params, pageState);
return params;
}
_addModelSpecificParams(params, pageState) {
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) {
console.error('Error parsing lora hashes from session storage:', error);
}
}
}
}
async moveSingleModel(filePath, targetPath) {
// Only allow move if supported
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null;
}
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
return null;
}
const response = await fetch(this.apiConfig.endpoints.moveModel, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
target_path: targetPath
})
});
const result = await response.json();
if (!response.ok) {
if (result && result.error) {
throw new Error(result.error);
}
throw new Error(`Failed to move ${this.apiConfig.config.displayName}`);
}
if (result && result.message) {
showToast('toast.api.moveInfo', { message: result.message }, 'info');
} else {
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
if (result.success) {
return {
original_file_path: result.original_file_path || filePath,
new_file_path: result.new_file_path
};
}
return null;
}
async moveBulkModels(filePaths, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return [];
}
const movedPaths = filePaths.filter(path => {
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
});
if (movedPaths.length === 0) {
showToast('toast.api.allAlreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
return [];
}
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_paths: movedPaths,
target_path: targetPath
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
}
if (result.success) {
if (result.failure_count > 0) {
showToast('toast.api.bulkMovePartial', {
successCount: result.success_count,
type: this.apiConfig.config.displayName,
failureCount: result.failure_count
}, 'warning');
console.log('Move operation results:', result.results);
const failedFiles = result.results
.filter(r => !r.success)
.map(r => {
const fileName = r.original_file_path.substring(r.original_file_path.lastIndexOf('/') + 1);
return `${fileName}: ${r.message}`;
});
if (failedFiles.length > 0) {
const failureMessage = failedFiles.length <= 3
? failedFiles.join('\n')
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
}
} else {
showToast('toast.api.bulkMoveSuccess', {
successCount: result.success_count,
type: this.apiConfig.config.displayName
}, 'success');
}
// Return the results array with original_file_path and new_file_path
return result.results || [];
} else {
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
}
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_paths: filePaths
})
});
if (!response.ok) {
throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
return {
success: true,
deleted_count: result.deleted_count,
failed_count: result.failed_count || 0,
errors: result.errors || []
};
} else {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
}
} catch (error) {
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
throw error;
} finally {
state.loadingManager.hide();
}
}
async downloadExampleImages(modelHashes, modelTypes = null) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
try {
// Connect to WebSocket for progress updates
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);
if (data.type !== 'example_images_progress') return;
switch(data.status) {
case 'running':
const percent = ((data.processed / data.total) * 100).toFixed(1);
loading.setProgress(percent);
loading.setStatus(
`Processing (${data.processed}/${data.total}) ${data.current_model || ''}`
);
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
`Completed: Downloaded example images for ${data.processed} models`
);
resolve();
break;
case 'error':
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
reject(new Error('WebSocket error: ' + error.message));
};
});
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
// Get the output directory from state
const outputDir = state.global?.settings?.example_images_path || '';
if (!outputDir) {
throw new Error('Please set the example images path in the settings first.');
}
// Determine optimize setting
const optimize = state.global?.settings?.optimize_example_images ?? true;
// Make the API request to start the download process
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hashes: modelHashes,
output_dir: outputDir,
optimize: optimize,
model_types: modelTypes || [this.apiConfig.config.singularName]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to download example images');
}
// Wait for the operation to complete via WebSocket
await operationComplete;
showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success');
return true;
} catch (error) {
console.error('Error downloading example images:', error);
showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error');
throw error;
} finally {
if (ws) {
ws.close();
}
}
}, {
initialMessage: 'Starting example images download...',
completionMessage: 'Example images download complete'
});
}
async fetchModelMetadata(filePath) {
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.metadata;
} else {
throw new Error(data.error || `No metadata found for ${this.apiConfig.config.singularName}`);
}
} catch (error) {
console.error(`Error fetching ${this.apiConfig.config.singularName} metadata:`, error);
throw error;
}
}
async fetchModelDescription(filePath) {
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.description;
} else {
throw new Error(data.error || `No description found for ${this.apiConfig.config.singularName}`);
}
} catch (error) {
console.error(`Error fetching ${this.apiConfig.config.singularName} description:`, error);
throw error;
}
}
/**
* Auto-organize models based on current path template settings
* @param {Array} filePaths - Optional array of file paths to organize. If not provided, organizes all models.
* @returns {Promise} - Promise that resolves when the operation is complete
*/
async autoOrganizeModels(filePaths = null) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
try {
// Connect to WebSocket for progress updates
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);
if (data.type !== 'auto_organize_progress') return;
switch(data.status) {
case 'started':
loading.setProgress(0);
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.starting', { type: operationType }, `Starting auto-organize for ${operationType}...`));
break;
case 'processing':
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
loading.setProgress(percent);
loading.setStatus(
translate('loras.bulkOperations.autoOrganizeProgress.processing', {
processed: data.processed,
total: data.total,
success: data.success,
failures: data.failures,
skipped: data.skipped
}, `Processing (${data.processed}/${data.total}) - ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
);
break;
case 'cleaning':
loading.setProgress(95);
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.cleaning', {}, 'Cleaning up empty directories...'));
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
translate('loras.bulkOperations.autoOrganizeProgress.completed', {
success: data.success,
skipped: data.skipped,
failures: data.failures,
total: data.total
}, `Completed: ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
);
setTimeout(() => {
resolve(data);
}, 1500);
break;
case 'error':
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error during auto-organize:', error);
reject(new Error('Connection error'));
};
});
// Start the auto-organize operation
const endpoint = this.apiConfig.endpoints.autoOrganize;
const requestOptions = {
method: filePaths ? 'POST' : 'GET',
headers: filePaths ? { 'Content-Type': 'application/json' } : {}
};
if (filePaths) {
requestOptions.body = JSON.stringify({ file_paths: filePaths });
}
const response = await fetch(endpoint, requestOptions);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to start auto-organize operation');
}
// Wait for the operation to complete via WebSocket
const result = await operationComplete;
// Show appropriate success message based on results
if (result.failures === 0) {
showToast('toast.loras.autoOrganizeSuccess', {
count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
}, 'success');
} else {
showToast('toast.loras.autoOrganizePartialSuccess', {
success: result.success,
failures: result.failures,
total: result.total
}, 'warning');
}
} catch (error) {
console.error('Error during auto-organize:', error);
showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
throw error;
} finally {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
}, {
initialMessage: translate('loras.bulkOperations.autoOrganizeProgress.initializing', {}, 'Initializing auto-organize...'),
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
});
}
}