Files
ComfyUI-Lora-Manager/static/js/api/baseModelApi.js
T
Will Miao 2dcaf6a30e feat(sidebar): manage folders that live under several model roots
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
2026-10-06 17:14:07 +08:00

2376 lines
97 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;
let lastProgressStage = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
// The per-file rate of one stage says nothing about the next one:
// the walk phase counts files while the new-file pass processes
// them, so a carried-over average would produce a nonsense ETA.
if (data.stage && data.stage !== lastProgressStage) {
lastProgressStage = data.stage;
etaTracker.reset();
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.stage === 'reconcile_scan') {
// Walk phase: the real file count is only known once the walk
// finishes, so report the files checked so far and the roots
// being walked instead of a processed/total ratio.
const filesSeen = Number(data.files_seen) || 0;
if (filesSeen > 0) {
const roots = Array.isArray(data.active_roots)
? data.active_roots.filter(Boolean)
: [];
const formattedFiles = filesSeen.toLocaleString();
const filesText = translate(
'common.scanProgress.walkFiles',
{ count: formattedFiles },
`${formattedFiles} files`
);
statusText += roots.length
? ` ${roots.join(', ')} (${filesText})`
: ` (${filesText})`;
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
} else 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;
}
/**
* Rename a folder inside the library roots.
*
* Works on folders that hold models too — the backend re-keys the affected
* cache records instead of cascading. A name collision or a staged delete
* inside the subtree surfaces as a 409 conflict, attached to the thrown
* Error as `code`.
*
* @param {string} folderPath Absolute business path of the folder
* @param {string} newName New leaf name (a single path segment)
*/
async renameFolder(folderPath, newName) {
const response = await fetch(this.apiConfig.endpoints.renameFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, new_name: newName })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to rename folder`);
error.code = result.code || null;
throw error;
}
return result;
}
/**
* Resolve a library-relative folder onto the directories it stands for.
*
* The unified folder tree merges every model root into one relative-path
* namespace, so a tree node cannot be turned into a business path by
* prefixing a root: the same relative folder may live under several roots,
* or only under one that is not the default root. Callers that hold a node
* ask here first; the answer is what the folder APIs must be given.
*
* @param {string} folderPath Library-relative folder path (e.g. `a/b`)
* @returns {Promise<{folder: string, candidates: Array<{folder_path: string, root: string, is_symlink: boolean}>}>}
* `candidates` is empty when no root holds the directory on disk.
*/
async resolveFolder(folderPath) {
const params = new URLSearchParams({ folder: folderPath });
const response = await fetch(`${this.apiConfig.endpoints.resolveFolder}?${params}`);
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to resolve folder`);
error.code = result.code || 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());
}
}
}
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')
});
}
/**
* Apply the configured download filename template to models, renaming their files
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
* @returns {Promise} - Promise that resolves when the operation is complete
*/
async applyFilenameTemplate(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 !== 'filename_template_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.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${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.filenameTemplateProgress.processing', {
processed: data.processed,
total: data.total,
success: data.success,
failures: data.failures,
skipped: data.skipped
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
);
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
success: data.success,
skipped: data.skipped,
failures: data.failures,
total: data.total
}, `Completed: ${data.success} renamed, ${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.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error during filename template apply:', error);
reject(new Error('Connection error'));
};
});
// Start the filename template operation
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
const requestBody = {};
if (filePaths) {
requestBody.file_paths = filePaths;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to start filename template 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.filenameTemplateSuccess', {
count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
}, 'success');
} else {
showToast('toast.loras.filenameTemplatePartialSuccess', {
success: result.success,
failures: result.failures,
total: result.total
}, 'warning');
}
} catch (error) {
console.error('Error applying filename template:', error);
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
throw error;
} finally {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
}, {
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply 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;
}
}
}