mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 14:42:11 -03:00
Add API endpoint for fetching trained words and implement dropdown suggestions in the trigger words editor. See #147
This commit is contained in:
@@ -1,15 +1,110 @@
|
||||
/**
|
||||
* TriggerWords.js
|
||||
* 处理LoRA模型触发词相关的功能模块
|
||||
* Module that handles trigger word functionality for LoRA models
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||
|
||||
/**
|
||||
* 渲染触发词
|
||||
* @param {Array} words - 触发词数组
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {string} HTML内容
|
||||
* Fetch trained words for a model
|
||||
* @param {string} filePath - Path to the model file
|
||||
* @returns {Promise<Array>} - Array of [word, frequency] pairs
|
||||
*/
|
||||
async function fetchTrainedWords(filePath) {
|
||||
try {
|
||||
const response = await fetch(`/api/trained-words?file_path=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.trained_words) {
|
||||
return data.trained_words; // Returns array of [word, frequency] pairs
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create suggestion dropdown with trained words as tags
|
||||
* @param {Array} trainedWords - Array of [word, frequency] pairs
|
||||
* @param {Array} existingWords - Already added trigger words
|
||||
* @returns {HTMLElement} - Dropdown element
|
||||
*/
|
||||
function createSuggestionDropdown(trainedWords, existingWords = []) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'trained-words-dropdown';
|
||||
|
||||
// Create header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'trained-words-header';
|
||||
|
||||
if (!trainedWords || trainedWords.length === 0) {
|
||||
header.innerHTML = '<span>No suggestions available</span>';
|
||||
dropdown.appendChild(header);
|
||||
dropdown.innerHTML += '<div class="no-trained-words">No trained words found in this model. You can manually enter trigger words.</div>';
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
// Sort by frequency (highest first)
|
||||
trainedWords.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
header.innerHTML = `
|
||||
<span>Suggestions from training data</span>
|
||||
<small>${trainedWords.length} words found</small>
|
||||
`;
|
||||
dropdown.appendChild(header);
|
||||
|
||||
// Create tag container
|
||||
const container = document.createElement('div');
|
||||
container.className = 'trained-words-container';
|
||||
|
||||
// Add each trained word as a tag
|
||||
trainedWords.forEach(([word, frequency]) => {
|
||||
const isAdded = existingWords.includes(word);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = `trained-word-item ${isAdded ? 'already-added' : ''}`;
|
||||
item.title = word; // Show full word on hover if truncated
|
||||
item.innerHTML = `
|
||||
<span class="trained-word-text">${word}</span>
|
||||
<div class="trained-word-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('.new-trigger-word-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);
|
||||
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 `
|
||||
@@ -24,19 +119,14 @@ export function renderTriggerWords(words, filePath) {
|
||||
<span class="no-trigger-words">No trigger word needed</span>
|
||||
<div class="trigger-words-tags" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
|
||||
</div>
|
||||
<div class="trigger-words-edit-controls" style="display:none;">
|
||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
<button class="save-trigger-words-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -63,44 +153,53 @@ export function renderTriggerWords(words, filePath) {
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
|
||||
</div>
|
||||
<div class="trigger-words-edit-controls" style="display:none;">
|
||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
<button class="save-trigger-words-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置触发词编辑模式
|
||||
* Set up trigger words edit mode
|
||||
*/
|
||||
export function setupTriggerWordsEditMode() {
|
||||
// Store trained words data
|
||||
let trainedWordsList = [];
|
||||
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', function() {
|
||||
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('.trigger-words-edit-controls');
|
||||
const addForm = triggerWordsSection.querySelector('.add-trigger-word-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
|
||||
@@ -115,10 +214,44 @@ export function setupTriggerWordsEditMode() {
|
||||
tag.querySelector('.trigger-word-copy').style.display = 'none';
|
||||
tag.querySelector('.delete-trigger-word-btn').style.display = 'block';
|
||||
});
|
||||
|
||||
// Load trained words and display dropdown when entering edit mode
|
||||
// Add loading indicator
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'trained-words-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) {
|
||||
trainedWordsList = await fetchTrainedWords(filePath);
|
||||
isTrainedWordsLoaded = true;
|
||||
}
|
||||
|
||||
// Remove loading indicator
|
||||
loadingIndicator.remove();
|
||||
|
||||
// Create and display suggestion dropdown
|
||||
const dropdown = createSuggestionDropdown(trainedWordsList, 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';
|
||||
|
||||
// BUGFIX: Restore original trigger words when canceling edit
|
||||
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
||||
|
||||
// If we have no trigger words, show the "No trigger word needed" text
|
||||
// and hide the empty tags container
|
||||
@@ -128,57 +261,26 @@ export function setupTriggerWordsEditMode() {
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Restore original state
|
||||
triggerWordTags.forEach(tag => {
|
||||
const word = tag.dataset.word;
|
||||
tag.onclick = () => copyTriggerWord(word);
|
||||
tag.querySelector('.trigger-word-copy').style.display = 'flex';
|
||||
tag.querySelector('.delete-trigger-word-btn').style.display = 'none';
|
||||
});
|
||||
|
||||
// Hide add form if open
|
||||
triggerWordsSection.querySelector('.add-trigger-word-form').style.display = 'none';
|
||||
// Remove dropdown if present
|
||||
const dropdown = document.querySelector('.trained-words-dropdown');
|
||||
if (dropdown) dropdown.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up add trigger word button
|
||||
const addBtn = document.querySelector('.add-trigger-word-btn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', function() {
|
||||
const triggerWordsSection = this.closest('.trigger-words');
|
||||
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
|
||||
addForm.style.display = 'flex';
|
||||
addForm.querySelector('input').focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Set up confirm and cancel add buttons
|
||||
const confirmAddBtn = document.querySelector('.confirm-add-trigger-word-btn');
|
||||
const cancelAddBtn = document.querySelector('.cancel-add-trigger-word-btn');
|
||||
// Set up input for adding trigger words
|
||||
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
||||
|
||||
if (confirmAddBtn && triggerWordInput) {
|
||||
confirmAddBtn.addEventListener('click', function() {
|
||||
addNewTriggerWord(triggerWordInput.value);
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelAddBtn) {
|
||||
cancelAddBtn.addEventListener('click', function() {
|
||||
const addForm = this.closest('.add-trigger-word-form');
|
||||
addForm.style.display = 'none';
|
||||
addForm.querySelector('input').value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Set up save button
|
||||
const saveBtn = document.querySelector('.save-trigger-words-btn');
|
||||
if (saveBtn) {
|
||||
@@ -191,13 +293,59 @@ export function setupTriggerWordsEditMode() {
|
||||
e.stopPropagation();
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
tag.remove();
|
||||
|
||||
// Update status of items in the trained words dropdown
|
||||
updateTrainedWordsDropdown();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新触发词
|
||||
* @param {string} word - 要添加的触发词
|
||||
* 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="delete-trigger-word-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();
|
||||
@@ -265,18 +413,75 @@ function addNewTriggerWord(word) {
|
||||
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
newTag.remove();
|
||||
// Update dropdown after removing
|
||||
updateTrainedWordsDropdown();
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(newTag);
|
||||
|
||||
// Clear and hide the input form
|
||||
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
||||
triggerWordInput.value = '';
|
||||
document.querySelector('.add-trigger-word-form').style.display = 'none';
|
||||
// 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('.trained-words-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('.trained-word-item').forEach(item => {
|
||||
const wordText = item.querySelector('.trained-word-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('.trained-word-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('.trained-word-text').textContent;
|
||||
addNewTriggerWord(word);
|
||||
|
||||
// Also populate the input field
|
||||
const input = document.querySelector('.new-trigger-word-input');
|
||||
if (input) input.value = word;
|
||||
|
||||
// Focus the input
|
||||
if (input) input.focus();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save trigger words
|
||||
*/
|
||||
async function saveTriggerWords() {
|
||||
const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath;
|
||||
@@ -331,8 +536,8 @@ async function saveTriggerWords() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制触发词到剪贴板
|
||||
* @param {string} word - 要复制的触发词
|
||||
* Copy a trigger word to clipboard
|
||||
* @param {string} word - Word to copy
|
||||
*/
|
||||
window.copyTriggerWord = async function(word) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user