diff --git a/locales/de.json b/locales/de.json
index 6c92b9f5..d1b739b8 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -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": {
diff --git a/locales/en.json b/locales/en.json
index ea62d8fe..b4d13ee3 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -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": {
diff --git a/locales/es.json b/locales/es.json
index eee4bf86..8756d351 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -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": {
diff --git a/locales/fr.json b/locales/fr.json
index ac139d19..93c44db8 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -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": {
diff --git a/locales/he.json b/locales/he.json
index f1de1aab..5db782c8 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -1190,6 +1190,8 @@
"cancel": "בטל עריכה",
"save": "שמור שינויים",
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
+ "editWord": "עריכת מילת טריגר",
+ "editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר",
"deleteWord": "מחק מילת טריגר",
"suggestions": {
diff --git a/locales/ja.json b/locales/ja.json
index 4643e63d..c9a16535 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -1190,6 +1190,8 @@
"cancel": "編集をキャンセル",
"save": "変更を保存",
"addPlaceholder": "入力して追加するか、下の提案をクリック",
+ "editWord": "トリガーワードを編集",
+ "editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"deleteWord": "トリガーワードを削除",
"suggestions": {
diff --git a/locales/ko.json b/locales/ko.json
index b1f0fcee..e8111c38 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -1190,6 +1190,8 @@
"cancel": "편집 취소",
"save": "변경사항 저장",
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
+ "editWord": "트리거 단어 편집",
+ "editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사",
"deleteWord": "트리거 단어 삭제",
"suggestions": {
diff --git a/locales/ru.json b/locales/ru.json
index 4b0c9140..7971aa54 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1190,6 +1190,8 @@
"cancel": "Отменить редактирование",
"save": "Сохранить изменения",
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
+ "editWord": "Редактировать триггерное слово",
+ "editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово",
"deleteWord": "Удалить триггерное слово",
"suggestions": {
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index c9606407..d1722fdc 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -1190,6 +1190,8 @@
"cancel": "取消编辑",
"save": "保存更改",
"addPlaceholder": "输入或点击下方建议添加",
+ "editWord": "编辑触发词",
+ "editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"deleteWord": "删除触发词",
"suggestions": {
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 1f19fbfa..2e59a747 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -1190,6 +1190,8 @@
"cancel": "取消編輯",
"save": "儲存變更",
"addPlaceholder": "輸入或點擊下方建議",
+ "editWord": "編輯觸發詞",
+ "editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"deleteWord": "刪除觸發詞",
"suggestions": {
diff --git a/static/css/components/lora-modal/triggerwords.css b/static/css/components/lora-modal/triggerwords.css
index 443a966c..1c695b08 100644
--- a/static/css/components/lora-modal/triggerwords.css
+++ b/static/css/components/lora-modal/triggerwords.css
@@ -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;
-}
\ No newline at end of file
+}
diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js
index 4426a508..98eae424 100644
--- a/static/js/components/shared/TriggerWords.js
+++ b/static/js/components/shared/TriggerWords.js
@@ -297,6 +297,8 @@ export function setupTriggerWordsEditMode() {
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
+ 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');
@@ -353,6 +355,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 +435,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.removeEventListener('click', startEditTriggerWord);
tag.onclick = () => copyTriggerWord(tag.dataset.word);
+ tag.title = translate('modals.model.triggerWords.copyWord');
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
@@ -474,25 +480,158 @@ 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 = `
- ${escapedWord}
-
-
-
-
- `;
- 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 = `
+ ${escapedWord}
+
+
+
+
+ `;
+
+ const deleteBtn = tag.querySelector('.metadata-delete-btn');
+ deleteBtn.addEventListener('click', deleteTriggerWord);
+
+ if (isEditMode) {
+ tag.addEventListener('click', startEditTriggerWord);
+ } else {
+ tag.onclick = () => copyTriggerWord(tag.dataset.word);
+ }
+
+ return tag;
+}
+
+/**
+ * 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 +664,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 +671,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 = `
- ${escapedWord}
-
-
-
-
- `;
-
- // 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 +745,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()) {
diff --git a/tests/frontend/components/triggerWords.inlineEdit.test.js b/tests/frontend/components/triggerWords.inlineEdit.test.js
new file mode 100644
index 00000000..a6b84552
--- /dev/null
+++ b/tests/frontend/components/triggerWords.inlineEdit.test.js
@@ -0,0 +1,138 @@
+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;
+
+ 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;
+ });
+
+ 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("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"]);
+ });
+});