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.
This commit is contained in:
Will Miao
2025-12-09 20:37:09 +08:00
parent 3fc72d6bc1
commit a6e23a7630
15 changed files with 486 additions and 165 deletions

View File

@@ -11,11 +11,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
this.modelType = 'checkpoint';
this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events only if not already initialized
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
this.initNSFWSelector();
}
// Implementation needed by the mixin
@@ -65,4 +61,4 @@ export class CheckpointContextMenu extends BaseContextMenu {
}
// Mix in shared methods
Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin);
Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -11,11 +11,7 @@ export class EmbeddingContextMenu extends BaseContextMenu {
this.modelType = 'embedding';
this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events only if not already initialized
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
this.initNSFWSelector();
}
// Implementation needed by the mixin

View File

@@ -12,11 +12,7 @@ export class LoraContextMenu extends BaseContextMenu {
this.modelType = 'lora';
this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events only if not already initialized
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
this.initNSFWSelector();
}
// Use the saveModelMetadata implementation from loraApi
@@ -78,4 +74,4 @@ export class LoraContextMenu extends BaseContextMenu {
}
// Mix in shared methods
Object.assign(LoraContextMenu.prototype, ModelContextMenuMixin);
Object.assign(LoraContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -5,96 +5,38 @@ 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() {
// Remove any existing event listeners by cloning and replacing elements
// This is a simple way to ensure we don't have duplicate event listeners
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
const newCloseBtn = closeBtn.cloneNode(true);
closeBtn.parentNode.replaceChild(newCloseBtn, closeBtn);
newCloseBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
// Remove any existing event listeners by cloning and replacing the button
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async () => {
const level = parseInt(newBtn.dataset.level);
const mode = this.nsfwSelector.dataset.mode || 'single';
if (mode === 'bulk') {
let bulkFilePaths = [];
if (this.nsfwSelector.dataset.bulkFilePaths) {
try {
bulkFilePaths = JSON.parse(this.nsfwSelector.dataset.bulkFilePaths);
} catch (error) {
console.warn('Failed to parse bulk file paths for content rating', error);
}
}
const success = await bulkManager.setBulkContentRating(level, bulkFilePaths);
if (success) {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
}
return;
}
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
} catch (error) {
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
}
});
});
// Close when clicking outside - use a named function so we can remove it later
const outsideClickListener = (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"], .context-menu-item[data-action="set-content-rating"]')) {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
}
};
// Remove previous listener if it exists
if (this._outsideClickListener) {
document.removeEventListener('click', this._outsideClickListener);
if (this._nsfwSelectorInitialized) {
return;
}
// Store and add new listener
this._outsideClickListener = outsideClickListener;
document.addEventListener('click', this._outsideClickListener);
const selector = getNsfwLevelSelector();
if (!selector) {
console.warn('NSFW selector element not found');
return;
}
this._nsfwSelectorInitialized = true;
this._nsfwSelector = selector;
},
resetNSFWSelectorState() {
if (!this.nsfwSelector) return;
delete this.nsfwSelector.dataset.bulkFilePaths;
delete this.nsfwSelector.dataset.mode;
delete this.nsfwSelector.dataset.cardPath;
// maintained for compatibility; no-op with shared selector
},
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
this.initNSFWSelector();
const selector = this._nsfwSelector || getNsfwLevelSelector();
if (!selector) {
console.warn('NSFW selector not available');
return;
}
// Get current NSFW level
let currentLevel = 0;
@@ -104,44 +46,28 @@ export const ModelContextMenuMixin = {
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
currentLevel = parseInt(card.dataset.nsfwLevel, 10) || 0;
}
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
selector.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
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(),
});
// Store reference to current card
selector.dataset.mode = 'single';
selector.dataset.cardPath = card.dataset.filepath;
delete selector.dataset.bulkFilePaths;
// Show selector
selector.style.display = 'block';
},
// Civitai re-linking methods

View File

@@ -11,11 +11,7 @@ export class RecipeContextMenu extends BaseContextMenu {
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'recipe';
// Initialize NSFW Level Selector events only if not already initialized
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
this.initNSFWSelector();
}
// Use the updateRecipeMetadata implementation from recipeApi
@@ -286,4 +282,4 @@ export class RecipeContextMenu extends BaseContextMenu {
}
// Mix in shared methods from ModelContextMenuMixin
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin);
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -0,0 +1,126 @@
import { getNSFWLevelName } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
let selectorController = null;
function buildController(selectorElement) {
if (!selectorElement) return null;
const levelButtons = Array.from(selectorElement.querySelectorAll('.nsfw-level-btn'));
const closeBtn = selectorElement.querySelector('.close-nsfw-selector');
const currentLevelEl = selectorElement.querySelector('#currentNSFWLevel');
let onSelect = null;
let onClose = null;
let isOpen = false;
let ignoreNextOutside = false;
const setLabel = (level, multipleLabel) => {
if (!currentLevelEl) return;
if (multipleLabel) {
currentLevelEl.textContent = multipleLabel;
return;
}
currentLevelEl.textContent = getNSFWLevelName(level);
};
const hide = () => {
selectorElement.style.display = 'none';
isOpen = false;
if (typeof onClose === 'function') {
onClose();
}
onSelect = null;
onClose = null;
};
const show = ({ currentLevel = 0, onSelect: selectCb, onClose: closeCb, multipleLabel = '' } = {}) => {
onSelect = selectCb || null;
onClose = closeCb || null;
isOpen = true;
ignoreNextOutside = true; // ignore the click that triggered open
// Position near center of viewport
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const rect = selectorElement.getBoundingClientRect();
const finalX = Math.max((viewportWidth - rect.width) / 2, 0);
const finalY = Math.max((viewportHeight - rect.height) / 2, 0);
selectorElement.style.left = `${finalX}px`;
selectorElement.style.top = `${finalY}px`;
setLabel(currentLevel, multipleLabel);
// Highlight selected level (if not showing multiple values)
levelButtons.forEach((btn) => {
const btnLevel = parseInt(btn.dataset.level || '0', 10);
if (multipleLabel) {
btn.classList.remove('active');
} else if (btnLevel === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
selectorElement.style.display = 'block';
};
if (closeBtn) {
closeBtn.addEventListener('click', hide);
}
document.addEventListener('click', (e) => {
if (!isOpen) return;
if (ignoreNextOutside) {
ignoreNextOutside = false;
return;
}
if (!selectorElement.contains(e.target)) {
hide();
}
});
levelButtons.forEach((btn) => {
btn.addEventListener('click', async () => {
if (!isOpen) return;
const level = parseInt(btn.dataset.level || '0', 10);
if (typeof onSelect === 'function') {
try {
const result = await onSelect(level);
if (result === false) {
return;
}
} catch (error) {
console.error('NSFW selector onSelect failed', error);
return;
}
}
hide();
});
});
const showMultiple = (labelKey = 'modals.contentRating.multiple') => {
const fallback = 'Multiple values';
const text = translate(labelKey, {}, fallback);
show({ multipleLabel: text });
};
return {
show,
hide,
showMultiple,
isOpen: () => isOpen,
};
}
export function getNsfwLevelSelector() {
if (selectorController) {
return selectorController;
}
const element = document.getElementById('nsfwLevelSelector');
selectorController = buildController(element);
return selectorController;
}

View File

@@ -3,9 +3,11 @@
* Media-specific utility functions for showcase components
* (Moved from uiHelpers.js to better organize code)
*/
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS } from '../../../utils/constants.js';
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
/**
* Try to load local image first, fall back to remote if local fails
@@ -471,6 +473,9 @@ export function initMediaControlHandlers(container) {
// Initialize set preview buttons
initSetPreviewHandlers(container);
// Initialize NSFW level buttons
initSetNsfwHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers
// Any click handlers or other functionality can still be added here
@@ -590,4 +595,143 @@ export function positionAllMediaControls(container) {
mediaWrappers.forEach(wrapper => {
positionMediaControlsInMediaRect(wrapper);
});
}
}
function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
if (!mediaWrapper) return;
const mediaElement = mediaWrapper.querySelector('img, video');
if (mediaElement) {
mediaElement.dataset.nsfwLevel = String(nsfwLevel);
}
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
if (shouldBlur) {
mediaWrapper.classList.add('nsfw-media-wrapper');
if (mediaElement) {
mediaElement.classList.add('blurred');
}
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'nsfw-overlay';
overlay.innerHTML = `
<div class="nsfw-warning">
<p>Mature Content</p>
<button class="show-content-btn">Show</button>
</div>
`;
mediaWrapper.appendChild(overlay);
} else {
overlay.style.display = 'flex';
}
if (!toggleBtn) {
toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-blur-btn showcase-toggle-btn';
toggleBtn.title = 'Toggle blur';
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
mediaWrapper.insertBefore(toggleBtn, mediaWrapper.firstChild);
} else {
const icon = toggleBtn.querySelector('i');
if (icon) {
icon.className = 'fas fa-eye';
}
}
} else {
mediaWrapper.classList.remove('nsfw-media-wrapper');
if (mediaElement) {
mediaElement.classList.remove('blurred');
}
if (overlay) {
overlay.style.display = 'none';
}
if (toggleBtn) {
const icon = toggleBtn.querySelector('i');
if (icon) {
icon.className = 'fas fa-eye-slash';
}
}
}
// Re-bind blur toggles for any newly added elements
initNsfwBlurHandlers(mediaWrapper);
}
function initSetNsfwHandlers(container) {
const nsfwButtons = container.querySelectorAll('.set-nsfw-btn');
const selector = getNsfwLevelSelector();
nsfwButtons.forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (!selector) {
console.warn('NSFW selector not available');
return;
}
const mediaWrapper = btn.closest('.media-wrapper');
const currentLevel = parseInt(mediaWrapper?.dataset.nsfwLevel || '0', 10);
const modelHash = document.querySelector('.showcase-section')?.dataset.modelHash;
const mediaSource = btn.dataset.mediaSource || 'civitai';
const mediaIndex = parseInt(btn.dataset.mediaIndex || '-1', 10);
const mediaId = btn.dataset.mediaId || '';
selector.show({
currentLevel,
onSelect: async (level) => {
if (!modelHash) {
showToast('toast.contextMenu.contentRatingFailed', { message: 'Missing model hash' }, 'error');
return false;
}
const originalIcon = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const payload = {
model_hash: modelHash,
nsfw_level: level,
source: mediaSource,
};
if (mediaSource === 'custom') {
payload.id = mediaId;
} else {
payload.index = mediaIndex;
}
const response = await fetch('/api/lm/example-images/set-nsfw-level', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to update NSFW level');
}
applyNsfwLevelChange(mediaWrapper, level);
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
return true;
} catch (error) {
console.error('Error updating NSFW level:', error);
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
return false;
} finally {
btn.disabled = false;
btn.innerHTML = originalIcon;
}
},
});
});
});
}

View File

@@ -211,6 +211,13 @@ function renderMediaItem(img, index, exampleFiles) {
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
<button class="media-control-btn set-nsfw-btn"
title="Set content rating"
data-media-index="${index}"
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
data-media-id="${img.id || ''}">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"