mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
Add new POST endpoint `/api/lm/example-images/set-nsfw-level` to allow updating NSFW classification for individual example images. The endpoint supports both regular and custom images, validates required parameters, and updates the corresponding model metadata. This enables users to manually adjust NSFW ratings for better content filtering.
308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
|
import { modalManager } from '../../managers/ModalManager.js';
|
|
import { state } from '../../state/index.js';
|
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
|
import { bulkManager } from '../../managers/BulkManager.js';
|
|
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
|
import { translate } from '../../utils/i18nHelpers.js';
|
|
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
|
|
|
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
|
export const ModelContextMenuMixin = {
|
|
// NSFW Selector methods
|
|
initNSFWSelector() {
|
|
if (this._nsfwSelectorInitialized) {
|
|
return;
|
|
}
|
|
|
|
const selector = getNsfwLevelSelector();
|
|
if (!selector) {
|
|
console.warn('NSFW selector element not found');
|
|
return;
|
|
}
|
|
|
|
this._nsfwSelectorInitialized = true;
|
|
this._nsfwSelector = selector;
|
|
},
|
|
|
|
resetNSFWSelectorState() {
|
|
// maintained for compatibility; no-op with shared selector
|
|
},
|
|
|
|
showNSFWLevelSelector(x, y, card) {
|
|
this.initNSFWSelector();
|
|
const selector = this._nsfwSelector || getNsfwLevelSelector();
|
|
|
|
if (!selector) {
|
|
console.warn('NSFW selector not available');
|
|
return;
|
|
}
|
|
|
|
// Get current NSFW level
|
|
let currentLevel = 0;
|
|
try {
|
|
const metaData = JSON.parse(card.dataset.meta || '{}');
|
|
currentLevel = metaData.preview_nsfw_level || 0;
|
|
|
|
// Update if we have no recorded level but have a dataset attribute
|
|
if (!currentLevel && card.dataset.nsfwLevel) {
|
|
currentLevel = parseInt(card.dataset.nsfwLevel, 10) || 0;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error parsing metadata:', err);
|
|
}
|
|
|
|
const filePath = card.dataset.filepath;
|
|
selector.show({
|
|
currentLevel,
|
|
onSelect: async (level) => {
|
|
if (!filePath) return false;
|
|
try {
|
|
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
|
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
|
return true;
|
|
} catch (error) {
|
|
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
|
return false;
|
|
}
|
|
},
|
|
onClose: () => this.resetNSFWSelectorState(),
|
|
});
|
|
},
|
|
|
|
// Civitai re-linking methods
|
|
showRelinkCivitaiModal() {
|
|
const filePath = this.currentCard.dataset.filepath;
|
|
if (!filePath) return;
|
|
|
|
// Set up confirm button handler
|
|
const confirmBtn = document.getElementById('confirmRelinkBtn');
|
|
const urlInput = document.getElementById('civitaiModelUrl');
|
|
const errorDiv = document.getElementById('civitaiModelUrlError');
|
|
|
|
// Remove previous event listener if exists
|
|
if (this._boundRelinkHandler) {
|
|
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
|
|
}
|
|
|
|
// Create new bound handler
|
|
this._boundRelinkHandler = async () => {
|
|
const url = urlInput.value.trim();
|
|
const { modelId, modelVersionId } = this.extractModelVersionId(url);
|
|
|
|
if (!modelId) {
|
|
errorDiv.textContent = 'Invalid URL format. Must include model ID.';
|
|
return;
|
|
}
|
|
|
|
errorDiv.textContent = '';
|
|
modalManager.closeModal('relinkCivitaiModal');
|
|
|
|
try {
|
|
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
|
|
|
|
const endpoint = this.modelType === 'checkpoint' ?
|
|
'/api/lm/checkpoints/relink-civitai' :
|
|
'/api/lm/loras/relink-civitai';
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
file_path: filePath,
|
|
model_id: modelId,
|
|
model_version_id: modelVersionId
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to re-link model: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
|
|
// Reload the current view to show updated data
|
|
await this.resetAndReload();
|
|
} else {
|
|
throw new Error(data.error || 'Failed to re-link model');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error re-linking model:', error);
|
|
showToast('toast.contextMenu.relinkFailed', { message: error.message }, 'error');
|
|
} finally {
|
|
state.loadingManager.hide();
|
|
}
|
|
};
|
|
|
|
// Set new event listener
|
|
confirmBtn.addEventListener('click', this._boundRelinkHandler);
|
|
|
|
// Clear previous input
|
|
urlInput.value = '';
|
|
errorDiv.textContent = '';
|
|
|
|
// Show modal
|
|
modalManager.showModal('relinkCivitaiModal');
|
|
|
|
// Auto-focus the URL input field after modal is shown
|
|
setTimeout(() => urlInput.focus(), 50);
|
|
},
|
|
|
|
extractModelVersionId(url) {
|
|
try {
|
|
// Handle all three URL formats:
|
|
// 1. https://civitai.com/models/649516
|
|
// 2. https://civitai.com/models/649516?modelVersionId=726676
|
|
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
|
|
|
|
const parsedUrl = new URL(url);
|
|
|
|
// Extract model ID from path
|
|
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
|
const modelId = pathMatch ? pathMatch[1] : null;
|
|
|
|
// Extract model version ID from query parameters
|
|
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
|
|
|
return { modelId, modelVersionId };
|
|
} catch (e) {
|
|
return { modelId: null, modelVersionId: null };
|
|
}
|
|
},
|
|
|
|
parseModelId(value) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
},
|
|
|
|
getModelIdFromCard(card) {
|
|
if (!card) {
|
|
return null;
|
|
}
|
|
|
|
if (card.dataset?.meta) {
|
|
try {
|
|
const meta = JSON.parse(card.dataset.meta);
|
|
const metaValue = this.parseModelId(meta?.modelId);
|
|
if (metaValue !== null) {
|
|
return metaValue;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Unable to parse card metadata for model ID', error);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
async checkUpdatesForCurrentModel() {
|
|
const card = this.currentCard;
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const modelId = this.getModelIdFromCard(card);
|
|
const typeConfig = MODEL_CONFIG[this.modelType] || {};
|
|
const typeLabel = (typeConfig.displayName || 'Model').toLowerCase();
|
|
|
|
if (modelId === null) {
|
|
showToast('toast.models.bulkUpdatesMissing', { type: typeLabel }, 'warning');
|
|
return;
|
|
}
|
|
|
|
const apiClient = getModelApiClient();
|
|
|
|
const loadingMessage = translate(
|
|
'toast.models.bulkUpdatesChecking',
|
|
{ count: 1, type: typeLabel },
|
|
`Checking selected ${typeLabel}(s) for updates...`
|
|
);
|
|
state.loadingManager.showSimpleLoading(loadingMessage);
|
|
|
|
try {
|
|
const response = await apiClient.refreshUpdatesForModels([modelId]);
|
|
const records = Array.isArray(response?.records) ? response.records : [];
|
|
const updatesCount = records.length;
|
|
|
|
if (updatesCount > 0) {
|
|
showToast('toast.models.bulkUpdatesSuccess', { count: updatesCount, type: typeLabel }, 'success');
|
|
} else {
|
|
showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
|
|
}
|
|
|
|
const resetFn = this.resetAndReload || resetAndReload;
|
|
if (typeof resetFn === 'function') {
|
|
await resetFn(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking updates for model:', error);
|
|
showToast(
|
|
'toast.models.bulkUpdatesFailed',
|
|
{ type: typeLabel, message: error?.message ?? 'Unknown error' },
|
|
'error'
|
|
);
|
|
} finally {
|
|
state.loadingManager.hide();
|
|
state.loadingManager.restoreProgressBar();
|
|
}
|
|
},
|
|
|
|
// Common action handlers
|
|
handleCommonMenuActions(action) {
|
|
switch(action) {
|
|
case 'preview':
|
|
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
|
return true;
|
|
case 'download-examples':
|
|
this.downloadExampleImages();
|
|
return true;
|
|
case 'civitai':
|
|
if (this.currentCard.dataset.from_civitai === 'true') {
|
|
if (this.currentCard.querySelector('.fa-globe')) {
|
|
this.currentCard.querySelector('.fa-globe').click();
|
|
} else {
|
|
showToast('toast.contextMenu.fetchMetadataFirst', {}, 'info');
|
|
}
|
|
} else {
|
|
showToast('toast.contextMenu.noCivitaiInfo', {}, 'info');
|
|
}
|
|
return true;
|
|
case 'relink-civitai':
|
|
this.showRelinkCivitaiModal();
|
|
return true;
|
|
case 'set-nsfw':
|
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
|
return true;
|
|
case 'check-updates':
|
|
this.checkUpdatesForCurrentModel();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Download example images method
|
|
async downloadExampleImages() {
|
|
const modelHash = this.currentCard.dataset.sha256;
|
|
if (!modelHash) {
|
|
showToast('toast.contextMenu.missingHash', {}, 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const apiClient = getModelApiClient();
|
|
await apiClient.downloadExampleImages([modelHash]);
|
|
} catch (error) {
|
|
console.error('Error downloading example images:', error);
|
|
}
|
|
}
|
|
};
|