Files
ComfyUI-Lora-Manager/static/js/api/baseModelApi.js
T
Will Miao 4938faa049 feat(sidebar): delete folders from the sidebar (#999)
Folders created from the sidebar had no in-app way back out: the only
removal path was to leave ComfyUI, delete the directory by hand and
rescan. A typo'd folder also polluted the move/download destination
picker permanently, since it reads the same all_folders source.

Adds POST /api/lm/{prefix}/delete-folder, restricted to directories
whose subtree holds no model weight files — a folder-level cascade would
bypass the per-model lifecycle bookkeeping (metadata sidecars, previews,
cache entries, pending-delete staging, recipe references). The service
walks the directory itself instead of trusting the possibly stale cache,
reports what it would remove (models / files / subfolders / symlinks),
and refuses library roots, top-level symlinks (shutil.rmtree rejects
those) and folders holding a staged delete, whose manifest would be
invalidated by the move. Symbolic links inside the subtree are counted
but never followed.

ModelScanner.remove_known_folder mirrors add_known_folder: the removed
subtree leaves all_folders while ancestors are kept (every recorded
ancestor exists on disk in its own right), stale cache entries under the
prefix are purged and the folder list recomputed. The handler broadcasts
models_changed so destination pickers drop the folder too.

The sidebar entry is a destructive context-menu item. The modal opens in
a confirm state for model-free folders and an explanatory one when the
subtree still holds models, decided from the models-only set that already
dims empty nodes; a stale tree is caught by the 409 not_empty/busy
conflict. Truly empty folders get the existing 20s undo affordance,
implemented by re-creating the directory.
2026-09-15 20:05:10 +08:00

2160 lines
87 KiB
JavaScript

import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import {
getCompleteApiConfig,
getCurrentModelType,
isValidModelType,
DOWNLOAD_ENDPOINTS,
HF_ENDPOINTS,
MODEL_SOURCE_ENDPOINTS,
WS_ENDPOINTS
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
import { sidebarManager } from '../components/SidebarManager.js';
// Shared scan ETA helpers live in a dependency-light module so pages that do
// not use BaseModelApiClient (e.g. recipes) can reuse them without pulling
// this module's import cycle (modelApiFactory -> loraApi -> baseModelApi).
import { createScanEtaTracker, formatScanRemainingTime } from '../utils/scanEtaUtils.js';
export { createScanEtaTracker, formatScanRemainingTime };
/**
* 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;
const isExcludedView = pageState.viewMode === 'excluded';
try {
const params = this._buildQueryParams({
page,
page_size: actualPageSize,
sort_by: pageState.sortBy
}, pageState);
// If params is null, it means wildcard resolved to no matches - return empty results
if (params === null) {
return {
items: [],
totalItems: 0,
totalPages: 0,
currentPage: page,
hasMore: false,
folders: []
};
}
const endpoint = isExcludedView
? this.apiConfig.endpoints.excluded
: this.apiConfig.endpoints.list;
const response = await fetch(`${endpoint}?${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 cancelTask() {
try {
const endpoint = this.apiConfig.endpoints.cancelTask;
const response = await fetch(endpoint, {
method: 'POST'
});
return await response.json();
} catch (error) {
console.error(`Error cancelling task for ${this.modelType}:`, error);
return { success: false, error: error.message };
}
}
async cancelDownload(downloadId) {
try {
const response = await fetch(
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
);
return await response.json();
} catch (error) {
console.error('Error cancelling download:', error);
return { success: false, error: error.message };
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState();
try {
// Use grid-scoped loading instead of full-page overlay
if (state.virtualScroller?.showGridLoading) {
state.virtualScroller.showGridLoading();
}
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;
// When resetting to page 1, scroll back to the top
// This covers: folder selection, filter/sort/search changes,
// favorites/update/excluded view toggles, alphabet filter, etc.
if (resetPage) {
const scrollContainer = document.querySelector('.page-content');
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
}
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;
// Wait for the next rAF so refreshWithData's scheduleRender has
// completed rendering new cards before hiding the grid loading overlay.
// This eliminates the ~6.7ms blank-frame gap that caused the flicker.
if (state.virtualScroller?.hideGridLoading) {
requestAnimationFrame(() => {
state.virtualScroller.hideGridLoading();
});
}
}
}
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);
}
const batchId = data.batch_id || null;
if (!batchId) {
// Not staged (staging failed): keep the legacy toast.
// When staged, the caller shows the undo action toast instead.
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
return { success: true, batch_id: batchId };
} 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 unexcludeModel(filePath) {
try {
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
const response = await fetch(this.apiConfig.endpoints.unexclude, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
});
if (!response.ok) {
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast(
'toast.api.restoreSuccess',
{ type: this.apiConfig.config.displayName },
'success',
`Restored ${this.apiConfig.config.displayName}`
);
return true;
}
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
} catch (error) {
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
showToast(
'toast.api.restoreFailed',
{ type: this.apiConfig.config.singularName, message: error.message },
'error',
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
);
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/*,image/webp,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();
}
}
/**
* Set a preview from a remote URL (e.g., CivitAI)
* @param {string} filePath - Path to the model file
* @param {string} imageUrl - Remote image URL
* @param {number} nsfwLevel - NSFW level for the preview
*/
async setPreviewFromUrl(filePath, imageUrl, nsfwLevel = 0) {
try {
state.loadingManager.showSimpleLoading('Setting preview from URL...');
const response = await fetch(this.apiConfig.endpoints.setPreviewFromUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_path: filePath,
image_url: imageUrl,
nsfw_level: nsfwLevel
})
});
if (!response.ok) {
throw new Error('Failed to set preview from URL');
}
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 setting preview from URL:', 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');
}
const result = await response.json();
state.virtualScroller.updateSingleItem(filePath, {
...data,
auto_tags: result.auto_tags,
});
return result;
} finally {
state.loadingManager.hide();
}
}
async addTags(filePath, data) {
try {
state.loadingManager.showSimpleLoading('Adding tags...');
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,
auto_tags: result.auto_tags,
});
}
return result;
} catch (error) {
console.error('Error adding tags:', error);
throw error;
} finally {
state.loadingManager.hide();
}
}
async refreshModels(fullRebuild = false) {
const abortController = new AbortController();
const displayName = this.apiConfig.config.displayName;
const singularName = this.apiConfig.config.singularName;
const actionText = translate(
fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
{},
fullRebuild ? 'Full rebuild' : 'Refresh'
);
const actionLowerText = translate(
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
{},
fullRebuild ? 'rebuild' : 'refresh'
);
const initialMessage = translate(
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
{ type: displayName },
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${displayName}s...`
);
const etaTracker = createScanEtaTracker();
let ws = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.total > 0) {
statusText += ` (${data.processed}/${data.total})`;
if (data.current_name) {
statusText += ` ${data.current_name}`;
}
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
state.loadingManager.setStatus(statusText);
};
try {
state.loadingManager.show(initialMessage, 0);
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
// Connect to the shared progress channel for live scan updates.
// Failure to connect must not block the refresh itself — fall back
// to the plain loading indicator.
ws = await this._connectScanProgressSocket(handleScanProgress, singularName);
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`Failed to refresh ${displayName}s: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
resetAndReload(true);
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: actionLowerText, type: displayName }, 'error');
} finally {
if (ws) {
ws.close();
}
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
/**
* Connect to the shared fetch-progress WebSocket for scan progress updates.
* Returns null when the connection cannot be established (silent fallback).
* @param {Function} onScanProgress - Handler for scan_progress messages
* @param {string} singularName - Model type filter (e.g. 'lora')
* @returns {Promise<WebSocket|null>}
*/
async _connectScanProgressSocket(onScanProgress, singularName) {
let socket = null;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
socket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (parseError) {
return;
}
// Only handle scan progress for this client's model type;
// other operations share this channel and must be ignored.
if (data.type !== 'scan_progress' || data.model_type !== singularName) {
return;
}
onScanProgress(data);
};
return socket;
} catch (error) {
if (socket) {
try {
socket.close();
} catch (closeError) {
// Ignore close errors during fallback
}
}
return null;
}
}
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 {
loading.showCancelButton(() => this.cancelTask());
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
// Now that we're connected, set up the message/error handlers
// for the actual operation (separate from connection errors)
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Scan progress shares this channel; it is handled by refreshModels
if (data.type === 'scan_progress') return;
switch (data.status) {
case 'started':
loading.setStatus('Starting metadata fetch...');
break;
case 'processing': {
const handled = data.handled || data.processed;
const percent = ((handled / data.total) * 100).toFixed(1);
loading.setProgress(percent);
let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`;
if (data.failure_count > 0) {
statusText += ` | ❌ ${data.failure_count} failed`;
}
if (data.skipped_count > 0) {
statusText += ` | ⏭️ ${data.skipped_count} skipped`;
}
loading.setStatus(statusText);
break;
}
case 'completed': {
loading.setProgress(100);
let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`;
if (data.failure_count > 0) {
summaryText += ` | ❌ ${data.failure_count} failed`;
}
if (data.skipped_count > 0) {
summaryText += ` | ⏭️ ${data.skipped_count} skipped`;
}
summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`;
loading.setStatus(summaryText);
resolve(data);
break;
}
case 'cancelled':
loading.setStatus('Operation cancelled by user');
resolve(data);
break;
case 'error':
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
reject(new Error('WebSocket error: ' + error.message));
};
});
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
const finalData = await operationComplete;
resetAndReload(false);
// Show result summary with failure details
if (finalData) {
this._showMetadataRefreshResult(finalData);
}
} 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'
});
}
_showMetadataRefreshResult(data) {
const { success, total } = data;
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success, total }, 'info');
return;
}
this._showFailureDetailsModal(data);
}
_showFailureDetailsModal(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
// Build failure list HTML
const failureRows = failures.map((f, i) =>
`<tr>
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${this._escapeHtml(f.name)}">${this._escapeHtml(f.name)}</td>
<td class="failure-error">${this._escapeHtml(f.error || 'Unknown')}</td>
</tr>`
).join('');
const modalHtml = `
<div id="metadataRefreshResultModal" class="modal" style="display: block;">
<div class="modal-content metadata-refresh-result-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${success}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failure_count}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
<span class="stat-card-value">${skipped_count}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
<span class="stat-card-value">${total || processed}</span>
</div>
</div>
<div class="stat-card stat-card-time">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
<span class="stat-card-value">${elapsed_seconds}s</span>
</div>
</div>
</div>
${failure_count > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.metadataFetchSummary.failedItems', { count: failure_count }, 'Failed Items (' + failure_count + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.metadataFetchSummary.columnModelName', {}, 'Model Name')}</th>
<th>${translate('modals.metadataFetchSummary.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.metadataFetchSummary.successMessage', { count: success, type: this.apiConfig.config.displayName }, 'All ' + success + ' ' + this.apiConfig.config.displayName + 's updated successfully!')}
</div>
`}
<div class="modal-actions">
<button class="cancel-btn" data-action="close-modal">${translate('modals.metadataFetchSummary.close', {}, 'Close')}</button>
${failure_count > 0 ? `
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.metadataFetchSummary.copyReport', {}, 'Copy Report')}</button>
<button class="secondary-btn" data-action="download-csv"><i class="fas fa-download"></i> ${translate('modals.metadataFetchSummary.downloadCsv', {}, 'Download CSV')}</button>
` : ''}
</div>
</div>
</div>
`;
const existing = document.getElementById('metadataRefreshResultModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'copy-report':
BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data);
break;
case 'download-csv':
BaseModelApiClient._downloadRefreshReport(data);
break;
}
});
}
_escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
static _copyRefreshReport(btn, data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
const lines = [
'=== Metadata Refresh Report ===',
`Date: ${new Date().toLocaleString()}`,
`Duration: ${elapsed_seconds}s`,
`Total scanned: ${total || processed}`,
`Successfully updated: ${success}`,
`Failed: ${failure_count}`,
`Skipped: ${skipped_count}`,
'',
];
if (failure_count > 0) {
lines.push('--- Failed Items ---');
failures.forEach((f, i) => {
lines.push(`${i + 1}. ${f.name || 'Unknown'}${f.error || 'Unknown error'}`);
});
lines.push('');
}
lines.push('====================');
const text = lines.join('\n');
navigator.clipboard.writeText(text).then(() => {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}).catch(() => {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
});
}
static _downloadRefreshReport(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
// CSV header
let csv = 'Model Name,Error\n';
failures.forEach(f => {
const name = (f.name || 'Unknown').replace(/"/g, '""');
const error = (f.error || 'Unknown').replace(/"/g, '""');
csv += `"${name}","${error}"\n`;
});
// Add summary as trailing comments
csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`;
csv += `# Total scanned: ${total || processed}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `metadata-refresh-failures-${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('toast.api.downloadStarted', {}, 'success');
}
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...');
let cancelled = false;
progressController.showCancelButton(() => {
cancelled = true;
this.cancelTask();
});
try {
for (let i = 0; i < filePaths.length; i++) {
if (cancelled) {
break;
}
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 (cancelled) {
completionMessage = translate('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, `Operation cancelled. ${successCount} items processed.`);
showToast('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, 'info');
} else 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');
} 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 abortController = new AbortController();
try {
state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
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) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for models:', error);
throw error;
} finally {
state.loadingManager.hide();
}
}
async refreshUpdatesForFolder(folderPath, { force = false } = {}) {
if (!folderPath) {
throw new Error('No folder path provided');
}
const abortController = new AbortController();
try {
state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
folder_path: folderPath,
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) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for folder:', error);
throw error;
} finally {
state.loadingManager.hide();
}
}
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(options = {}) {
try {
const { includeEmpty = false } = options || {};
const url = includeEmpty
? `${this.apiConfig.endpoints.folders}?include_empty=1`
: this.apiConfig.endpoints.folders;
const response = await fetch(url);
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 createFolder(folderPath) {
const response = await fetch(this.apiConfig.endpoints.createFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
throw new Error(result.error || `Failed to create folder`);
}
return result;
}
/**
* Delete a model-free folder inside the library roots.
*
* Only model-free folders can be removed; the backend answers with a 409
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
* thrown Error (`code`, `manifest`) so callers can explain the refusal
* instead of showing a bare message.
*
* @param {string} folderPath Absolute business path of the folder
* @param {{dryRun?: boolean}} [options]
*/
async deleteFolder(folderPath, options = {}) {
const { dryRun = false } = options || {};
const response = await fetch(this.apiConfig.endpoints.deleteFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to delete folder`);
error.code = result.code || null;
error.manifest = result.manifest || null;
throw error;
}
return result;
}
async fetchUnifiedFolderTree(options = {}) {
try {
const { includeEmpty = false } = options;
const url = includeEmpty
? `${this.apiConfig.endpoints.unifiedFolderTree}?include_empty=1`
: this.apiConfig.endpoints.unifiedFolderTree;
const response = await fetch(url);
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, fileParams = null, useSaveDirAsRoot = false) {
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,
use_save_dir_as_root: useSaveDirAsRoot,
download_id: downloadId,
...(source ? { source } : {}),
...(fileParams ? { file_params: fileParams } : {})
})
});
if (!response.ok) {
throw new Error(await response.text());
}
return await response.json();
} catch (error) {
console.error('Error downloading model:', error);
throw error;
}
}
/**
* List the downloadable weight files of an external repository.
* @param {string} repo - `owner/name`
* @param {string} [platform] - `huggingface` (default) or `modelscope`
* @param {string} [revision] - branch; each site has its own default
*/
async fetchModelSourceFiles(repo, platform = 'huggingface', revision = '') {
try {
const params = new URLSearchParams({ repo, platform });
if (revision) params.set('revision', revision);
const response = await fetch(`${MODEL_SOURCE_ENDPOINTS.repoFiles}?${params}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to fetch repository files');
}
return await response.json();
} catch (error) {
console.error('Error fetching repository files:', error);
throw error;
}
}
/** Backwards-compatible Hugging Face wrapper. */
async fetchHfRepoFiles(repo, revision = 'main') {
return this.fetchModelSourceFiles(repo, 'huggingface', revision);
}
async downloadModelSource({
platform = 'huggingface',
repo,
filename,
revision,
modelRoot,
relativePath,
useDefaultPaths,
download_id,
}) {
try {
const response = await fetch(MODEL_SOURCE_ENDPOINTS.download, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform,
repo,
filename,
revision: revision || '',
model_root: modelRoot,
relative_path: relativePath || '',
use_default_paths: useDefaultPaths || false,
...(download_id ? { download_id } : {}),
})
});
if (!response.ok) {
throw new Error(await response.text());
}
return await response.json();
} catch (error) {
console.error('Error downloading model:', error);
throw error;
}
}
/** Backwards-compatible Hugging Face wrapper. */
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
return this.downloadModelSource({
platform: 'huggingface',
repo,
filename,
revision: revision || 'main',
modelRoot,
relativePath,
useDefaultPaths,
download_id,
});
}
_buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';
if (!isExcludedView && pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
}
if (!isExcludedView && pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
params.append('update_available_only', 'true');
}
if (!isExcludedView && 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());
}
if (pageState.searchOptions.hash !== undefined) {
params.append('search_hash', pageState.searchOptions.hash.toString());
}
}
}
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
const vlmModelId = getSessionItem('vlm_model_id');
if (state.global.settings.group_by_model && !vlmModelId) {
params.append('group_by_model', 'true');
}
if (!isExcludedView && 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.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
if (pageState.filters.baseModel.length === 1 &&
pageState.filters.baseModel[0] === EMPTY_WILDCARD_MARKER) {
// Wildcard resolved to no matches - return empty results
return null; // Signal to return empty results
}
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');
}
}
}
if (pageState.filters.modelTypes && pageState.filters.modelTypes.length > 0) {
pageState.filters.modelTypes.forEach((type) => {
params.append('model_type', type);
});
}
// Add tag logic parameter (any = OR, all = AND)
if (pageState.filters.tagLogic) {
params.append('tag_logic', pageState.filters.tagLogic);
}
}
if (!isExcludedView) {
this._addModelSpecificParams(params, pageState);
}
return params;
}
_addModelSpecificParams(params, pageState) {
// Check for View Local Versions filter (takes priority over recipe filters)
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
if (vlmModelId && vlmPageType === this.modelType) {
params.append('civitai_model_id', vlmModelId);
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmBaseModel) {
params.append('base_model', vlmBaseModel);
}
return;
} else if (vlmModelId && vlmPageType !== this.modelType) {
// Stale VLM data from a different page type — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
}
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);
}
}
} else if (this.modelType === 'checkpoints') {
const filterCheckpointHash = getSessionItem('recipe_to_checkpoint_filterHash');
const filterCheckpointHashes = getSessionItem('recipe_to_checkpoint_filterHashes');
if (filterCheckpointHash) {
params.append('checkpoint_hash', filterCheckpointHash);
} else if (filterCheckpointHashes) {
try {
if (Array.isArray(filterCheckpointHashes) && filterCheckpointHashes.length > 0) {
params.append('checkpoint_hashes', filterCheckpointHashes.join(','));
}
} catch (error) {
console.error('Error parsing checkpoint hashes from session storage:', error);
}
}
}
}
async moveSingleModel(filePath, targetPath, useDefaultPaths = false) {
// 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 && !useDefaultPaths) {
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,
use_default_paths: useDefaultPaths
})
});
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,
cache_entry: result.cache_entry
};
}
return null;
}
async moveBulkModels(filePaths, targetPath, useDefaultPaths = false) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return [];
}
const movedPaths = useDefaultPaths ? filePaths : 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,
use_default_paths: useDefaultPaths
})
});
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');
}
const abortController = new AbortController();
try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
signal: abortController.signal,
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 ?? result.total_deleted,
failed_count: result.failed_count || 0,
errors: result.errors || [],
// Undo batch fields — batch_id on merge success, batch_ids
// array on merge failure (same success dict for the
// status='cancelled' staged-subset path)
batch_id: result.batch_id || null,
batch_ids: result.batch_ids || null
};
} else {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Bulk delete cancelled by user for ${this.apiConfig.config.displayName.toLowerCase()}s`);
return { success: false, cancelled: true };
}
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, { force = true } = {}) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.stopExampleImages());
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;
// force=false routes to the regular endpoint, which skips already-processed models
const endpoint = force
? DOWNLOAD_ENDPOINTS.exampleImages
: DOWNLOAD_ENDPOINTS.exampleImagesMissing;
// Make the API request to start the download process
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hashes: modelHashes,
output_dir: outputDir,
optimize: optimize,
force: force,
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) => {
loading.showCancelButton(() => this.cancelTask());
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 'cancelled':
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
resolve(data);
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 exclusionPatterns = (state.global.settings.auto_organize_exclusions || [])
.filter(pattern => typeof pattern === 'string' && pattern.trim())
.map(pattern => pattern.trim());
const requestBody = {};
if (filePaths) {
requestBody.file_paths = filePaths;
}
if (exclusionPatterns.length > 0) {
requestBody.exclusion_patterns = exclusionPatterns;
}
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
};
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.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
} else 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')
});
}
async stopExampleImages() {
try {
const response = await fetch('/api/lm/stop-example-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
return response.ok;
} catch (error) {
console.error('Error stopping example images:', error);
return false;
}
}
}