feat: Move LoRA related components to shared directory for consistency

- Added PresetTags.js to handle LoRA model preset parameter tags.
- Introduced RecipeTab.js for managing recipes associated with LoRA models.
- Created TriggerWords.js to manage trigger word functionality for LoRA models.
- Implemented utility functions in utils.js for general model modal operations.
This commit is contained in:
Will Miao
2025-07-22 16:00:04 +08:00
parent 67b403f8ca
commit fcfc868e57
26 changed files with 1240 additions and 2673 deletions

View File

@@ -1,102 +0,0 @@
/**
* ModelDescription.js
* 处理LoRA模型描述相关的功能模块
*/
import { showToast } from '../../utils/uiHelpers.js';
/**
* 设置标签页切换功能
*/
export function setupTabSwitching() {
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Remove active class from all tabs
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
btn.classList.remove('active')
);
document.querySelectorAll('.tab-content .tab-pane').forEach(tab =>
tab.classList.remove('active')
);
// Add active class to clicked tab
button.classList.add('active');
const tabId = `${button.dataset.tab}-tab`;
document.getElementById(tabId).classList.add('active');
// If switching to description tab, make sure content is properly sized
if (button.dataset.tab === 'description') {
const descriptionContent = document.querySelector('.model-description-content');
if (descriptionContent) {
const hasContent = descriptionContent.innerHTML.trim() !== '';
document.querySelector('.model-description-loading')?.classList.add('hidden');
// If no content, show a message
if (!hasContent) {
descriptionContent.innerHTML = '<div class="no-description">No model description available</div>';
descriptionContent.classList.remove('hidden');
}
}
}
});
});
}
/**
* 加载模型描述
* @param {string} modelId - 模型ID
* @param {string} filePath - 文件路径
*/
export async function loadModelDescription(modelId, filePath) {
try {
const descriptionContainer = document.querySelector('.model-description-content');
const loadingElement = document.querySelector('.model-description-loading');
if (!descriptionContainer || !loadingElement) return;
// Show loading indicator
loadingElement.classList.remove('hidden');
descriptionContainer.classList.add('hidden');
// Try to get model description from API
const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`Failed to fetch model description: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.description) {
// Update the description content
descriptionContainer.innerHTML = data.description;
// Process any links in the description to open in new tab
const links = descriptionContainer.querySelectorAll('a');
links.forEach(link => {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
});
// Show the description and hide loading indicator
descriptionContainer.classList.remove('hidden');
loadingElement.classList.add('hidden');
} else {
throw new Error(data.error || 'No description available');
}
} catch (error) {
console.error('Error loading model description:', error);
const loadingElement = document.querySelector('.model-description-loading');
if (loadingElement) {
loadingElement.innerHTML = `<div class="error-message">Failed to load model description. ${error.message}</div>`;
}
// Show empty state message in the description container
const descriptionContainer = document.querySelector('.model-description-content');
if (descriptionContainer) {
descriptionContainer.innerHTML = '<div class="no-description">No model description available</div>';
descriptionContainer.classList.remove('hidden');
}
}
}

View File

@@ -1,442 +0,0 @@
/**
* ModelMetadata.js
* 处理LoRA模型元数据编辑相关的功能模块
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js';
/**
* 设置模型名称编辑功能
* @param {string} filePath - 文件路径
*/
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('Model name is limited to 100 characters', '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('Model name cannot be empty', 'error');
exitEditMode();
return;
}
if (newModelName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Get the file path from the dataset
const filePath = this.dataset.filePath;
await saveModelMetadata(filePath, { model_name: newModelName });
showToast('Model name updated successfully', 'success');
} catch (error) {
console.error('Error updating model name:', error);
this.textContent = originalValue; // Restore original model name
showToast('Failed to update model name', 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
modelNameContent.removeAttribute('contenteditable');
modelNameHeader.classList.remove('editing');
editBtn.classList.remove('visible');
}
}
/**
* 设置基础模型编辑功能
* @param {string} filePath - 文件路径
*/
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();
// Create dropdown selector to replace the base model content
const currentValue = originalValue;
const dropdown = document.createElement('select');
dropdown.className = 'base-model-selector';
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODELS constants
const baseModelCategories = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.UNKNOWN
]
};
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {
const group = document.createElement('optgroup');
group.label = category;
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
option.selected = model === currentValue;
group.appendChild(option);
});
dropdown.appendChild(group);
});
// Replace content with dropdown
baseModelContent.style.display = 'none';
baseModelDisplay.insertBefore(dropdown, editBtn);
// Hide edit button during editing
editBtn.style.display = 'none';
// Focus the dropdown
dropdown.focus();
// Handle dropdown change
dropdown.addEventListener('change', function() {
const selectedModel = this.value;
baseModelContent.textContent = selectedModel;
// Mark that a change was made if the value differs from original
if (selectedModel !== originalValue) {
valueChanged = true;
} else {
valueChanged = false;
}
});
// Function to save changes and exit edit mode
const saveAndExit = function() {
// Check if dropdown still exists and remove it
if (dropdown && dropdown.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(dropdown);
}
// Show the content and edit button
baseModelContent.style.display = '';
editBtn.style.display = '';
// Remove editing class
baseModelDisplay.classList.remove('editing');
// Only save if the value has actually changed
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
// Get file path from the dataset
const filePath = baseModelContent.dataset.filePath;
// Save the changes, passing the original value for comparison
saveBaseModel(filePath, originalValue);
}
// Remove this event listener
document.removeEventListener('click', outsideClickHandler);
};
// Handle outside clicks to save and exit
const outsideClickHandler = function(e) {
// If click is outside the dropdown and base model display
if (!baseModelDisplay.contains(e.target)) {
saveAndExit();
}
};
// Add delayed event listener for outside clicks
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
}, 0);
// Also handle dropdown blur event
dropdown.addEventListener('blur', function(e) {
// Only save if the related target is not the edit button or inside the baseModelDisplay
if (!baseModelDisplay.contains(e.relatedTarget)) {
saveAndExit();
}
});
});
}
/**
* 保存基础模型
* @param {string} filePath - 文件路径
* @param {string} originalValue - 原始值(用于比较)
*/
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
}
try {
await saveModelMetadata(filePath, { base_model: newBaseModel });
showToast('Base model updated successfully', 'success');
} catch (error) {
showToast('Failed to update base model', 'error');
}
}
/**
* 设置文件名编辑功能
* @param {string} filePath - 文件路径
*/
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('Invalid characters removed from filename', '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('File name cannot be empty', 'error');
exitEditMode();
return;
}
if (newFileName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Get the file path from the dataset
const filePath = this.dataset.filePath;
// Call API to rename the file using the new function from loraApi.js
const result = await renameLoraFile(filePath, newFileName);
if (result.success) {
showToast('File name updated successfully', 'success');
// Get the new file path and update the card
const newFilePath = filePath.replace(originalValue, newFileName);
;
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
} else {
throw new Error(result.error || 'Unknown error');
}
} catch (error) {
console.error('Error renaming file:', error);
this.textContent = originalValue; // Restore original file name
showToast(`Failed to rename file: ${error.message}`, 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
fileNameContent.removeAttribute('contenteditable');
fileNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible');
}
}

View File

@@ -1,471 +0,0 @@
/**
* ModelTags.js
* Module for handling model tag editing functionality
*/
import { showToast } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';
// Preset tag suggestions
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
// Create a named function so we can remove it later
let saveTagsHandler = null;
/**
* Set up tag editing mode
*/
export function setupTagEditMode() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
// Store original tags for restoring on cancel
let originalTags = [];
// Remove any previously attached click handler
if (editBtn._hasClickHandler) {
editBtn.removeEventListener('click', editBtn._clickHandler);
}
// Create new handler and store reference
const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container');
const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact');
const tagsEditContainer = tagsSection.querySelector('.metadata-edit-container');
if (isEditMode) {
// Enter edit mode
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing";
// Get all tags from tooltip, not just the visible ones in compact display
originalTags = Array.from(
tagsSection.querySelectorAll('.tooltip-tag')
).map(tag => tag.textContent);
// Hide compact display, show edit container
compactTagsDisplay.style.display = 'none';
// If edit container doesn't exist yet, create it
if (!tagsEditContainer) {
const editContainer = document.createElement('div');
editContainer.className = 'metadata-edit-container';
// Move the edit button inside the container header for better visibility
const editBtnClone = editBtn.cloneNode(true);
editBtnClone.classList.add('metadata-header-btn');
// Create edit UI with edit button in the header
editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML);
tagsSection.appendChild(editContainer);
// Setup the tag input field behavior
setupTagInput();
// Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
// Setup delete buttons for existing tags
setupDeleteButtons();
// Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
if (newEditBtn) {
newEditBtn.addEventListener('click', function() {
editBtn.click();
});
}
// Hide the original button when in edit mode
editBtn.style.display = 'none';
} else {
// Just show the existing edit container
tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none';
}
} else {
// Exit edit mode
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit tags";
editBtn.style.display = 'block';
// Show compact display, hide edit container
compactTagsDisplay.style.display = 'flex';
if (tagsEditContainer) tagsEditContainer.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original tags
restoreOriginalTags(tagsSection, originalTags);
} else {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
}
};
// Store the handler reference on the button itself
editBtn._clickHandler = editBtnClickHandler;
editBtn._hasClickHandler = true;
editBtn.addEventListener('click', editBtnClickHandler);
// Clean up any previous document click handler
if (saveTagsHandler) {
document.removeEventListener('click', saveTagsHandler);
}
// Create new save handler and store reference
saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) {
saveTags();
}
};
// Add the new handler
document.addEventListener('click', saveTagsHandler);
}
/**
* Create the tag editing UI
* @param {Array} currentTags - Current tags
* @param {string} editBtnHTML - HTML for the edit button to include in header
* @returns {string} HTML markup for tag editing UI
*/
function createTagEditUI(currentTags, editBtnHTML = '') {
return `
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>Edit Tags</label>
${editBtnHTML}
</div>
<div class="metadata-items">
${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}">
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
<div class="metadata-edit-controls">
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* Create suggestions dropdown with preset tags
* @param {Array} existingTags - Already added tags
* @returns {HTMLElement} - Dropdown element
*/
function createSuggestionsDropdown(existingTags = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
dropdown.appendChild(header);
// Create tag container
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each preset tag as a suggestion
PRESET_TAGS.forEach(tag => {
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
item.innerHTML = `
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
addNewTag(tag);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateSuggestionsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
/**
* Set up tag input behavior
*/
function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
if (tagInput) {
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag(this.value);
this.value = ''; // Clear input after adding
}
});
}
}
/**
* Set up delete buttons for tags
*/
function setupDeleteButtons() {
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.metadata-item');
tag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
});
}
/**
* Add a new tag
* @param {string} tag - Tag to add
*/
function addNewTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items');
if (!tagsContainer) return;
// Validation: Check length
if (tag.length > 30) {
showToast('Tag should not exceed 30 characters', 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('Maximum 30 tags allowed', 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
if (existingTags.includes(tag)) {
showToast('This tag already exists', 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
newTag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
tagsContainer.appendChild(newTag);
// Update status of items in the suggestions dropdown
updateSuggestionsDropdown();
}
/**
* Update status of items in the suggestions dropdown
*/
function updateSuggestionsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current tags
const currentTags = document.querySelectorAll('.metadata-item');
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const tagText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingTags.includes(tagText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
item.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const tag = item.querySelector('.metadata-suggestion-text').textContent;
addNewTag(tag);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus the input
if (input) input.focus();
};
}
}
});
}
/**
* Restore original tags when canceling edit
* @param {HTMLElement} section - The tags section
* @param {Array} originalTags - Original tags array
*/
function restoreOriginalTags(section, originalTags) {
// Nothing to do here as we're just hiding the edit UI
// and showing the original compact tags which weren't modified
}
/**
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
if (!tagsChanged) {
// No changes made, just exit edit mode without API call
editBtn.dataset.skipRestore = "true";
editBtn.click();
return;
}
try {
// Save tags metadata
await saveModelMetadata(filePath, { tags: tags });
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
}
}

View File

@@ -1,66 +0,0 @@
/**
* PresetTags.js
* 处理LoRA模型预设参数标签相关的功能模块
*/
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* 解析预设参数
* @param {string} usageTips - 包含预设参数的JSON字符串
* @returns {Object} 解析后的预设参数对象
*/
export function parsePresets(usageTips) {
if (!usageTips) return {};
try {
return JSON.parse(usageTips);
} catch {
return {};
}
}
/**
* 渲染预设标签
* @param {Object} presets - 预设参数对象
* @returns {string} HTML内容
*/
export function renderPresetTags(presets) {
return Object.entries(presets).map(([key, value]) => `
<div class="preset-tag" data-key="${key}">
<span>${formatPresetKey(key)}: ${value}</span>
<i class="fas fa-times" onclick="removePreset('${key}')"></i>
</div>
`).join('');
}
/**
* 格式化预设键名
* @param {string} key - 预设键名
* @returns {string} 格式化后的键名
*/
function formatPresetKey(key) {
return key.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
/**
* 移除预设参数
* @param {string} key - 要移除的预设键名
*/
window.removePreset = async function(key) {
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
delete currentPresets[key];
const newPresetsJson = JSON.stringify(currentPresets);
await saveModelMetadata(filePath, {
usage_tips: newPresetsJson
});
document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets);
};

View File

@@ -1,228 +0,0 @@
/**
* RecipeTab - Handles the recipes tab in the Lora Modal
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
/**
* Loads recipes that use the specified Lora and renders them in the tab
* @param {string} loraName - The display name of the Lora
* @param {string} sha256 - The SHA256 hash of the Lora
*/
export function loadRecipesForLora(loraName, sha256) {
const recipeTab = document.getElementById('recipes-tab');
if (!recipeTab) return;
// Show loading state
recipeTab.innerHTML = `
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
`;
// Fetch recipes that use this Lora by hash
fetch(`/api/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
.then(response => response.json())
.then(data => {
if (!data.success) {
throw new Error(data.error || 'Failed to load recipes');
}
renderRecipes(recipeTab, data.recipes, loraName, sha256);
})
.catch(error => {
console.error('Error loading recipes for Lora:', error);
recipeTab.innerHTML = `
<div class="recipes-error">
<i class="fas fa-exclamation-circle"></i>
<p>Failed to load recipes. Please try again later.</p>
</div>
`;
});
}
/**
* Renders the recipe cards in the tab
* @param {HTMLElement} tabElement - The tab element to render into
* @param {Array} recipes - Array of recipe objects
* @param {string} loraName - The display name of the Lora
* @param {string} loraHash - The hash of the Lora
*/
function renderRecipes(tabElement, recipes, loraName, loraHash) {
if (!recipes || recipes.length === 0) {
tabElement.innerHTML = `
<div class="recipes-empty">
<i class="fas fa-book-open"></i>
<p>No recipes found that use this Lora.</p>
</div>
`;
return;
}
// Create header with count and view all button
const headerElement = document.createElement('div');
headerElement.className = 'recipes-header';
headerElement.innerHTML = `
<h3>Found ${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora</h3>
<button class="view-all-btn" title="View all in Recipes page">
<i class="fas fa-external-link-alt"></i> View All in Recipes
</button>
`;
// Add click handler for "View All" button
headerElement.querySelector('.view-all-btn').addEventListener('click', () => {
navigateToRecipesPage(loraName, loraHash);
});
// Create grid container for recipe cards
const cardGrid = document.createElement('div');
cardGrid.className = 'card-grid';
// Create recipe cards matching the structure in recipes.html
recipes.forEach(recipe => {
// Get basic info
const baseModel = recipe.base_model || '';
const loras = recipe.loras || [];
const lorasCount = loras.length;
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
// Ensure file_url exists, fallback to file_path if needed
const imageUrl = recipe.file_url ||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
// Create card element matching the structure in recipes.html
const card = document.createElement('div');
card.className = 'lora-card';
card.dataset.filePath = recipe.file_path || '';
card.dataset.title = recipe.title || '';
card.dataset.created = recipe.created_date || '';
card.dataset.id = recipe.id || '';
card.innerHTML = `
<div class="card-preview">
<img src="${imageUrl}" alt="${recipe.title}" loading="lazy">
<div class="card-header">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
<div class="card-actions">
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
</div>
</div>
<div class="card-footer">
<div class="model-info">
<span class="model-name">${recipe.title}</span>
</div>
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
title="${getLoraStatusTitle(lorasCount, missingLorasCount)}">
<i class="fas fa-layer-group"></i> ${lorasCount}
</div>
</div>
</div>
`;
// Add event listeners for action buttons
card.querySelector('.fa-copy').addEventListener('click', (e) => {
e.stopPropagation();
copyRecipeSyntax(recipe.id);
});
// Add click handler for the entire card
card.addEventListener('click', () => {
navigateToRecipeDetails(recipe.id);
});
// Add card to grid
cardGrid.appendChild(card);
});
// Clear loading indicator and append content
tabElement.innerHTML = '';
tabElement.appendChild(headerElement);
tabElement.appendChild(cardGrid);
}
/**
* Returns a descriptive title for the LoRA status indicator
* @param {number} totalCount - Total number of LoRAs in recipe
* @param {number} missingCount - Number of missing LoRAs
* @returns {string} Status title text
*/
function getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`;
}
/**
* Copies recipe syntax to clipboard
* @param {string} recipeId - The recipe ID
*/
function copyRecipeSyntax(recipeId) {
if (!recipeId) {
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
return;
}
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to copy: ', err);
showToast('Failed to copy recipe syntax', 'error');
});
}
/**
* Navigates to the recipes page with filter for the current Lora
* @param {string} loraName - The Lora display name to filter by
* @param {string} loraHash - The hash of the Lora to filter by
* @param {boolean} createNew - Whether to open the create recipe dialog
*/
function navigateToRecipesPage(loraName, loraHash) {
// Close the current modal
if (window.modalManager) {
modalManager.closeModal('loraModal');
}
// Clear any previous filters first
removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('viewRecipeId');
// Store the LoRA name and hash filter in sessionStorage
setSessionItem('lora_to_recipe_filterLoraName', loraName);
setSessionItem('lora_to_recipe_filterLoraHash', loraHash);
// Directly navigate to recipes page
window.location.href = '/loras/recipes';
}
/**
* Navigates directly to a specific recipe's details
* @param {string} recipeId - The recipe ID to view
*/
function navigateToRecipeDetails(recipeId) {
// Close the current modal
if (window.modalManager) {
modalManager.closeModal('loraModal');
}
// Clear any previous filters first
removeSessionItem('filterLoraName');
removeSessionItem('filterLoraHash');
removeSessionItem('viewRecipeId');
// Store the recipe ID in sessionStorage to load on recipes page
setSessionItem('viewRecipeId', recipeId);
// Directly navigate to recipes page
window.location.href = '/loras/recipes';
}

View File

@@ -1,648 +0,0 @@
/**
* TriggerWords.js
* Module that handles trigger word functionality for LoRA models
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* Fetch trained words for a model
* @param {string} filePath - Path to the model file
* @returns {Promise<Object>} - Object with trained words and class tokens
*/
async function fetchTrainedWords(filePath) {
try {
const response = await fetch(`/api/trained-words?file_path=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.success) {
return {
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
classTokens: data.class_tokens // Can be null or a string
};
} else {
throw new Error(data.error || 'Failed to fetch trained words');
}
} catch (error) {
console.error('Error fetching trained words:', error);
showToast('Could not load trained words', 'error');
return { trainedWords: [], classTokens: null };
}
}
/**
* Create suggestion dropdown with trained words as tags
* @param {Array} trainedWords - Array of [word, frequency] pairs
* @param {string|null} classTokens - Class tokens from training
* @param {Array} existingWords - Already added trigger words
* @returns {HTMLElement} - Dropdown element
*/
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
// No suggestions case
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
header.innerHTML = '<span>No suggestions available</span>';
dropdown.appendChild(header);
dropdown.innerHTML += '<div class="no-suggestions">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
return dropdown;
}
// Sort trained words by frequency (highest first) if available
if (trainedWords && trainedWords.length > 0) {
trainedWords.sort((a, b) => b[1] - a[1]);
}
// Add class tokens section if available
if (classTokens) {
// Add class tokens header
const classTokensHeader = document.createElement('div');
classTokensHeader.className = 'metadata-suggestions-header';
classTokensHeader.innerHTML = `
<span>Class Token</span>
<small>Add to your prompt for best results</small>
`;
dropdown.appendChild(classTokensHeader);
// Add class tokens container
const classTokensContainer = document.createElement('div');
classTokensContainer.className = 'class-tokens-container';
// Create a special item for the class token
const tokenItem = document.createElement('div');
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `Class token: ${classTokens}`;
tokenItem.innerHTML = `
<span class="metadata-suggestion-text">${classTokens}</span>
<div class="metadata-suggestion-meta">
<span class="token-badge">Class Token</span>
${existingWords.includes(classTokens) ?
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
</div>
`;
// Add click handler if not already added
if (!existingWords.includes(classTokens)) {
tokenItem.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(classTokens);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = classTokens;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
classTokensContainer.appendChild(tokenItem);
dropdown.appendChild(classTokensContainer);
// Add separator if we also have trained words
if (trainedWords && trainedWords.length > 0) {
const separator = document.createElement('div');
separator.className = 'dropdown-separator';
dropdown.appendChild(separator);
}
}
// Add trained words header if we have any
if (trainedWords && trainedWords.length > 0) {
header.innerHTML = `
<span>Word Suggestions</span>
<small>${trainedWords.length} words found</small>
`;
dropdown.appendChild(header);
// Create tag container for trained words
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each trained word as a tag
trainedWords.forEach(([word, frequency]) => {
const isAdded = existingWords.includes(word);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = word; // Show full word on hover if truncated
item.innerHTML = `
<span class="metadata-suggestion-text">${word}</span>
<div class="metadata-suggestion-meta">
<span class="trained-word-freq">${frequency}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
</div>
`;
if (!isAdded) {
item.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(word);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
} else if (!classTokens) {
// If we have neither class tokens nor trained words
dropdown.innerHTML += '<div class="no-suggestions">No word suggestions found in this model. You can manually enter trigger words.</div>';
}
return dropdown;
}
/**
* Render trigger words
* @param {Array} words - Array of trigger words
* @param {string} filePath - File path
* @returns {string} HTML content
*/
export function renderTriggerWords(words, filePath) {
if (!words.length) return `
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
<label>Trigger Words</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="trigger-words-content">
<span class="no-trigger-words">No trigger word needed</span>
<div class="trigger-words-tags" style="display:none;"></div>
</div>
<div class="metadata-edit-controls" style="display:none;">
<button class="metadata-save-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="metadata-add-form" style="display:none;">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
return `
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
<label>Trigger Words</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="trigger-words-content">
<div class="trigger-words-tags">
${words.map(word => `
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
</div>
<div class="metadata-edit-controls" style="display:none;">
<button class="metadata-save-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="metadata-add-form" style="display:none;">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* Set up trigger words edit mode
*/
export function setupTriggerWordsEditMode() {
// Store trained words data
let trainedWordsList = [];
let classTokensValue = null;
let isTrainedWordsLoaded = false;
// Store original trigger words for restoring on cancel
let originalTriggerWords = [];
const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return;
editBtn.addEventListener('click', async function() {
const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (isEditMode) {
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing";
// Store original trigger words for potential restoration
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
// Show edit controls and input form
editControls.style.display = 'flex';
addForm.style.display = 'flex';
// If we have no trigger words yet, hide the "No trigger word needed" text
// and show the empty tags container
if (noTriggerWords) {
noTriggerWords.style.display = 'none';
if (tagsContainer) tagsContainer.style.display = 'flex';
}
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
if (copyIcon) copyIcon.style.display = 'none';
if (deleteBtn) {
deleteBtn.style.display = 'block';
// Re-attach event listener to ensure it works every time
// First remove any existing listeners to avoid duplication
deleteBtn.removeEventListener('click', deleteTriggerWord);
deleteBtn.addEventListener('click', deleteTriggerWord);
}
});
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'metadata-loading';
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
addForm.appendChild(loadingIndicator);
// Get currently added trigger words
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Asynchronously load trained words if not already loaded
if (!isTrainedWordsLoaded) {
const result = await fetchTrainedWords(filePath);
trainedWordsList = result.trainedWords;
classTokensValue = result.classTokens;
isTrainedWordsLoaded = true;
}
// Remove loading indicator
loadingIndicator.remove();
// Create and display suggestion dropdown
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
addForm.appendChild(dropdown);
// Focus the input
addForm.querySelector('input').focus();
} else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit trigger words";
// Hide edit controls and input form
editControls.style.display = 'none';
addForm.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original trigger words
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
} else {
// If saving, reset UI state on current trigger words
resetTriggerWordsUIState(triggerWordsSection);
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
// If we have no trigger words, show the "No trigger word needed" text
// and hide the empty tags container
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
if (noTriggerWords && currentTags.length === 0) {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
// Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
}
});
// Set up input for adding trigger words
const triggerWordInput = document.querySelector('.metadata-input');
if (triggerWordInput) {
// Add keydown event to input
triggerWordInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTriggerWord(this.value);
this.value = ''; // Clear input after adding
}
});
}
// Set up save button
const saveBtn = document.querySelector('.metadata-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', saveTriggerWords);
}
// Set up delete buttons
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
// Remove any existing listeners to avoid duplication
btn.removeEventListener('click', deleteTriggerWord);
btn.addEventListener('click', deleteTriggerWord);
});
}
/**
* Delete trigger word event handler
* @param {Event} e - Click event
*/
function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
tag.remove();
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
/**
* Reset UI state for trigger words after saving
* @param {HTMLElement} section - The trigger words section
*/
function resetTriggerWordsUIState(section) {
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(word);
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
if (deleteBtn) deleteBtn.style.display = 'none';
});
}
/**
* Restore original trigger words when canceling edit
* @param {HTMLElement} section - The trigger words section
* @param {Array} originalWords - Original trigger words
*/
function restoreOriginalTriggerWords(section, originalWords) {
const tagsContainer = section.querySelector('.trigger-words-tags');
const noTriggerWords = section.querySelector('.no-trigger-words');
if (!tagsContainer) return;
// Clear current tags
tagsContainer.innerHTML = '';
if (originalWords.length === 0) {
if (noTriggerWords) noTriggerWords.style.display = '';
tagsContainer.style.display = 'none';
return;
}
// Hide "no trigger words" message
if (noTriggerWords) noTriggerWords.style.display = 'none';
tagsContainer.style.display = 'flex';
// Recreate original tags
originalWords.forEach(word => {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(word);
tag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
tagsContainer.appendChild(tag);
});
}
/**
* Add a new trigger word
* @param {string} word - Trigger word to add
*/
function addNewTriggerWord(word) {
word = word.trim();
if (!word) return;
const triggerWordsSection = document.querySelector('.trigger-words');
let tagsContainer = document.querySelector('.trigger-words-tags');
// Ensure tags container exists and is visible
if (tagsContainer) {
tagsContainer.style.display = 'flex';
} else {
// Create tags container if it doesn't exist
const contentDiv = triggerWordsSection.querySelector('.trigger-words-content');
if (contentDiv) {
tagsContainer = document.createElement('div');
tagsContainer.className = 'trigger-words-tags';
contentDiv.appendChild(tagsContainer);
}
}
if (!tagsContainer) return;
// Hide "no trigger words" message if it exists
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
if (noTriggerWordsMsg) {
noTriggerWordsMsg.style.display = 'none';
}
// Validation: Check length
if (word.split(/\s+/).length > 30) {
showToast('Trigger word should not exceed 30 words', 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 30) {
showToast('Maximum 30 trigger words allowed', 'error');
return;
}
// Validation: Check for duplicates
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('This trigger word already exists', 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag';
newTag.dataset.word = word;
newTag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
tagsContainer.appendChild(newTag);
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
/**
* Update status of items in the trained words dropdown
*/
function updateTrainedWordsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current trigger words
const currentTags = document.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingWords.includes(wordText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
const meta = item.querySelector('.metadata-suggestion-meta');
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
meta.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const word = item.querySelector('.metadata-suggestion-text').textContent;
addNewTriggerWord(word);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus the input
if (input) input.focus();
};
}
}
});
}
/**
* Save trigger words
*/
async function saveTriggerWords() {
const editBtn = document.querySelector('.edit-trigger-words-btn');
const filePath = editBtn.dataset.filePath;
const triggerWordsSection = editBtn.closest('.trigger-words');
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
try {
// Special format for updating nested civitai.trainedWords
await saveModelMetadata(filePath, {
civitai: { trainedWords: words }
});
// Set flag to skip restoring original words when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Exit edit mode without restoring original trigger words
editBtn.click();
// If we saved an empty array and there's a no-trigger-words element, show it
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (words.length === 0 && noTriggerWords) {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
showToast('Trigger words updated successfully', 'success');
} catch (error) {
console.error('Error saving trigger words:', error);
showToast('Failed to update trigger words', 'error');
}
}
/**
* Copy a trigger word to clipboard
* @param {string} word - Word to copy
*/
window.copyTriggerWord = async function(word) {
try {
await copyToClipboard(word, 'Trigger word copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
};

View File

@@ -1,338 +1,7 @@
/**
* LoraModal - 主入口点
*
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
*/
import { showToast } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from '../shared/showcase/ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { loadRecipesForLora } from './RecipeTab.js';
import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
// Legacy LoraModal - now using shared ModelModal component
import { showModelModal } from '../shared/ModelModal.js';
/**
* 显示LoRA模型弹窗
* @param {Object} lora - LoRA模型数据
*/
// Re-export function with original name for backwards compatibility
export function showLoraModal(lora) {
const escapedWords = lora.civitai?.trainedWords?.length ?
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
const content = `
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('loraModal')">&times;</button>
<header class="modal-header">
<div class="model-name-header">
<h2 class="model-name-content">${lora.model_name}</h2>
<button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${lora.civitai?.creator ? `
<div class="creator-info">
${lora.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${lora.civitai.creator.image}" alt="${lora.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">${lora.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(lora.tags || [], lora.file_path)}
</header>
<div class="modal-body">
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<label>Version</label>
<span>${lora.civitai.name || 'N/A'}</span>
</div>
<div class="info-item">
<label>File Name</label>
<div class="file-name-wrapper">
<span id="file-name" class="file-name-content">${lora.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="Edit file name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item location-size">
<div class="location-wrapper">
<label>Location</label>
<span class="file-path">${lora.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
</div>
</div>
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<div class="base-model-display">
<span class="base-model-content">${lora.base_model || 'N/A'}</span>
<button class="edit-base-model-btn" title="Edit base model">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="size-wrapper">
<label>Size</label>
<span>${formatFileSize(lora.file_size)}</span>
</div>
</div>
<div class="info-item usage-tips">
<label>Usage Tips</label>
<div class="editable-field">
<div class="preset-controls">
<select id="preset-selector">
<option value="">Add preset parameter...</option>
<option value="strength_min">Strength Min</option>
<option value="strength_max">Strength Max</option>
<option value="strength">Strength</option>
<option value="clip_skip">Clip Skip</option>
</select>
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
<button class="add-preset-btn">Add</button>
</div>
<div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))}
</div>
</div>
</div>
${renderTriggerWords(escapedWords, lora.file_path)}
<div class="info-item notes">
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
</div>
</div>
<div class="info-item full-width">
<label>About this version</label>
<div class="description-text">${lora.civitai?.description || 'N/A'}</div>
</div>
</div>
</div>
<div class="showcase-section" data-model-hash="${lora.sha256 || ''}" data-filepath="${lora.file_path}">
<div class="showcase-tabs">
<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
<button class="tab-btn" data-tab="recipes">Recipes</button>
</div>
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> Loading example images...
</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> Loading model description...
</div>
<div class="model-description-content">
${lora.modelDescription || ''}
</div>
</div>
</div>
<div id="recipes-tab" class="tab-pane">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
</div>
</div>
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div>
</div>
</div>
`;
modalManager.showModal('loraModal', content, null, function() {
// Clean up all handlers when modal closes
const modalElement = document.getElementById('loraModal');
if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
});
setupEditableFields(lora.file_path);
setupShowcaseScroll('loraModal');
setupTabSwitching();
setupTagTooltip();
setupTriggerWordsEditMode();
setupModelNameEditing(lora.file_path);
setupBaseModelEditing(lora.file_path);
setupFileNameEditing(lora.file_path);
setupTagEditMode(); // Initialize tag editing functionality
setupEventHandlers(lora.file_path);
// If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) {
loadModelDescription(lora.civitai.modelId, lora.file_path);
}
// Load recipes for this Lora
loadRecipesForLora(lora.model_name, lora.sha256);
// Load example images asynchronously - merge regular and custom images
const regularImages = lora.civitai?.images || [];
const customImages = lora.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, lora.sha256);
}
/**
* Sets up event handlers using event delegation
* @param {string} filePath - Path to the model file
*/
function setupEventHandlers(filePath) {
const modalElement = document.getElementById('loraModal');
// 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('loraModal');
break;
case 'scroll-to-top':
scrollToTop(target);
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;
}
async function saveNotes(filePath) {
const content = document.querySelector('.notes-content').textContent;
try {
await saveModelMetadata(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
}
};
function setupEditableFields(filePath) {
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...';
}
}
});
});
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');
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(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
currentPresets[key] = parseFloat(value);
const newPresetsJson = JSON.stringify(currentPresets);
await saveModelMetadata(filePath, {
usage_tips: newPresetsJson
});
presetTags.innerHTML = renderPresetTags(currentPresets);
presetSelector.value = '';
presetValue.value = '';
presetValue.style.display = 'none';
});
// 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);
}
});
}
// Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addPresetBtn.click();
}
});
return showModelModal(lora, 'lora');
}

View File

@@ -1,81 +0,0 @@
/**
* utils.js
* LoraModal组件的辅助函数集合
*/
import { showToast } from '../../utils/uiHelpers.js';
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的文件大小
*/
export function formatFileSize(bytes) {
if (!bytes) return 'N/A';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* 渲染紧凑标签
* @param {Array} tags - 标签数组
* @param {string} filePath - 文件路径,用于编辑按钮
* @returns {string} HTML内容
*/
export function renderCompactTags(tags, filePath = '') {
// Remove the early return and always render the container
const tagsList = tags || [];
// Display up to 5 tags, with a tooltip indicator if there are more
const visibleTags = tagsList.slice(0, 5);
const remainingCount = Math.max(0, tagsList.length - 5);
return `
<div class="model-tags-container">
<div class="model-tags-header">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
</div>
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${tagsList.length > 0 ?
`<div class="model-tags-tooltip">
<div class="tooltip-content">
${tagsList.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
</div>` :
''}
</div>
`;
}
/**
* 设置标签提示功能
*/
export function setupTagTooltip() {
const tagsContainer = document.querySelector('.model-tags-container');
const tooltip = document.querySelector('.model-tags-tooltip');
if (tagsContainer && tooltip) {
tagsContainer.addEventListener('mouseenter', () => {
tooltip.classList.add('visible');
});
tagsContainer.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
});
}
}