mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 16:31:28 -03:00
2bfb987312
- Extract a shared BaseModelPicker (search, keyboard navigation, filename-based suggestions, dynamic API models such as MiniMax H3 under 'Other (API)') used by both the single-model metadata modal and the bulk base model modal - Rework the bulk base model modal into a dedicated inline-list layout: fixed modal size, sticky-free footer with app-standard modal-actions/primary-btn/cancel-btn buttons, and an inline option list that scrolls itself instead of an overlay dropdown covering the footer - Selecting an option in change mode now filters the list to the selection instead of resetting and scroll-jumping to it - Restore opaque sticky section headers in the bulk modal so scrolled items no longer bleed through
604 lines
21 KiB
JavaScript
604 lines
21 KiB
JavaScript
/**
|
|
* ModelMetadata.js
|
|
* Handles model metadata editing functionality - General version
|
|
*/
|
|
|
|
import { showToast } from '../../utils/uiHelpers.js';
|
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { inferBaseModelsFromFilename, createBaseModelPicker } from './BaseModelPicker.js';
|
|
|
|
/**
|
|
* Resolve the active file path for the currently open model modal.
|
|
* Falls back to the provided value when DOM state has not been initialised yet.
|
|
* @param {string} fallback - Optional fallback path
|
|
* @returns {string}
|
|
*/
|
|
function getActiveModalFilePath(fallback = '') {
|
|
const modalElement = document.getElementById('modelModal');
|
|
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
|
return modalElement.dataset.filePath;
|
|
}
|
|
|
|
const fileNameContent = document.querySelector('.file-name-content');
|
|
if (fileNameContent && fileNameContent.dataset && fileNameContent.dataset.filePath) {
|
|
return fileNameContent.dataset.filePath;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Update all modal controls that cache the current model file path.
|
|
* Keeps metadata interactions in sync after renames or moves.
|
|
* @param {string} newFilePath - Updated model file path
|
|
*/
|
|
function updateModalFilePathReferences(newFilePath) {
|
|
if (!newFilePath) {
|
|
return;
|
|
}
|
|
|
|
const modalElement = document.getElementById('modelModal');
|
|
if (!modalElement) {
|
|
return;
|
|
}
|
|
|
|
modalElement.dataset.filePath = newFilePath;
|
|
modalElement.setAttribute('data-file-path', newFilePath);
|
|
|
|
const scopedQuery = (selector) => modalElement.querySelector(selector);
|
|
const scopedQueryAll = (selector) => modalElement.querySelectorAll(selector);
|
|
|
|
const modelNameContent = scopedQuery('.model-name-content');
|
|
if (modelNameContent && modelNameContent.dataset) {
|
|
modelNameContent.dataset.filePath = newFilePath;
|
|
modelNameContent.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
const baseModelContent = scopedQuery('.base-model-content');
|
|
if (baseModelContent && baseModelContent.dataset) {
|
|
baseModelContent.dataset.filePath = newFilePath;
|
|
baseModelContent.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
const fileNameContent = scopedQuery('.file-name-content');
|
|
if (fileNameContent && fileNameContent.dataset) {
|
|
fileNameContent.dataset.filePath = newFilePath;
|
|
fileNameContent.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
const versionNameContent = scopedQuery('.version-name-content');
|
|
if (versionNameContent && versionNameContent.dataset) {
|
|
versionNameContent.dataset.filePath = newFilePath;
|
|
versionNameContent.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
const editTagsBtn = scopedQuery('.edit-tags-btn');
|
|
if (editTagsBtn) {
|
|
editTagsBtn.dataset.filePath = newFilePath;
|
|
editTagsBtn.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
const editTriggerWordsBtn = scopedQuery('.edit-trigger-words-btn');
|
|
if (editTriggerWordsBtn) {
|
|
editTriggerWordsBtn.dataset.filePath = newFilePath;
|
|
editTriggerWordsBtn.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
scopedQueryAll('[data-action="open-file-location"]').forEach((el) => {
|
|
el.dataset.filepath = newFilePath;
|
|
el.setAttribute('data-filepath', newFilePath);
|
|
});
|
|
|
|
scopedQueryAll('[data-file-path]').forEach((el) => {
|
|
el.dataset.filePath = newFilePath;
|
|
el.setAttribute('data-file-path', newFilePath);
|
|
});
|
|
|
|
scopedQueryAll('[data-filepath]').forEach((el) => {
|
|
el.dataset.filepath = newFilePath;
|
|
el.setAttribute('data-filepath', newFilePath);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up model name editing functionality
|
|
* @param {string} filePath - File path
|
|
*/
|
|
export function setupModelNameEditing(filePath) {
|
|
const modelNameContent = document.querySelector('.model-name-content');
|
|
const editBtn = document.querySelector('.edit-model-name-btn');
|
|
|
|
if (!modelNameContent || !editBtn) return;
|
|
|
|
// Store the file path in a data attribute for later use
|
|
modelNameContent.dataset.filePath = filePath;
|
|
|
|
// Show edit button on hover
|
|
const modelNameHeader = document.querySelector('.model-name-header');
|
|
modelNameHeader.addEventListener('mouseenter', () => {
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
modelNameHeader.addEventListener('mouseleave', () => {
|
|
if (!modelNameHeader.classList.contains('editing')) {
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
// Handle edit button click
|
|
editBtn.addEventListener('click', () => {
|
|
modelNameHeader.classList.add('editing');
|
|
modelNameContent.setAttribute('contenteditable', 'true');
|
|
// Store original value for comparison later
|
|
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
|
|
modelNameContent.focus();
|
|
|
|
// Place cursor at the end
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
if (modelNameContent.childNodes.length > 0) {
|
|
range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
// Handle keyboard events in edit mode
|
|
modelNameContent.addEventListener('keydown', function(e) {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
this.blur(); // Trigger save on Enter
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
// Restore original value
|
|
this.textContent = this.dataset.originalValue;
|
|
exitEditMode();
|
|
}
|
|
});
|
|
|
|
// Limit model name length
|
|
modelNameContent.addEventListener('input', function() {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
// Limit model name length
|
|
if (this.textContent.length > 100) {
|
|
this.textContent = this.textContent.substring(0, 100);
|
|
// Place cursor at the end
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
range.setStart(this.childNodes[0], 100);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
|
|
showToast('toast.models.nameTooLong', {}, 'warning');
|
|
}
|
|
});
|
|
|
|
// Handle focus out - save changes
|
|
modelNameContent.addEventListener('blur', async function() {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
const newModelName = this.textContent.trim();
|
|
const originalValue = this.dataset.originalValue;
|
|
|
|
// Basic validation
|
|
if (!newModelName) {
|
|
// Restore original value if empty
|
|
this.textContent = originalValue;
|
|
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
if (newModelName === originalValue) {
|
|
// No changes, just exit edit mode
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Resolve current file path from modal state
|
|
const filePath = getActiveModalFilePath(this.dataset.filePath);
|
|
|
|
await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
|
|
|
|
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
|
|
} catch (error) {
|
|
console.error('Error updating model name:', error);
|
|
this.textContent = originalValue; // Restore original model name
|
|
showToast('toast.models.nameUpdateFailed', {}, 'error');
|
|
} finally {
|
|
exitEditMode();
|
|
}
|
|
});
|
|
|
|
function exitEditMode() {
|
|
modelNameContent.removeAttribute('contenteditable');
|
|
modelNameHeader.classList.remove('editing');
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up base model editing functionality with searchable dropdown
|
|
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
|
|
* and allows typing custom values.
|
|
* @param {string} filePath - File path
|
|
*/
|
|
export function setupBaseModelEditing(filePath) {
|
|
const baseModelContent = document.querySelector('.base-model-content');
|
|
const editBtn = document.querySelector('.edit-base-model-btn');
|
|
|
|
if (!baseModelContent || !editBtn) return;
|
|
|
|
// Store the file path in a data attribute for later use
|
|
baseModelContent.dataset.filePath = filePath;
|
|
|
|
// Show edit button on hover
|
|
const baseModelDisplay = document.querySelector('.base-model-display');
|
|
baseModelDisplay.addEventListener('mouseenter', () => {
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
baseModelDisplay.addEventListener('mouseleave', () => {
|
|
if (!baseModelDisplay.classList.contains('editing')) {
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
// Handle edit button click
|
|
editBtn.addEventListener('click', () => {
|
|
baseModelDisplay.classList.add('editing');
|
|
|
|
// Store the original value to check for changes later
|
|
const originalValue = baseModelContent.textContent.trim();
|
|
|
|
// Filename-based inference for the Suggested section
|
|
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
|
|
(document.querySelector('.model-name-content')?.textContent || '');
|
|
const inferredModels = inferBaseModelsFromFilename(fileName);
|
|
|
|
const saveIfChanged = () => {
|
|
const finalValue = baseModelContent.textContent.trim();
|
|
if (finalValue !== originalValue) {
|
|
saveBaseModel(
|
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
|
originalValue
|
|
);
|
|
}
|
|
};
|
|
|
|
const picker = createBaseModelPicker({
|
|
suggestions: inferredModels,
|
|
initialValue: originalValue,
|
|
mode: 'commit',
|
|
onCommit: (value) => {
|
|
baseModelContent.textContent = value;
|
|
cleanup();
|
|
saveIfChanged();
|
|
},
|
|
onDismiss: () => {
|
|
// Escape or empty outside click: restore the original value
|
|
baseModelContent.textContent = originalValue;
|
|
cleanup();
|
|
},
|
|
});
|
|
|
|
function cleanup() {
|
|
picker.destroy();
|
|
if (picker.element.parentNode === baseModelDisplay) {
|
|
baseModelDisplay.removeChild(picker.element);
|
|
}
|
|
baseModelContent.style.display = '';
|
|
editBtn.style.display = '';
|
|
baseModelDisplay.classList.remove('editing');
|
|
}
|
|
|
|
// Replace content with search widget
|
|
baseModelContent.style.display = 'none';
|
|
editBtn.style.display = 'none';
|
|
baseModelDisplay.insertBefore(picker.element, editBtn);
|
|
const searchInput = picker.element.querySelector('.base-model-search-input');
|
|
if (searchInput) searchInput.focus();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save base model
|
|
* @param {string} filePath - File path
|
|
* @param {string} originalValue - Original value (for comparison)
|
|
*/
|
|
async function saveBaseModel(filePath, originalValue) {
|
|
const baseModelElement = document.querySelector('.base-model-content');
|
|
const newBaseModel = baseModelElement.textContent.trim();
|
|
|
|
// Only save if the value has actually changed
|
|
if (newBaseModel === originalValue) {
|
|
return; // No change, no need to save
|
|
}
|
|
|
|
const resolvedPath = getActiveModalFilePath(filePath);
|
|
if (!resolvedPath) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await getModelApiClient().saveModelMetadata(resolvedPath, { base_model: newBaseModel });
|
|
|
|
showToast('toast.models.baseModelUpdated', {}, 'success');
|
|
} catch (error) {
|
|
showToast('toast.models.baseModelUpdateFailed', {}, 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up file name editing functionality
|
|
* @param {string} filePath - File path
|
|
*/
|
|
export function setupFileNameEditing(filePath) {
|
|
const fileNameContent = document.querySelector('.file-name-content');
|
|
const editBtn = document.querySelector('.edit-file-name-btn');
|
|
|
|
if (!fileNameContent || !editBtn) return;
|
|
|
|
// Store the original file path
|
|
fileNameContent.dataset.filePath = filePath;
|
|
|
|
// Show edit button on hover
|
|
const fileNameWrapper = document.querySelector('.file-name-wrapper');
|
|
fileNameWrapper.addEventListener('mouseenter', () => {
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
fileNameWrapper.addEventListener('mouseleave', () => {
|
|
if (!fileNameWrapper.classList.contains('editing')) {
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
// Handle edit button click
|
|
editBtn.addEventListener('click', () => {
|
|
fileNameWrapper.classList.add('editing');
|
|
fileNameContent.setAttribute('contenteditable', 'true');
|
|
fileNameContent.focus();
|
|
|
|
// Store original value for comparison later
|
|
fileNameContent.dataset.originalValue = fileNameContent.textContent.trim();
|
|
|
|
// Place cursor at the end
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
range.selectNodeContents(fileNameContent);
|
|
range.collapse(false);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
// Handle keyboard events in edit mode
|
|
fileNameContent.addEventListener('keydown', function(e) {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
this.blur(); // Trigger save on Enter
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
// Restore original value
|
|
this.textContent = this.dataset.originalValue;
|
|
exitEditMode();
|
|
}
|
|
});
|
|
|
|
// Handle input validation
|
|
fileNameContent.addEventListener('input', function() {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
// Replace invalid characters for filenames
|
|
const invalidChars = /[\\/:*?"<>|]/g;
|
|
if (invalidChars.test(this.textContent)) {
|
|
const cursorPos = window.getSelection().getRangeAt(0).startOffset;
|
|
this.textContent = this.textContent.replace(invalidChars, '');
|
|
|
|
// Restore cursor position
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
const newPos = Math.min(cursorPos, this.textContent.length);
|
|
|
|
if (this.firstChild) {
|
|
range.setStart(this.firstChild, newPos);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
showToast('toast.models.invalidCharactersRemoved', {}, 'warning');
|
|
}
|
|
});
|
|
|
|
// Handle focus out - save changes
|
|
fileNameContent.addEventListener('blur', async function() {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
const newFileName = this.textContent.trim();
|
|
const originalValue = this.dataset.originalValue;
|
|
|
|
// Basic validation
|
|
if (!newFileName) {
|
|
// Restore original value if empty
|
|
this.textContent = originalValue;
|
|
showToast('toast.models.filenameCannotBeEmpty', {}, 'error');
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
if (newFileName === originalValue) {
|
|
// No changes, just exit edit mode
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentFilePath = getActiveModalFilePath(this.dataset.filePath);
|
|
const result = await getModelApiClient().renameModelFile(currentFilePath, newFileName);
|
|
|
|
if (result && result.success && result.new_file_path) {
|
|
const newFilePath = result.new_file_path;
|
|
this.dataset.filePath = newFilePath;
|
|
this.setAttribute('data-file-path', newFilePath);
|
|
|
|
const modalElement = document.getElementById('modelModal');
|
|
if (modalElement) {
|
|
modalElement.dataset.filePath = newFilePath;
|
|
modalElement.setAttribute('data-file-path', newFilePath);
|
|
}
|
|
|
|
updateModalFilePathReferences(newFilePath);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error renaming file:', error);
|
|
this.textContent = originalValue; // Restore original file name
|
|
showToast('toast.models.renameFailed', { message: error.message }, 'error');
|
|
} finally {
|
|
exitEditMode();
|
|
}
|
|
});
|
|
|
|
function exitEditMode() {
|
|
fileNameContent.removeAttribute('contenteditable');
|
|
fileNameWrapper.classList.remove('editing');
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up version name editing functionality
|
|
* @param {string} filePath - File path
|
|
*/
|
|
export function setupVersionNameEditing(filePath) {
|
|
const versionNameContent = document.querySelector('.version-name-content');
|
|
const editBtn = document.querySelector('.edit-version-name-btn');
|
|
|
|
if (!versionNameContent || !editBtn) return;
|
|
|
|
// Store the file path in a data attribute for later use
|
|
versionNameContent.dataset.filePath = filePath;
|
|
|
|
// Show edit button on hover
|
|
const versionNameWrapper = document.querySelector('.version-name-wrapper');
|
|
versionNameWrapper.addEventListener('mouseenter', () => {
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
versionNameWrapper.addEventListener('mouseleave', () => {
|
|
if (!versionNameWrapper.classList.contains('editing')) {
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
// Handle edit button click
|
|
editBtn.addEventListener('click', () => {
|
|
versionNameWrapper.classList.add('editing');
|
|
versionNameContent.setAttribute('contenteditable', 'true');
|
|
// Store original value for comparison later
|
|
versionNameContent.dataset.originalValue = versionNameContent.textContent.trim();
|
|
versionNameContent.focus();
|
|
|
|
// Place cursor at the end
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
if (versionNameContent.childNodes.length > 0) {
|
|
range.setStart(versionNameContent.childNodes[0], versionNameContent.textContent.length);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
editBtn.classList.add('visible');
|
|
});
|
|
|
|
// Handle keyboard events in edit mode
|
|
versionNameContent.addEventListener('keydown', function(e) {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
this.blur(); // Trigger save on Enter
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
// Restore original value
|
|
this.textContent = this.dataset.originalValue;
|
|
exitEditMode();
|
|
}
|
|
});
|
|
|
|
// Limit version name length
|
|
versionNameContent.addEventListener('input', function() {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
if (this.textContent.length > 100) {
|
|
this.textContent = this.textContent.substring(0, 100);
|
|
// Place cursor at the end
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
range.setStart(this.childNodes[0], 100);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
|
|
showToast('toast.models.nameTooLong', {}, 'warning');
|
|
}
|
|
});
|
|
|
|
// Handle focus out - save changes
|
|
versionNameContent.addEventListener('blur', async function() {
|
|
if (!this.getAttribute('contenteditable')) return;
|
|
|
|
const newVersionName = this.textContent.trim();
|
|
const originalValue = this.dataset.originalValue;
|
|
|
|
// Basic validation
|
|
if (!newVersionName) {
|
|
// Restore original value if empty
|
|
this.textContent = originalValue;
|
|
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
if (newVersionName === originalValue) {
|
|
// No changes, just exit edit mode
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Resolve current file path from modal state
|
|
const filePath = getActiveModalFilePath(this.dataset.filePath);
|
|
|
|
await getModelApiClient().saveModelMetadata(filePath, { civitai: { name: newVersionName } });
|
|
|
|
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
|
|
} catch (error) {
|
|
console.error('Error updating version name:', error);
|
|
this.textContent = originalValue; // Restore original version name
|
|
showToast('toast.models.nameUpdateFailed', {}, 'error');
|
|
} finally {
|
|
exitEditMode();
|
|
}
|
|
});
|
|
|
|
function exitEditMode() {
|
|
versionNameContent.removeAttribute('contenteditable');
|
|
versionNameWrapper.classList.remove('editing');
|
|
editBtn.classList.remove('visible');
|
|
}
|
|
}
|