mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-07-14 04:31:17 -03:00
- Merge Relink to Civitai and new Link to HuggingFace into a single 'Link Model' submenu with sub-options for each source - Add POST /api/lm/set-hf-url endpoint to associate a model with a HuggingFace repo URL, saving hf_url to .metadata.json - Add link_hf_modal.html for URL input, following relink-civitai pattern - Use update_single_model_cache instead of add_model_to_cache to prevent duplicate cache entries after linking - Remove os.path.realpath usage for consistency with relink-civitai - Raise errors instead of silently falling back to LoRA scanner when model root cannot be determined - Scope .input-group CSS rules to modal IDs to fix style conflicts with download-modal.css - Add i18n keys across all 10 locales with translations for zh-CN, zh-TW, ja, ko, de, es, fr, he, ru
396 lines
14 KiB
JavaScript
396 lines
14 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';
|
|
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
|
|
|
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
|
export const ModelContextMenuMixin = {
|
|
isExcludedView() {
|
|
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
|
|
},
|
|
|
|
updateExcludeMenuItem() {
|
|
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
|
|
if (!excludeItem) {
|
|
return;
|
|
}
|
|
|
|
const isExcludedView = this.isExcludedView();
|
|
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
|
|
excludeItem.innerHTML = isExcludedView
|
|
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
|
|
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
|
|
},
|
|
|
|
async restoreExcludedModel(filePath) {
|
|
const restored = await getModelApiClient().unexcludeModel(filePath);
|
|
if (!restored) {
|
|
return;
|
|
}
|
|
|
|
if (window.pageControls?.exitExcludedView) {
|
|
await window.pageControls.exitExcludedView();
|
|
} else {
|
|
const resetFn = this.resetAndReload || resetAndReload;
|
|
if (typeof resetFn === 'function') {
|
|
await resetFn(true);
|
|
}
|
|
}
|
|
},
|
|
|
|
// 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,
|
|
cardPath: filePath,
|
|
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);
|
|
},
|
|
|
|
// HuggingFace linking methods
|
|
showLinkHfModal() {
|
|
const filePath = this.currentCard.dataset.filepath;
|
|
if (!filePath) return;
|
|
|
|
const confirmBtn = document.getElementById('confirmLinkHfBtn');
|
|
const urlInput = document.getElementById('hfModelUrl');
|
|
const errorDiv = document.getElementById('hfModelUrlError');
|
|
|
|
if (this._boundLinkHfHandler) {
|
|
confirmBtn.removeEventListener('click', this._boundLinkHfHandler);
|
|
}
|
|
|
|
this._boundLinkHfHandler = async () => {
|
|
const hfUrl = urlInput.value.trim();
|
|
if (!hfUrl) {
|
|
errorDiv.textContent = 'Please enter a HuggingFace repository URL.';
|
|
return;
|
|
}
|
|
|
|
const hfPattern = /^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/?$/;
|
|
if (!hfPattern.test(hfUrl)) {
|
|
errorDiv.textContent = 'Invalid URL format. Expected: https://huggingface.co/user/repo';
|
|
return;
|
|
}
|
|
|
|
errorDiv.textContent = '';
|
|
modalManager.closeModal('linkHfModal');
|
|
|
|
try {
|
|
state.loadingManager.showSimpleLoading('Linking to HuggingFace...');
|
|
|
|
const response = await fetch('/api/lm/set-hf-url', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ file_path: filePath, hf_url: hfUrl }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.json().catch(() => ({}));
|
|
throw new Error(errData.error || `Request failed: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
showToast('toast.contextMenu.linkHfSuccess', {}, 'success');
|
|
await this.resetAndReload();
|
|
} else {
|
|
throw new Error(data.error || 'Failed to link model');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error linking model to HuggingFace:', error);
|
|
showToast('toast.contextMenu.linkHfFailed', { message: error.message }, 'error');
|
|
} finally {
|
|
state.loadingManager.hide();
|
|
}
|
|
};
|
|
|
|
confirmBtn.addEventListener('click', this._boundLinkHfHandler);
|
|
|
|
urlInput.value = '';
|
|
errorDiv.textContent = '';
|
|
|
|
modalManager.showModal('linkHfModal');
|
|
|
|
setTimeout(() => urlInput.focus(), 50);
|
|
},
|
|
|
|
extractModelVersionId(url) {
|
|
return extractCivitaiModelUrlParts(url);
|
|
},
|
|
|
|
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 'link-hf':
|
|
this.showLinkHfModal();
|
|
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);
|
|
}
|
|
}
|
|
};
|