mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
- Replaced `safeTranslate` with `translate` in various components for consistent translation handling. - Updated Chinese (Simplified and Traditional) localization files to include new keys and improved translations for model card actions, metadata, and usage tips. - Enhanced the ModelCard, ModelDescription, ModelMetadata, ModelModal, and ModelTags components to utilize the new translation functions. - Improved user feedback messages for actions like copying to clipboard, saving notes, and updating tags with localized strings. - Ensured all UI elements reflect the correct translations based on the user's language preference.
456 lines
20 KiB
JavaScript
456 lines
20 KiB
JavaScript
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
|
import { modalManager } from '../../managers/ModalManager.js';
|
|
import {
|
|
toggleShowcase,
|
|
setupShowcaseScroll,
|
|
scrollToTop,
|
|
loadExampleImages
|
|
} from './showcase/ShowcaseView.js';
|
|
import { setupTabSwitching } from './ModelDescription.js';
|
|
import {
|
|
setupModelNameEditing,
|
|
setupBaseModelEditing,
|
|
setupFileNameEditing
|
|
} from './ModelMetadata.js';
|
|
import { setupTagEditMode } from './ModelTags.js';
|
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
|
import { loadRecipesForLora } from './RecipeTab.js';
|
|
import { translate } from '../../utils/i18nHelpers.js';
|
|
|
|
/**
|
|
* Display the model modal with the given model data
|
|
* @param {Object} model - Model data object
|
|
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
|
*/
|
|
export async function showModelModal(model, modelType) {
|
|
const modalId = 'modelModal';
|
|
const modalTitle = model.model_name;
|
|
|
|
// Fetch complete civitai metadata
|
|
let completeCivitaiData = model.civitai || {};
|
|
if (model.file_path) {
|
|
try {
|
|
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
|
completeCivitaiData = fullMetadata || model.civitai || {};
|
|
} catch (error) {
|
|
console.warn('Failed to fetch complete metadata, using existing data:', error);
|
|
// Continue with existing data if fetch fails
|
|
}
|
|
}
|
|
|
|
// Update model with complete civitai data
|
|
const modelWithFullData = {
|
|
...model,
|
|
civitai: completeCivitaiData
|
|
};
|
|
|
|
// Prepare LoRA specific data with complete civitai data
|
|
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
|
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
|
|
|
// Generate model type specific content
|
|
let typeSpecificContent;
|
|
if (modelType === 'loras') {
|
|
typeSpecificContent = renderLoraSpecificContent(modelWithFullData, escapedWords);
|
|
} else if (modelType === 'embeddings') {
|
|
typeSpecificContent = renderEmbeddingSpecificContent(modelWithFullData, escapedWords);
|
|
} else {
|
|
typeSpecificContent = '';
|
|
}
|
|
|
|
// Generate tabs based on model type
|
|
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
|
|
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
|
|
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
|
|
|
|
const tabsContent = modelType === 'loras' ?
|
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
|
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
|
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>`;
|
|
|
|
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
|
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
|
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
|
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
|
|
|
const tabPanesContent = modelType === 'loras' ?
|
|
`<div id="showcase-tab" class="tab-pane active">
|
|
<div class="example-images-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
|
</div>
|
|
</div>
|
|
|
|
<div id="description-tab" class="tab-pane">
|
|
<div class="model-description-container">
|
|
<div class="model-description-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
|
|
</div>
|
|
<div class="model-description-content hidden">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="recipes-tab" class="tab-pane">
|
|
<div class="recipes-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
|
|
</div>
|
|
</div>` :
|
|
`<div id="showcase-tab" class="tab-pane active">
|
|
<div class="recipes-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExamplesText}
|
|
</div>
|
|
</div>
|
|
|
|
<div id="description-tab" class="tab-pane">
|
|
<div class="model-description-container">
|
|
<div class="model-description-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
|
|
</div>
|
|
<div class="model-description-content hidden">
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
const content = `
|
|
<div class="modal-content">
|
|
<button class="close" onclick="modalManager.closeModal('${modalId}')">×</button>
|
|
<header class="modal-header">
|
|
<div class="model-name-header">
|
|
<h2 class="model-name-content">${modalTitle}</h2>
|
|
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="creator-actions">
|
|
${modelWithFullData.from_civitai ? `
|
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}">
|
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
|
</div>` : ''}
|
|
|
|
${modelWithFullData.civitai?.creator ? `
|
|
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
|
${modelWithFullData.civitai.creator.image ?
|
|
`<div class="creator-avatar">
|
|
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
|
</div>` :
|
|
`<div class="creator-avatar creator-placeholder">
|
|
<i class="fas fa-user"></i>
|
|
</div>`
|
|
}
|
|
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
|
|
</div>` : ''}
|
|
</div>
|
|
|
|
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
|
|
</header>
|
|
|
|
<div class="modal-body">
|
|
<div class="info-section">
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
|
|
<span>${modelWithFullData.civitai?.name || 'N/A'}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
|
|
<div class="file-name-wrapper">
|
|
<span id="file-name" class="file-name-content">${modelWithFullData.file_name || 'N/A'}</span>
|
|
<button class="edit-file-name-btn" title="${translate('modals.model.actions.editFileName', {}, 'Edit file name')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="info-item location-size">
|
|
<div class="location-wrapper">
|
|
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
|
<span class="file-path">${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="info-item base-size">
|
|
<div class="base-wrapper">
|
|
<label>${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</label>
|
|
<div class="base-model-display">
|
|
<span class="base-model-content">${modelWithFullData.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown')}</span>
|
|
<button class="edit-base-model-btn" title="${translate('modals.model.actions.editBaseModel', {}, 'Edit base model')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="size-wrapper">
|
|
<label>${translate('modals.model.metadata.size', {}, 'Size')}</label>
|
|
<span>${formatFileSize(modelWithFullData.file_size)}</span>
|
|
</div>
|
|
</div>
|
|
${typeSpecificContent}
|
|
<div class="info-item notes">
|
|
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
|
|
<div class="editable-field">
|
|
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
|
|
</div>
|
|
</div>
|
|
<div class="info-item full-width">
|
|
<label>${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</label>
|
|
<div class="description-text">${modelWithFullData.civitai?.description || 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${modelWithFullData.file_path}">
|
|
<div class="showcase-tabs">
|
|
${tabsContent}
|
|
</div>
|
|
|
|
<div class="tab-content">
|
|
${tabPanesContent}
|
|
</div>
|
|
|
|
<button class="back-to-top" data-action="scroll-to-top">
|
|
<i class="fas fa-arrow-up"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const onCloseCallback = function() {
|
|
// Clean up all handlers when modal closes for LoRA
|
|
const modalElement = document.getElementById(modalId);
|
|
if (modalElement && modalElement._clickHandler) {
|
|
modalElement.removeEventListener('click', modalElement._clickHandler);
|
|
delete modalElement._clickHandler;
|
|
}
|
|
};
|
|
|
|
modalManager.showModal(modalId, content, null, onCloseCallback);
|
|
setupEditableFields(modelWithFullData.file_path, modelType);
|
|
setupShowcaseScroll(modalId);
|
|
setupTabSwitching();
|
|
setupTagTooltip();
|
|
setupTagEditMode();
|
|
setupModelNameEditing(modelWithFullData.file_path);
|
|
setupBaseModelEditing(modelWithFullData.file_path);
|
|
setupFileNameEditing(modelWithFullData.file_path);
|
|
setupEventHandlers(modelWithFullData.file_path);
|
|
|
|
// LoRA specific setup
|
|
if (modelType === 'loras' || modelType === 'embeddings') {
|
|
setupTriggerWordsEditMode();
|
|
|
|
if (modelType == 'loras') {
|
|
// Load recipes for this LoRA
|
|
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
|
}
|
|
}
|
|
|
|
// Load example images asynchronously - merge regular and custom images
|
|
const regularImages = modelWithFullData.civitai?.images || [];
|
|
const customImages = modelWithFullData.civitai?.customImages || [];
|
|
// Combine images - regular images first, then custom images
|
|
const allImages = [...regularImages, ...customImages];
|
|
loadExampleImages(allImages, modelWithFullData.sha256);
|
|
}
|
|
|
|
function renderLoraSpecificContent(lora, escapedWords) {
|
|
return `
|
|
<div class="info-item usage-tips">
|
|
<label>${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</label>
|
|
<div class="editable-field">
|
|
<div class="preset-controls">
|
|
<select id="preset-selector">
|
|
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Add preset parameter...')}</option>
|
|
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
|
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
|
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
|
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
|
</select>
|
|
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
|
|
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
|
</div>
|
|
<div class="preset-tags">
|
|
${renderPresetTags(parsePresets(lora.usage_tips))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${renderTriggerWords(escapedWords, lora.file_path)}
|
|
`;
|
|
}
|
|
|
|
function renderEmbeddingSpecificContent(embedding, escapedWords) {
|
|
return `${renderTriggerWords(escapedWords, embedding.file_path)}`;
|
|
}
|
|
|
|
/**
|
|
* Sets up event handlers using event delegation for LoRA modal
|
|
* @param {string} filePath - Path to the model file
|
|
*/
|
|
function setupEventHandlers(filePath) {
|
|
const modalElement = document.getElementById('modelModal');
|
|
|
|
// Remove existing event listeners first
|
|
modalElement.removeEventListener('click', handleModalClick);
|
|
|
|
// Create and store the handler function
|
|
function handleModalClick(event) {
|
|
const target = event.target.closest('[data-action]');
|
|
if (!target) return;
|
|
|
|
const action = target.dataset.action;
|
|
|
|
switch (action) {
|
|
case 'close-modal':
|
|
modalManager.closeModal('modelModal');
|
|
break;
|
|
case 'scroll-to-top':
|
|
scrollToTop(target);
|
|
break;
|
|
case 'view-civitai':
|
|
openCivitai(target.dataset.filepath);
|
|
break;
|
|
case 'view-creator':
|
|
const username = target.dataset.username;
|
|
if (username) {
|
|
window.open(`https://civitai.com/user/${username}`, '_blank');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Add the event listener with the named function
|
|
modalElement.addEventListener('click', handleModalClick);
|
|
|
|
// Store reference to the handler on the element for potential cleanup
|
|
modalElement._clickHandler = handleModalClick;
|
|
}
|
|
|
|
/**
|
|
* Set up editable fields (notes and usage tips) in the model modal
|
|
* @param {string} filePath - The full file path of the model
|
|
* @param {string} modelType - Type of model ('loras' or 'checkpoints' or 'embeddings')
|
|
*/
|
|
function setupEditableFields(filePath, modelType) {
|
|
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
|
|
|
|
editableFields.forEach(field => {
|
|
field.addEventListener('focus', function() {
|
|
if (this.textContent === 'Add your notes here...') {
|
|
this.textContent = '';
|
|
}
|
|
});
|
|
|
|
field.addEventListener('blur', function() {
|
|
if (this.textContent.trim() === '') {
|
|
if (this.classList.contains('notes-content')) {
|
|
this.textContent = 'Add your notes here...';
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add keydown event listeners for notes
|
|
const notesContent = document.querySelector('.notes-content');
|
|
if (notesContent) {
|
|
notesContent.addEventListener('keydown', async function(e) {
|
|
if (e.key === 'Enter') {
|
|
if (e.shiftKey) {
|
|
// Allow shift+enter for new line
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
await saveNotes(filePath);
|
|
}
|
|
});
|
|
}
|
|
|
|
// LoRA specific field setup
|
|
if (modelType === 'loras') {
|
|
setupLoraSpecificFields(filePath);
|
|
}
|
|
}
|
|
|
|
function setupLoraSpecificFields(filePath) {
|
|
const presetSelector = document.getElementById('preset-selector');
|
|
const presetValue = document.getElementById('preset-value');
|
|
const addPresetBtn = document.querySelector('.add-preset-btn');
|
|
const presetTags = document.querySelector('.preset-tags');
|
|
|
|
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
|
|
|
presetSelector.addEventListener('change', function() {
|
|
const selected = this.value;
|
|
if (selected) {
|
|
presetValue.style.display = 'inline-block';
|
|
presetValue.min = selected.includes('strength') ? -10 : 0;
|
|
presetValue.max = selected.includes('strength') ? 10 : 10;
|
|
presetValue.step = 0.5;
|
|
if (selected === 'clip_skip') {
|
|
presetValue.type = 'number';
|
|
presetValue.step = 1;
|
|
}
|
|
// Add auto-focus
|
|
setTimeout(() => presetValue.focus(), 0);
|
|
} else {
|
|
presetValue.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
addPresetBtn.addEventListener('click', async function() {
|
|
const key = presetSelector.value;
|
|
const value = presetValue.value;
|
|
|
|
if (!key || !value) return;
|
|
|
|
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
|
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
|
|
|
currentPresets[key] = parseFloat(value);
|
|
const newPresetsJson = JSON.stringify(currentPresets);
|
|
|
|
await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson });
|
|
|
|
presetTags.innerHTML = renderPresetTags(currentPresets);
|
|
|
|
presetSelector.value = '';
|
|
presetValue.value = '';
|
|
presetValue.style.display = 'none';
|
|
});
|
|
|
|
// Add keydown event for preset value
|
|
presetValue.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addPresetBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save model notes
|
|
* @param {string} filePath - Path to the model file
|
|
*/
|
|
async function saveNotes(filePath) {
|
|
const content = document.querySelector('.notes-content').textContent;
|
|
try {
|
|
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
|
|
|
|
const successMessage = translate('modals.model.notes.saved', {}, 'Notes saved successfully');
|
|
showToast(successMessage, 'success');
|
|
} catch (error) {
|
|
const errorMessage = translate('modals.model.notes.saveFailed', {}, 'Failed to save notes');
|
|
showToast(errorMessage, 'error');
|
|
}
|
|
}
|
|
|
|
// Export the model modal API
|
|
const modelModal = {
|
|
show: showModelModal,
|
|
toggleShowcase,
|
|
scrollToTop
|
|
};
|
|
|
|
export { modelModal }; |