Compare commits

..

4 Commits

Author SHA1 Message Date
Will Miao
a1dff6dd47 fix(download): auto fetch example images after model download 2026-04-21 22:48:06 +08:00
Will Miao
7fa40023b0 fix(trigger-words): edit tag on double click 2026-04-21 22:31:56 +08:00
Will Miao
3c8acdb65e fix(trigger-words): support stable inline editing 2026-04-21 22:18:35 +08:00
Will Miao
1e9a7812d6 fix(model-modal): allow resizing notes editor 2026-04-21 21:42:06 +08:00
17 changed files with 664 additions and 57 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "בטל עריכה",
"save": "שמור שינויים",
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר",
"deleteWord": "מחק מילת טריגר",
"suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "編集をキャンセル",
"save": "変更を保存",
"addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"deleteWord": "トリガーワードを削除",
"suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "편집 취소",
"save": "변경사항 저장",
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사",
"deleteWord": "트리거 단어 삭제",
"suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "Отменить редактирование",
"save": "Сохранить изменения",
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово",
"deleteWord": "Удалить триггерное слово",
"suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "取消编辑",
"save": "保存更改",
"addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"deleteWord": "删除触发词",
"suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "取消編輯",
"save": "儲存變更",
"addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"deleteWord": "刪除觸發詞",
"suggestions": {

View File

@@ -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):

View File

@@ -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 */

View File

@@ -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;
}
}

View File

@@ -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()) {

View File

@@ -49,7 +49,7 @@ describe("TriggerWords HTML Escaping", () => {
expect(html).toContain('data-word="word&#39;with&#39;quotes"');
expect(html).toContain('data-word="&lt;tag&gt;"');
// 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');
});
});

View 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"]);
});
});

View File

@@ -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