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",
|
"cancel": "Bearbeitung abbrechen",
|
||||||
"save": "Änderungen speichern",
|
"save": "Änderungen speichern",
|
||||||
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
||||||
|
"editWord": "Trigger Word bearbeiten",
|
||||||
|
"editPlaceholder": "Trigger Word bearbeiten",
|
||||||
"copyWord": "Trigger Word kopieren",
|
"copyWord": "Trigger Word kopieren",
|
||||||
"deleteWord": "Trigger Word löschen",
|
"deleteWord": "Trigger Word löschen",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "Cancel editing",
|
"cancel": "Cancel editing",
|
||||||
"save": "Save changes",
|
"save": "Save changes",
|
||||||
"addPlaceholder": "Type to add or click suggestions below",
|
"addPlaceholder": "Type to add or click suggestions below",
|
||||||
|
"editWord": "Edit trigger word",
|
||||||
|
"editPlaceholder": "Edit trigger word",
|
||||||
"copyWord": "Copy trigger word",
|
"copyWord": "Copy trigger word",
|
||||||
"deleteWord": "Delete trigger word",
|
"deleteWord": "Delete trigger word",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "Cancelar edición",
|
"cancel": "Cancelar edición",
|
||||||
"save": "Guardar cambios",
|
"save": "Guardar cambios",
|
||||||
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
"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",
|
"copyWord": "Copiar palabra clave",
|
||||||
"deleteWord": "Eliminar palabra clave",
|
"deleteWord": "Eliminar palabra clave",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "Annuler la modification",
|
"cancel": "Annuler la modification",
|
||||||
"save": "Sauvegarder les modifications",
|
"save": "Sauvegarder les modifications",
|
||||||
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
"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é",
|
"copyWord": "Copier le mot-clé",
|
||||||
"deleteWord": "Supprimer le mot-clé",
|
"deleteWord": "Supprimer le mot-clé",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "בטל עריכה",
|
"cancel": "בטל עריכה",
|
||||||
"save": "שמור שינויים",
|
"save": "שמור שינויים",
|
||||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||||
|
"editWord": "עריכת מילת טריגר",
|
||||||
|
"editPlaceholder": "עריכת מילת טריגר",
|
||||||
"copyWord": "העתק מילת טריגר",
|
"copyWord": "העתק מילת טריגר",
|
||||||
"deleteWord": "מחק מילת טריגר",
|
"deleteWord": "מחק מילת טריגר",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "編集をキャンセル",
|
"cancel": "編集をキャンセル",
|
||||||
"save": "変更を保存",
|
"save": "変更を保存",
|
||||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||||
|
"editWord": "トリガーワードを編集",
|
||||||
|
"editPlaceholder": "トリガーワードを編集",
|
||||||
"copyWord": "トリガーワードをコピー",
|
"copyWord": "トリガーワードをコピー",
|
||||||
"deleteWord": "トリガーワードを削除",
|
"deleteWord": "トリガーワードを削除",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "편집 취소",
|
"cancel": "편집 취소",
|
||||||
"save": "변경사항 저장",
|
"save": "변경사항 저장",
|
||||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||||
|
"editWord": "트리거 단어 편집",
|
||||||
|
"editPlaceholder": "트리거 단어 편집",
|
||||||
"copyWord": "트리거 단어 복사",
|
"copyWord": "트리거 단어 복사",
|
||||||
"deleteWord": "트리거 단어 삭제",
|
"deleteWord": "트리거 단어 삭제",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "Отменить редактирование",
|
"cancel": "Отменить редактирование",
|
||||||
"save": "Сохранить изменения",
|
"save": "Сохранить изменения",
|
||||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||||
|
"editWord": "Редактировать триггерное слово",
|
||||||
|
"editPlaceholder": "Редактировать триггерное слово",
|
||||||
"copyWord": "Копировать триггерное слово",
|
"copyWord": "Копировать триггерное слово",
|
||||||
"deleteWord": "Удалить триггерное слово",
|
"deleteWord": "Удалить триггерное слово",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "取消编辑",
|
"cancel": "取消编辑",
|
||||||
"save": "保存更改",
|
"save": "保存更改",
|
||||||
"addPlaceholder": "输入或点击下方建议添加",
|
"addPlaceholder": "输入或点击下方建议添加",
|
||||||
|
"editWord": "编辑触发词",
|
||||||
|
"editPlaceholder": "编辑触发词",
|
||||||
"copyWord": "复制触发词",
|
"copyWord": "复制触发词",
|
||||||
"deleteWord": "删除触发词",
|
"deleteWord": "删除触发词",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@
|
|||||||
"cancel": "取消編輯",
|
"cancel": "取消編輯",
|
||||||
"save": "儲存變更",
|
"save": "儲存變更",
|
||||||
"addPlaceholder": "輸入或點擊下方建議",
|
"addPlaceholder": "輸入或點擊下方建議",
|
||||||
|
"editWord": "編輯觸發詞",
|
||||||
|
"editPlaceholder": "編輯觸發詞",
|
||||||
"copyWord": "複製觸發詞",
|
"copyWord": "複製觸發詞",
|
||||||
"deleteWord": "刪除觸發詞",
|
"deleteWord": "刪除觸發詞",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
|
|||||||
@@ -75,6 +75,65 @@ class DownloadManager:
|
|||||||
backend = (get_settings_manager().get("download_backend") or "python").strip()
|
backend = (get_settings_manager().get("download_backend") or "python").strip()
|
||||||
return backend.lower() or "python"
|
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(
|
async def _download_model_file(
|
||||||
self,
|
self,
|
||||||
download_url: str,
|
download_url: str,
|
||||||
@@ -1458,6 +1517,10 @@ class DownloadManager:
|
|||||||
version_info,
|
version_info,
|
||||||
model_version_id,
|
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 exists and download failed, replace error message
|
||||||
if "early_access_msg" in locals() and not result.get("success", False):
|
if "early_access_msg" in locals() and not result.get("success", False):
|
||||||
|
|||||||
@@ -140,9 +140,11 @@
|
|||||||
|
|
||||||
/* Add specific styles for notes content */
|
/* Add specific styles for notes content */
|
||||||
.info-item.notes .editable-field [contenteditable] {
|
.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 */
|
min-height: 60px; /* Increase height for multiple lines */
|
||||||
max-height: 150px; /* Limit maximum height */
|
max-height: 420px; /* Limit maximum height */
|
||||||
overflow-y: auto; /* Add scrolling for long content */
|
overflow: auto; /* Enable scrolling and resize handle for long content */
|
||||||
|
resize: vertical; /* Allow manual vertical resizing */
|
||||||
white-space: pre-wrap; /* Preserve line breaks */
|
white-space: pre-wrap; /* Preserve line breaks */
|
||||||
line-height: 1.5; /* Improve readability */
|
line-height: 1.5; /* Improve readability */
|
||||||
padding: 8px 12px; /* Slightly increase padding */
|
padding: 8px 12px; /* Slightly increase padding */
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trigger-word-tag:not(.is-editing) {
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.trigger-word-content {
|
.trigger-word-content {
|
||||||
color: var(--lora-accent) !important;
|
color: var(--lora-accent) !important;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@@ -65,6 +69,38 @@
|
|||||||
border-color: var(--lora-accent);
|
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 {
|
.trigger-word-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { escapeAttribute, escapeHtml } from './utils.js';
|
|||||||
|
|
||||||
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
||||||
const MAX_TRIGGER_WORD_GROUPS = 100;
|
const MAX_TRIGGER_WORD_GROUPS = 100;
|
||||||
|
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch trained words for a model
|
* Fetch trained words for a model
|
||||||
@@ -226,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
const escapedWord = escapeHtml(word);
|
const escapedWord = escapeHtml(word);
|
||||||
const escapedAttr = escapeAttribute(word);
|
const escapedAttr = escapeAttribute(word);
|
||||||
return `
|
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-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
@@ -264,6 +265,8 @@ export function setupTriggerWordsEditMode() {
|
|||||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||||
if (!editBtn) return;
|
if (!editBtn) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag);
|
||||||
|
|
||||||
editBtn.addEventListener('click', async function () {
|
editBtn.addEventListener('click', async function () {
|
||||||
const triggerWordsSection = this.closest('.trigger-words');
|
const triggerWordsSection = this.closest('.trigger-words');
|
||||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||||
@@ -296,7 +299,9 @@ export function setupTriggerWordsEditMode() {
|
|||||||
|
|
||||||
// Disable click-to-copy and show delete buttons
|
// Disable click-to-copy and show delete buttons
|
||||||
triggerWordTags.forEach(tag => {
|
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 copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
|
||||||
@@ -340,6 +345,12 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// Focus the input
|
// Focus the input
|
||||||
addForm.querySelector('input').focus();
|
addForm.querySelector('input').focus();
|
||||||
|
|
||||||
|
const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag;
|
||||||
|
delete triggerWordsSection._pendingTriggerWordEditTag;
|
||||||
|
if (pendingEditTag && document.contains(pendingEditTag)) {
|
||||||
|
startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } });
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||||
this.title = translate('modals.model.triggerWords.edit');
|
this.title = translate('modals.model.triggerWords.edit');
|
||||||
@@ -353,6 +364,7 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// If canceling, restore original trigger words
|
// If canceling, restore original trigger words
|
||||||
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
||||||
} else {
|
} else {
|
||||||
|
commitActiveTriggerWordEdit(triggerWordsSection);
|
||||||
// If saving, reset UI state on current trigger words
|
// If saving, reset UI state on current trigger words
|
||||||
resetTriggerWordsUIState(triggerWordsSection);
|
resetTriggerWordsUIState(triggerWordsSection);
|
||||||
// Reset the skip restore flag
|
// Reset the skip restore flag
|
||||||
@@ -432,15 +444,18 @@ function deleteTriggerWord(e) {
|
|||||||
* @param {HTMLElement} section - The trigger words section
|
* @param {HTMLElement} section - The trigger words section
|
||||||
*/
|
*/
|
||||||
function resetTriggerWordsUIState(section) {
|
function resetTriggerWordsUIState(section) {
|
||||||
|
commitActiveTriggerWordEdit(section);
|
||||||
|
|
||||||
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
||||||
|
|
||||||
triggerWordTags.forEach(tag => {
|
triggerWordTags.forEach(tag => {
|
||||||
const word = tag.dataset.word;
|
|
||||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
|
||||||
// Restore click-to-copy functionality
|
// 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
|
// Show copy icon, hide delete button
|
||||||
if (copyIcon) copyIcon.style.display = '';
|
if (copyIcon) copyIcon.style.display = '';
|
||||||
@@ -474,23 +489,234 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
|||||||
|
|
||||||
// Recreate original tags
|
// Recreate original tags
|
||||||
originalWords.forEach(word => {
|
originalWords.forEach(word => {
|
||||||
|
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');
|
const tag = document.createElement('div');
|
||||||
tag.className = 'trigger-word-tag';
|
tag.className = 'trigger-word-tag';
|
||||||
tag.dataset.word = word;
|
tag.dataset.word = word;
|
||||||
tag.onclick = () => copyTriggerWord(tag.dataset.word);
|
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
|
||||||
|
|
||||||
const escapedWord = escapeHtml(word);
|
const escapedWord = escapeHtml(word);
|
||||||
tag.innerHTML = `
|
tag.innerHTML = `
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</span>
|
</span>
|
||||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
|
<button class="metadata-delete-btn" style="${isEditMode ? '' : 'display:none;'}" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
tagsContainer.appendChild(tag);
|
|
||||||
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -525,12 +751,6 @@ function addNewTriggerWord(word) {
|
|||||||
noTriggerWordsMsg.style.display = 'none';
|
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
|
// Validation: Check total number
|
||||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||||
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
|
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
|
||||||
@@ -538,33 +758,9 @@ function addNewTriggerWord(word) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check for duplicates
|
if (!validateTriggerWord(word, tagsContainer)) return;
|
||||||
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);
|
|
||||||
|
|
||||||
|
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
|
||||||
tagsContainer.appendChild(newTag);
|
tagsContainer.appendChild(newTag);
|
||||||
|
|
||||||
// Update status of items in the trained words dropdown
|
// Update status of items in the trained words dropdown
|
||||||
@@ -636,6 +832,8 @@ async function saveTriggerWords() {
|
|||||||
const filePath = editBtn.dataset.filePath;
|
const filePath = editBtn.dataset.filePath;
|
||||||
const triggerWordsSection = editBtn.closest('.trigger-words');
|
const triggerWordsSection = editBtn.closest('.trigger-words');
|
||||||
|
|
||||||
|
commitActiveTriggerWordEdit(triggerWordsSection);
|
||||||
|
|
||||||
// Auto-commit any pending input to prevent data loss
|
// Auto-commit any pending input to prevent data loss
|
||||||
const input = triggerWordsSection.querySelector('.metadata-input');
|
const input = triggerWordsSection.querySelector('.metadata-input');
|
||||||
if (input && input.value.trim()) {
|
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="word'with'quotes"');
|
||||||
expect(html).toContain('data-word="<tag>"');
|
expect(html).toContain('data-word="<tag>"');
|
||||||
|
|
||||||
// Check for the onclick handler
|
// Copy/edit handlers are attached by setupTriggerWordsEditMode, not inline HTML.
|
||||||
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_download_uses_active_mirrors(
|
async def test_download_uses_active_mirrors(
|
||||||
monkeypatch, scanners, metadata_provider, tmp_path
|
monkeypatch, scanners, metadata_provider, tmp_path
|
||||||
|
|||||||
Reference in New Issue
Block a user