Files
ComfyUI-Lora-Manager/static/js/components/ContextMenu/ModelContextMenuMixin.js
Will Miao a6e23a7630 feat(example-images): add NSFW level setting endpoint
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.
2025-12-09 20:37:16 +08:00

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);
}
}
};