mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
4 Commits
37f0e8f213
...
a1dff6dd47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1dff6dd47 | ||
|
|
7fa40023b0 | ||
|
|
3c8acdb65e | ||
|
|
1e9a7812d6 |
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "Bearbeitung abbrechen",
|
||||
"save": "Änderungen speichern",
|
||||
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
||||
"editWord": "Trigger Word bearbeiten",
|
||||
"editPlaceholder": "Trigger Word bearbeiten",
|
||||
"copyWord": "Trigger Word kopieren",
|
||||
"deleteWord": "Trigger Word löschen",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "Cancel editing",
|
||||
"save": "Save changes",
|
||||
"addPlaceholder": "Type to add or click suggestions below",
|
||||
"editWord": "Edit trigger word",
|
||||
"editPlaceholder": "Edit trigger word",
|
||||
"copyWord": "Copy trigger word",
|
||||
"deleteWord": "Delete trigger word",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "Cancelar edición",
|
||||
"save": "Guardar cambios",
|
||||
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
||||
"editWord": "Editar palabra de activación",
|
||||
"editPlaceholder": "Editar palabra de activación",
|
||||
"copyWord": "Copiar palabra clave",
|
||||
"deleteWord": "Eliminar palabra clave",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "Annuler la modification",
|
||||
"save": "Sauvegarder les modifications",
|
||||
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
||||
"editWord": "Modifier le mot déclencheur",
|
||||
"editPlaceholder": "Modifier le mot déclencheur",
|
||||
"copyWord": "Copier le mot-clé",
|
||||
"deleteWord": "Supprimer le mot-clé",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "בטל עריכה",
|
||||
"save": "שמור שינויים",
|
||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||
"editWord": "עריכת מילת טריגר",
|
||||
"editPlaceholder": "עריכת מילת טריגר",
|
||||
"copyWord": "העתק מילת טריגר",
|
||||
"deleteWord": "מחק מילת טריגר",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "編集をキャンセル",
|
||||
"save": "変更を保存",
|
||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||
"editWord": "トリガーワードを編集",
|
||||
"editPlaceholder": "トリガーワードを編集",
|
||||
"copyWord": "トリガーワードをコピー",
|
||||
"deleteWord": "トリガーワードを削除",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "편집 취소",
|
||||
"save": "변경사항 저장",
|
||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||
"editWord": "트리거 단어 편집",
|
||||
"editPlaceholder": "트리거 단어 편집",
|
||||
"copyWord": "트리거 단어 복사",
|
||||
"deleteWord": "트리거 단어 삭제",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "Отменить редактирование",
|
||||
"save": "Сохранить изменения",
|
||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||
"editWord": "Редактировать триггерное слово",
|
||||
"editPlaceholder": "Редактировать триггерное слово",
|
||||
"copyWord": "Копировать триггерное слово",
|
||||
"deleteWord": "Удалить триггерное слово",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "取消编辑",
|
||||
"save": "保存更改",
|
||||
"addPlaceholder": "输入或点击下方建议添加",
|
||||
"editWord": "编辑触发词",
|
||||
"editPlaceholder": "编辑触发词",
|
||||
"copyWord": "复制触发词",
|
||||
"deleteWord": "删除触发词",
|
||||
"suggestions": {
|
||||
|
||||
@@ -1190,6 +1190,8 @@
|
||||
"cancel": "取消編輯",
|
||||
"save": "儲存變更",
|
||||
"addPlaceholder": "輸入或點擊下方建議",
|
||||
"editWord": "編輯觸發詞",
|
||||
"editPlaceholder": "編輯觸發詞",
|
||||
"copyWord": "複製觸發詞",
|
||||
"deleteWord": "刪除觸發詞",
|
||||
"suggestions": {
|
||||
|
||||
@@ -75,6 +75,65 @@ class DownloadManager:
|
||||
backend = (get_settings_manager().get("download_backend") or "python").strip()
|
||||
return backend.lower() or "python"
|
||||
|
||||
async def _schedule_auto_example_images_download(
|
||||
self,
|
||||
*,
|
||||
metadata,
|
||||
model_type: str,
|
||||
) -> None:
|
||||
settings_manager = get_settings_manager()
|
||||
if not settings_manager.get("auto_download_example_images", False):
|
||||
return
|
||||
|
||||
if not settings_manager.get("example_images_path"):
|
||||
logger.debug(
|
||||
"Skipping automatic example images download; example_images_path is not configured"
|
||||
)
|
||||
return
|
||||
|
||||
raw_hash = getattr(metadata, "sha256", "") or ""
|
||||
model_hash = str(raw_hash).strip().lower()
|
||||
if not model_hash:
|
||||
logger.debug(
|
||||
"Skipping automatic example images download for %s; missing sha256",
|
||||
getattr(metadata, "file_path", ""),
|
||||
)
|
||||
return
|
||||
|
||||
optimize = bool(settings_manager.get("optimize_example_images", True))
|
||||
|
||||
async def _run_auto_example_images_download() -> None:
|
||||
try:
|
||||
from ..utils.example_images_download_manager import (
|
||||
DownloadInProgressError,
|
||||
get_default_download_manager,
|
||||
)
|
||||
|
||||
ws_manager = await ServiceRegistry.get_websocket_manager()
|
||||
example_images_manager = get_default_download_manager(ws_manager)
|
||||
await example_images_manager.start_force_download(
|
||||
{
|
||||
"model_hashes": [model_hash],
|
||||
"optimize": optimize,
|
||||
"model_types": [model_type],
|
||||
"delay": 0,
|
||||
}
|
||||
)
|
||||
except DownloadInProgressError:
|
||||
logger.info(
|
||||
"Skipping automatic example images download for %s; another example images download is already running",
|
||||
model_hash,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Automatic example images download failed for %s: %s",
|
||||
model_hash,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_run_auto_example_images_download())
|
||||
|
||||
async def _download_model_file(
|
||||
self,
|
||||
download_url: str,
|
||||
@@ -1458,6 +1517,10 @@ class DownloadManager:
|
||||
version_info,
|
||||
model_version_id,
|
||||
)
|
||||
await self._schedule_auto_example_images_download(
|
||||
metadata=metadata,
|
||||
model_type=model_type,
|
||||
)
|
||||
|
||||
# If early_access_msg exists and download failed, replace error message
|
||||
if "early_access_msg" in locals() and not result.get("success", False):
|
||||
|
||||
@@ -140,9 +140,11 @@
|
||||
|
||||
/* Add specific styles for notes content */
|
||||
.info-item.notes .editable-field [contenteditable] {
|
||||
height: 60px; /* Keep initial modal layout stable regardless of note length */
|
||||
min-height: 60px; /* Increase height for multiple lines */
|
||||
max-height: 150px; /* Limit maximum height */
|
||||
overflow-y: auto; /* Add scrolling for long content */
|
||||
max-height: 420px; /* Limit maximum height */
|
||||
overflow: auto; /* Enable scrolling and resize handle for long content */
|
||||
resize: vertical; /* Allow manual vertical resizing */
|
||||
white-space: pre-wrap; /* Preserve line breaks */
|
||||
line-height: 1.5; /* Improve readability */
|
||||
padding: 8px 12px; /* Slightly increase padding */
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger-word-tag:not(.is-editing) {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.trigger-word-content {
|
||||
color: var(--lora-accent) !important;
|
||||
font-size: 0.85em;
|
||||
@@ -65,6 +69,38 @@
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.trigger-words.edit-mode .trigger-word-tag {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.trigger-word-tag.is-editing {
|
||||
align-items: center;
|
||||
flex: 0 1 min(var(--trigger-word-edit-width, 48ch), 100%);
|
||||
width: min(var(--trigger-word-edit-width, 48ch), 100%);
|
||||
height: var(--trigger-word-edit-height, auto);
|
||||
border-color: var(--lora-accent);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.trigger-word-edit-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 2px;
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.trigger-word-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -109,4 +145,4 @@
|
||||
padding: 2px 5px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { escapeAttribute, escapeHtml } from './utils.js';
|
||||
|
||||
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
||||
const MAX_TRIGGER_WORD_GROUPS = 100;
|
||||
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
|
||||
|
||||
/**
|
||||
* Fetch trained words for a model
|
||||
@@ -226,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
|
||||
const escapedWord = escapeHtml(word);
|
||||
const escapedAttr = escapeAttribute(word);
|
||||
return `
|
||||
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
|
||||
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
|
||||
<span class="trigger-word-content">${escapedWord}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
@@ -264,6 +265,8 @@ export function setupTriggerWordsEditMode() {
|
||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||
if (!editBtn) return;
|
||||
|
||||
document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag);
|
||||
|
||||
editBtn.addEventListener('click', async function () {
|
||||
const triggerWordsSection = this.closest('.trigger-words');
|
||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||
@@ -296,7 +299,9 @@ export function setupTriggerWordsEditMode() {
|
||||
|
||||
// Disable click-to-copy and show delete buttons
|
||||
triggerWordTags.forEach(tag => {
|
||||
tag.onclick = null;
|
||||
teardownDisplayTriggerWordTag(tag);
|
||||
tag.addEventListener('click', startEditTriggerWord);
|
||||
tag.title = translate('modals.model.triggerWords.editWord');
|
||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||
|
||||
@@ -340,6 +345,12 @@ export function setupTriggerWordsEditMode() {
|
||||
// Focus the input
|
||||
addForm.querySelector('input').focus();
|
||||
|
||||
const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag;
|
||||
delete triggerWordsSection._pendingTriggerWordEditTag;
|
||||
if (pendingEditTag && document.contains(pendingEditTag)) {
|
||||
startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } });
|
||||
}
|
||||
|
||||
} else {
|
||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||
this.title = translate('modals.model.triggerWords.edit');
|
||||
@@ -353,6 +364,7 @@ export function setupTriggerWordsEditMode() {
|
||||
// If canceling, restore original trigger words
|
||||
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
||||
} else {
|
||||
commitActiveTriggerWordEdit(triggerWordsSection);
|
||||
// If saving, reset UI state on current trigger words
|
||||
resetTriggerWordsUIState(triggerWordsSection);
|
||||
// Reset the skip restore flag
|
||||
@@ -432,15 +444,18 @@ function deleteTriggerWord(e) {
|
||||
* @param {HTMLElement} section - The trigger words section
|
||||
*/
|
||||
function resetTriggerWordsUIState(section) {
|
||||
commitActiveTriggerWordEdit(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(tag.dataset.word);
|
||||
tag.removeEventListener('click', startEditTriggerWord);
|
||||
setupDisplayTriggerWordTag(tag);
|
||||
tag.title = translate('modals.model.triggerWords.copyWord');
|
||||
|
||||
// Show copy icon, hide delete button
|
||||
if (copyIcon) copyIcon.style.display = '';
|
||||
@@ -474,25 +489,236 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
||||
|
||||
// Recreate original tags
|
||||
originalWords.forEach(word => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'trigger-word-tag';
|
||||
tag.dataset.word = word;
|
||||
tag.onclick = () => copyTriggerWord(tag.dataset.word);
|
||||
|
||||
const escapedWord = escapeHtml(word);
|
||||
tag.innerHTML = `
|
||||
<span class="trigger-word-content">${escapedWord}</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);
|
||||
tagsContainer.appendChild(createTriggerWordTag(word, false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trigger word tag element
|
||||
* @param {string} word - Trigger word
|
||||
* @param {boolean} isEditMode - Whether the tag should be editable
|
||||
* @returns {HTMLElement} Tag element
|
||||
*/
|
||||
function createTriggerWordTag(word, isEditMode = false) {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'trigger-word-tag';
|
||||
tag.dataset.word = word;
|
||||
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
|
||||
|
||||
const escapedWord = escapeHtml(word);
|
||||
tag.innerHTML = `
|
||||
<span class="trigger-word-content">${escapedWord}</span>
|
||||
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="metadata-delete-btn" style="${isEditMode ? '' : 'display:none;'}" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||
|
||||
if (isEditMode) {
|
||||
tag.addEventListener('click', startEditTriggerWord);
|
||||
} else {
|
||||
setupDisplayTriggerWordTag(tag);
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up display-mode click-to-copy and double-click-to-edit behavior
|
||||
* @param {HTMLElement} tag - Trigger word tag
|
||||
*/
|
||||
function setupDisplayTriggerWordTag(tag) {
|
||||
teardownDisplayTriggerWordTag(tag);
|
||||
|
||||
tag.addEventListener('click', handleDisplayTriggerWordClick);
|
||||
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
|
||||
tag.title = translate('modals.model.triggerWords.copyWord');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove display-mode handlers and pending copy action
|
||||
* @param {HTMLElement} tag - Trigger word tag
|
||||
*/
|
||||
function teardownDisplayTriggerWordTag(tag) {
|
||||
if (tag.dataset.copyTimerId) {
|
||||
clearTimeout(Number(tag.dataset.copyTimerId));
|
||||
delete tag.dataset.copyTimerId;
|
||||
}
|
||||
tag.onclick = null;
|
||||
tag.removeEventListener('click', handleDisplayTriggerWordClick);
|
||||
tag.removeEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy trigger word after a short delay so dblclick can cancel it
|
||||
* @param {MouseEvent} e - Click event
|
||||
*/
|
||||
function handleDisplayTriggerWordClick(e) {
|
||||
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
if (!tag || tag.closest('.trigger-words')?.classList.contains('edit-mode')) return;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
if (tag.dataset.copyTimerId) {
|
||||
clearTimeout(Number(tag.dataset.copyTimerId));
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
delete tag.dataset.copyTimerId;
|
||||
copyTriggerWord(tag.dataset.word);
|
||||
}, TRIGGER_WORD_CLICK_DELAY_MS);
|
||||
tag.dataset.copyTimerId = String(timerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter edit mode and start editing the double-clicked trigger word
|
||||
* @param {MouseEvent} e - Double-click event
|
||||
*/
|
||||
function handleDisplayTriggerWordDoubleClick(e) {
|
||||
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
const section = tag?.closest('.trigger-words');
|
||||
const editBtn = section?.querySelector('.edit-trigger-words-btn');
|
||||
if (!tag || !section || !editBtn) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (tag.dataset.copyTimerId) {
|
||||
clearTimeout(Number(tag.dataset.copyTimerId));
|
||||
delete tag.dataset.copyTimerId;
|
||||
}
|
||||
|
||||
if (!section.classList.contains('edit-mode')) {
|
||||
section._pendingTriggerWordEditTag = tag;
|
||||
editBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
startEditTriggerWord.call(tag, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a trigger word against existing tags
|
||||
* @param {string} word - Trigger word
|
||||
* @param {HTMLElement} tagsContainer - Tags container
|
||||
* @param {HTMLElement|null} currentTag - Tag being edited, if any
|
||||
* @returns {boolean} Whether the word is valid
|
||||
*/
|
||||
function validateTriggerWord(word, tagsContainer, currentTag = null) {
|
||||
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
|
||||
showToast('toast.triggerWords.tooLong', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||
const existingWords = Array.from(currentTags)
|
||||
.filter(tag => tag !== currentTag)
|
||||
.map(tag => tag.dataset.word);
|
||||
|
||||
if (existingWords.includes(word)) {
|
||||
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start inline editing for a trigger word tag
|
||||
* @param {Event} e - Click event
|
||||
*/
|
||||
function startEditTriggerWord(e) {
|
||||
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
const section = tag?.closest('.trigger-words');
|
||||
if (!tag || !section?.classList.contains('edit-mode') || tag.classList.contains('is-editing')) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
commitActiveTriggerWordEdit(section);
|
||||
|
||||
const content = tag.querySelector('.trigger-word-content');
|
||||
const originalWord = tag.dataset.word;
|
||||
const originalRect = tag.getBoundingClientRect();
|
||||
if (originalRect.width > 0) {
|
||||
tag.style.setProperty('--trigger-word-edit-width', `${Math.ceil(originalRect.width)}px`);
|
||||
}
|
||||
if (originalRect.height > 0) {
|
||||
tag.style.setProperty('--trigger-word-edit-height', `${Math.ceil(originalRect.height)}px`);
|
||||
}
|
||||
|
||||
const editor = document.createElement('textarea');
|
||||
editor.className = 'trigger-word-edit-input';
|
||||
editor.rows = 1;
|
||||
editor.value = originalWord;
|
||||
editor.setAttribute('aria-label', translate('modals.model.triggerWords.editWord'));
|
||||
editor.placeholder = translate('modals.model.triggerWords.editPlaceholder');
|
||||
|
||||
let finished = false;
|
||||
const finish = (shouldCommit) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
const nextWord = editor.value.trim().replace(/\s*\n+\s*/g, ' ');
|
||||
if (shouldCommit && nextWord && nextWord !== originalWord) {
|
||||
const tagsContainer = tag.closest('.trigger-words-tags');
|
||||
if (tagsContainer && validateTriggerWord(nextWord, tagsContainer, tag)) {
|
||||
tag.dataset.word = nextWord;
|
||||
content.textContent = nextWord;
|
||||
}
|
||||
}
|
||||
|
||||
editor.remove();
|
||||
content.style.display = '';
|
||||
tag.classList.remove('is-editing');
|
||||
tag.style.removeProperty('--trigger-word-edit-width');
|
||||
tag.style.removeProperty('--trigger-word-edit-height');
|
||||
updateTrainedWordsDropdown();
|
||||
};
|
||||
|
||||
editor.addEventListener('click', event => event.stopPropagation());
|
||||
editor.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
finish(true);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
editor.addEventListener('blur', () => finish(true));
|
||||
|
||||
editor.style.visibility = 'hidden';
|
||||
content.after(editor);
|
||||
tag.classList.add('is-editing');
|
||||
content.style.display = 'none';
|
||||
editor.style.visibility = '';
|
||||
editor.focus();
|
||||
editor.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit an active inline trigger word edit if one exists
|
||||
* @param {HTMLElement} section - Trigger words section
|
||||
*/
|
||||
function commitActiveTriggerWordEdit(section) {
|
||||
const input = section.querySelector('.trigger-word-edit-input');
|
||||
if (input) {
|
||||
input.dispatchEvent(new FocusEvent('blur'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new trigger word
|
||||
* @param {string} word - Trigger word to add
|
||||
@@ -525,12 +751,6 @@ function addNewTriggerWord(word) {
|
||||
noTriggerWordsMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
// Validation: Check length
|
||||
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
|
||||
showToast('toast.triggerWords.tooLong', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check total number
|
||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
|
||||
@@ -538,33 +758,9 @@ function addNewTriggerWord(word) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check for duplicates
|
||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||
if (existingWords.includes(word)) {
|
||||
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
const newTag = document.createElement('div');
|
||||
newTag.className = 'trigger-word-tag';
|
||||
newTag.dataset.word = word;
|
||||
|
||||
const escapedWord = escapeHtml(word);
|
||||
newTag.innerHTML = `
|
||||
<span class="trigger-word-content">${escapedWord}</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);
|
||||
if (!validateTriggerWord(word, tagsContainer)) return;
|
||||
|
||||
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
|
||||
tagsContainer.appendChild(newTag);
|
||||
|
||||
// Update status of items in the trained words dropdown
|
||||
@@ -636,6 +832,8 @@ async function saveTriggerWords() {
|
||||
const filePath = editBtn.dataset.filePath;
|
||||
const triggerWordsSection = editBtn.closest('.trigger-words');
|
||||
|
||||
commitActiveTriggerWordEdit(triggerWordsSection);
|
||||
|
||||
// Auto-commit any pending input to prevent data loss
|
||||
const input = triggerWordsSection.querySelector('.metadata-input');
|
||||
if (input && input.value.trim()) {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("TriggerWords HTML Escaping", () => {
|
||||
expect(html).toContain('data-word="word'with'quotes"');
|
||||
expect(html).toContain('data-word="<tag>"');
|
||||
|
||||
// Check for the onclick handler
|
||||
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
|
||||
// Copy/edit handlers are attached by setupTriggerWordsEditMode, not inline HTML.
|
||||
expect(html).not.toContain('onclick="copyTriggerWord');
|
||||
});
|
||||
});
|
||||
|
||||
158
tests/frontend/components/triggerWords.inlineEdit.test.js
Normal file
158
tests/frontend/components/triggerWords.inlineEdit.test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
TRIGGER_WORDS_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((key, params, fallback) => fallback || key),
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
saveModelMetadata: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("TriggerWords inline editing", () => {
|
||||
let renderTriggerWords;
|
||||
let setupTriggerWordsEditMode;
|
||||
let showToast;
|
||||
let copyToClipboard;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
trained_words: [],
|
||||
class_tokens: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const module = await import(TRIGGER_WORDS_MODULE);
|
||||
const uiHelpers = await import(UI_HELPERS_MODULE);
|
||||
renderTriggerWords = module.renderTriggerWords;
|
||||
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
|
||||
showToast = uiHelpers.showToast;
|
||||
copyToClipboard = uiHelpers.copyToClipboard;
|
||||
});
|
||||
|
||||
async function enterEditMode(words = ["alpha", "beta"]) {
|
||||
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
|
||||
setupTriggerWordsEditMode();
|
||||
|
||||
document.querySelector('.edit-trigger-words-btn')
|
||||
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
function editFirstTag(nextValue, key = 'Enter') {
|
||||
const firstTag = document.querySelector('.trigger-word-tag');
|
||||
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
const input = firstTag.querySelector('.trigger-word-edit-input');
|
||||
input.value = nextValue;
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||
|
||||
return firstTag;
|
||||
}
|
||||
|
||||
it("updates an existing trigger word in place", async () => {
|
||||
await enterEditMode();
|
||||
|
||||
const firstTag = editFirstTag("gamma");
|
||||
|
||||
expect(firstTag.dataset.word).toBe("gamma");
|
||||
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("gamma");
|
||||
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
|
||||
});
|
||||
|
||||
it("enters edit mode and edits the double-clicked tag from display mode without copying", async () => {
|
||||
document.body.innerHTML = renderTriggerWords(["alpha", "beta"], "test.safetensors");
|
||||
setupTriggerWordsEditMode();
|
||||
|
||||
const firstTag = document.querySelector('.trigger-word-tag');
|
||||
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
firstTag.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.trigger-words').classList.contains('edit-mode')).toBe(true);
|
||||
expect(firstTag.querySelector('.trigger-word-edit-input')).toBeTruthy();
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 260));
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the original word and shows a toast when editing to a duplicate", async () => {
|
||||
await enterEditMode();
|
||||
|
||||
const firstTag = editFirstTag("beta");
|
||||
|
||||
expect(firstTag.dataset.word).toBe("alpha");
|
||||
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
|
||||
expect(showToast).toHaveBeenCalledWith('toast.triggerWords.alreadyExists', {}, 'error');
|
||||
});
|
||||
|
||||
it("restores the original value when Escape is pressed", async () => {
|
||||
await enterEditMode();
|
||||
|
||||
const firstTag = editFirstTag("gamma", "Escape");
|
||||
|
||||
expect(firstTag.dataset.word).toBe("alpha");
|
||||
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
|
||||
});
|
||||
|
||||
it("preserves the current tag dimensions while editing long trigger words", async () => {
|
||||
await enterEditMode(["alpha beta gamma delta epsilon zeta eta theta"]);
|
||||
|
||||
const firstTag = document.querySelector('.trigger-word-tag');
|
||||
vi.spyOn(firstTag, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 320,
|
||||
height: 44,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 44,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
const editor = firstTag.querySelector('.trigger-word-edit-input');
|
||||
expect(editor.tagName).toBe('TEXTAREA');
|
||||
expect(firstTag.style.getPropertyValue('--trigger-word-edit-width')).toBe('320px');
|
||||
expect(firstTag.style.getPropertyValue('--trigger-word-edit-height')).toBe('44px');
|
||||
});
|
||||
|
||||
it("restores all original trigger words when edit mode is canceled", async () => {
|
||||
await enterEditMode();
|
||||
editFirstTag("gamma");
|
||||
|
||||
document.querySelector('.edit-trigger-words-btn')
|
||||
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
const words = Array.from(document.querySelectorAll('.trigger-word-tag'))
|
||||
.map(tag => tag.dataset.word);
|
||||
expect(words).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
@@ -233,6 +233,136 @@ async def test_successful_download_uses_defaults(
|
||||
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_download_schedules_auto_example_images(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
manager = DownloadManager()
|
||||
scheduled = []
|
||||
|
||||
async def fake_execute_download(
|
||||
self,
|
||||
*,
|
||||
download_urls,
|
||||
save_dir,
|
||||
metadata,
|
||||
version_info,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
model_type,
|
||||
download_id,
|
||||
transfer_backend=None,
|
||||
):
|
||||
return {"success": True}
|
||||
|
||||
async def fake_schedule(self, *, metadata, model_type):
|
||||
scheduled.append({"metadata": metadata, "model_type": model_type})
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
DownloadManager,
|
||||
"_schedule_auto_example_images_download",
|
||||
fake_schedule,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(scheduled) == 1
|
||||
assert scheduled[0]["model_type"] == "lora"
|
||||
assert scheduled[0]["metadata"].sha256 == "sha256"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_example_images_download_uses_settings_payload(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
manager = DownloadManager()
|
||||
settings = get_settings_manager()
|
||||
settings.settings["auto_download_example_images"] = True
|
||||
settings.settings["example_images_path"] = str(tmp_path / "examples")
|
||||
settings.settings["optimize_example_images"] = False
|
||||
|
||||
calls = []
|
||||
|
||||
class DummyExampleImagesManager:
|
||||
async def start_force_download(self, payload):
|
||||
calls.append(payload)
|
||||
return {"success": True}
|
||||
|
||||
from py.utils import example_images_download_manager
|
||||
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_websocket_manager",
|
||||
AsyncMock(return_value=object()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
example_images_download_manager,
|
||||
"get_default_download_manager",
|
||||
lambda _ws_manager: DummyExampleImagesManager(),
|
||||
)
|
||||
|
||||
metadata = SimpleNamespace(sha256="ABCDEF", file_path="model.safetensors")
|
||||
await manager._schedule_auto_example_images_download(
|
||||
metadata=metadata,
|
||||
model_type="lora",
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
if calls:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"model_hashes": ["abcdef"],
|
||||
"optimize": False,
|
||||
"model_types": ["lora"],
|
||||
"delay": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_example_images_download_skips_without_configuration(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
manager = DownloadManager()
|
||||
settings = get_settings_manager()
|
||||
settings.settings["auto_download_example_images"] = True
|
||||
settings.settings["example_images_path"] = ""
|
||||
|
||||
get_ws_manager = AsyncMock(return_value=object())
|
||||
monkeypatch.setattr(ServiceRegistry, "get_websocket_manager", get_ws_manager)
|
||||
|
||||
await manager._schedule_auto_example_images_download(
|
||||
metadata=SimpleNamespace(sha256="abcdef", file_path="model.safetensors"),
|
||||
model_type="lora",
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
get_ws_manager.assert_not_called()
|
||||
|
||||
settings.settings["example_images_path"] = str(tmp_path / "examples")
|
||||
await manager._schedule_auto_example_images_download(
|
||||
metadata=SimpleNamespace(sha256="", file_path="model.safetensors"),
|
||||
model_type="lora",
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
get_ws_manager.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_uses_active_mirrors(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
|
||||
Reference in New Issue
Block a user