mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-24 06:21:26 -03:00
feat(ui): improve tag autocomplete toggle discoverability in prompt nodes
- Add Tag Autocomplete ON/OFF entry to the Prompt (LoraManager) node right-click menu, cross-referencing the slash commands - Show the current autocomplete state (/autocomplete or /noautocomplete hint) below the slash command list - Show a one-time dismissible tip in the suggestion dropdown on first use - Clarify toggle command labels (Turn autocomplete ON/OFF) and cross-link all three entry points in the settings tooltip - Share the setting write path via setLoraManagerSettingValue()
This commit is contained in:
@@ -2032,4 +2032,122 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
||||
expect(calledUrl).toContain('recursive=true');
|
||||
});
|
||||
|
||||
describe('discoverability hints', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const typeSlashCommand = async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/';
|
||||
input.selectionStart = 1;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return autoComplete;
|
||||
};
|
||||
|
||||
it('shows the current autocomplete state below the slash command list', async () => {
|
||||
const autoComplete = await typeSlashCommand();
|
||||
|
||||
const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer.textContent).toContain('/noautocomplete to disable');
|
||||
});
|
||||
|
||||
it('shows how to re-enable autocomplete in the footer when it is off', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const autoComplete = await typeSlashCommand();
|
||||
|
||||
const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer.textContent).toContain('/autocomplete to enable');
|
||||
});
|
||||
|
||||
it('stays silent when typing with tag autocomplete disabled', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.autocomplete_accept_key') {
|
||||
return 'both';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'hello';
|
||||
input.selectionStart = 5;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('hello');
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
expect(autoComplete.isVisible).toBe(false);
|
||||
expect(fetchApiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a dismissible first-run hint on tag suggestions and remembers dismissal', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [{ tag_name: '1girl', category: 4, post_count: 500000 }],
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
|
||||
|
||||
const triggerSearch = async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1gi';
|
||||
input.selectionStart = 3;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
return autoComplete;
|
||||
};
|
||||
|
||||
const autoComplete = await triggerSearch();
|
||||
|
||||
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
|
||||
expect(hint).not.toBeNull();
|
||||
expect(hint.textContent).toContain('/noautocomplete');
|
||||
|
||||
hint.querySelector('button').click();
|
||||
|
||||
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||
expect(localStorage.getItem('lm:autocomplete-disable-tip-dismissed')).toBe('1');
|
||||
|
||||
// A fresh instance no longer shows the hint once dismissed
|
||||
const autoComplete2 = await triggerSearch();
|
||||
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+147
-19
@@ -17,9 +17,13 @@ import {
|
||||
getLoraActiveFiltersAutocompletePreference,
|
||||
getPromptTagAutocompletePreference,
|
||||
getTagSpaceReplacementPreference,
|
||||
setLoraManagerSettingValue,
|
||||
} from "./settings.js";
|
||||
import { showToast } from "./utils.js";
|
||||
|
||||
// localStorage key for the one-time "how to disable" hint in the dropdown
|
||||
const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed';
|
||||
|
||||
// Command definitions for category filtering
|
||||
const TAG_COMMANDS = {
|
||||
'/character': { categories: [4, 11], label: 'Character' },
|
||||
@@ -37,14 +41,14 @@ const TAG_COMMANDS = {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.prompt_tag_autocomplete',
|
||||
value: true,
|
||||
label: 'Autocomplete: ON',
|
||||
label: 'Turn autocomplete ON',
|
||||
condition: () => !getPromptTagAutocompletePreference()
|
||||
},
|
||||
'/noautocomplete': {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.prompt_tag_autocomplete',
|
||||
value: false,
|
||||
label: 'Autocomplete: OFF',
|
||||
label: 'Turn autocomplete OFF',
|
||||
condition: () => getPromptTagAutocompletePreference()
|
||||
},
|
||||
};
|
||||
@@ -55,7 +59,7 @@ const LORAS_COMMANDS = {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||
value: true,
|
||||
label: 'Active Filters: ON',
|
||||
label: 'Turn active filters search ON',
|
||||
feedbackSummary: 'Active Filters Search: ON',
|
||||
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
|
||||
condition: () => !getLoraActiveFiltersAutocompletePreference()
|
||||
@@ -64,7 +68,7 @@ const LORAS_COMMANDS = {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||
value: false,
|
||||
label: 'Active Filters: OFF',
|
||||
label: 'Turn active filters search OFF',
|
||||
feedbackSummary: 'Active Filters Search: OFF',
|
||||
feedbackDetail: 'LoRA autocomplete searches the full library again.',
|
||||
condition: () => getLoraActiveFiltersAutocompletePreference()
|
||||
@@ -72,8 +76,7 @@ const LORAS_COMMANDS = {
|
||||
};
|
||||
|
||||
// Category display information
|
||||
const CATEGORY_INFO = {
|
||||
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||||
const CATEGORY_INFO = { 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||||
1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
|
||||
3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
|
||||
4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
|
||||
@@ -471,6 +474,10 @@ class AutoComplete {
|
||||
this.searchType = null;
|
||||
this.suppressAutocompleteOnce = false;
|
||||
|
||||
// Discoverability hints state
|
||||
this.commandListFooter = null; // State hint shown below the slash command list
|
||||
this.firstRunHint = null; // One-time "how to disable" bar inside the dropdown
|
||||
|
||||
// Virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.hasMoreItems = true;
|
||||
@@ -829,7 +836,9 @@ class AutoComplete {
|
||||
searchTerm = rawSearchTerm;
|
||||
this.searchType = 'custom_words';
|
||||
} else {
|
||||
// No command and setting disabled - no autocomplete for direct typing
|
||||
// No command and setting disabled - no autocomplete for direct typing.
|
||||
// Re-enable discovery is covered by the command-list footer,
|
||||
// the node context menu and the settings tooltip.
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
@@ -1708,12 +1717,131 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
// State hint below the command list (e.g. how to toggle autocomplete)
|
||||
this._renderCommandListFooter();
|
||||
|
||||
// Update virtual scroll height for virtual scrolling mode
|
||||
if (this.contentContainer) {
|
||||
this.updateVirtualScrollHeight();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a state hint below the slash command list so the autocomplete
|
||||
* toggle commands explain themselves. Only applies to prompt nodes.
|
||||
*/
|
||||
_renderCommandListFooter() {
|
||||
this._removeCommandListFooter();
|
||||
|
||||
if (this.modelType !== 'prompt') {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = getPromptTagAutocompletePreference();
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'lm-autocomplete-command-footer';
|
||||
footer.textContent = enabled
|
||||
? 'Tag autocomplete is ON — /noautocomplete to disable'
|
||||
: 'Tag autocomplete is OFF — /autocomplete to enable';
|
||||
footer.style.cssText = `
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.1);
|
||||
white-space: nowrap;
|
||||
`;
|
||||
// Keep focus in the textarea when the hint is clicked
|
||||
footer.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
|
||||
this.dropdown.appendChild(footer);
|
||||
this.commandListFooter = footer;
|
||||
}
|
||||
|
||||
_removeCommandListFooter() {
|
||||
if (this.commandListFooter) {
|
||||
this.commandListFooter.remove();
|
||||
this.commandListFooter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a one-time, dismissible hint inside the dropdown telling users how
|
||||
* to disable tag autocomplete. Dismissal is persisted in localStorage.
|
||||
*/
|
||||
_maybeShowFirstRunHint() {
|
||||
if (this.firstRunHint) {
|
||||
return;
|
||||
}
|
||||
if (this.modelType !== 'prompt'
|
||||
|| this.showingCommands
|
||||
|| this.searchType !== 'custom_words'
|
||||
|| this.activeCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1';
|
||||
} catch (e) {
|
||||
// localStorage unavailable - fall through and show the hint
|
||||
}
|
||||
if (dismissed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'lm-autocomplete-first-run-hint';
|
||||
hint.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(226, 232, 240, 0.6);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||
`;
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Tip: type /noautocomplete to turn off these suggestions';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.title = 'Dismiss';
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
closeBtn.addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1');
|
||||
} catch (e) {
|
||||
}
|
||||
this._removeFirstRunHint();
|
||||
});
|
||||
|
||||
hint.appendChild(text);
|
||||
hint.appendChild(closeBtn);
|
||||
// Keep focus in the textarea when interacting with the hint
|
||||
hint.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
|
||||
this.dropdown.insertBefore(hint, this.dropdown.firstChild);
|
||||
this.firstRunHint = hint;
|
||||
}
|
||||
|
||||
_removeFirstRunHint() {
|
||||
if (this.firstRunHint) {
|
||||
this.firstRunHint.remove();
|
||||
this.firstRunHint = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a command into the input
|
||||
* @param {string} command - The command to insert (e.g., "/character")
|
||||
@@ -1745,6 +1873,9 @@ class AutoComplete {
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
|
||||
// Command-list state hints do not belong to regular search results
|
||||
this._removeCommandListFooter();
|
||||
|
||||
// Reset virtual scroll state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.currentPage = 0;
|
||||
@@ -2447,6 +2578,7 @@ class AutoComplete {
|
||||
return;
|
||||
}
|
||||
|
||||
this._maybeShowFirstRunHint();
|
||||
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
|
||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||
this.dropdown.style.display = 'block';
|
||||
@@ -2516,6 +2648,10 @@ class AutoComplete {
|
||||
this.hasManualSelection = false;
|
||||
this.showingCommands = false;
|
||||
|
||||
// Remove discoverability hints attached to the dropdown
|
||||
this._removeCommandListFooter();
|
||||
this._removeFirstRunHint();
|
||||
|
||||
// Clear items to prevent stale data from being displayed
|
||||
// when autocomplete is shown again
|
||||
this.items = [];
|
||||
@@ -2854,20 +2990,12 @@ class AutoComplete {
|
||||
const { settingId, value } = command;
|
||||
|
||||
try {
|
||||
// Use ComfyUI's setting API to update global setting
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (settingManager && typeof settingManager.set === 'function') {
|
||||
await settingManager.set(settingId, value);
|
||||
const success = await setLoraManagerSettingValue(settingId, value);
|
||||
if (success) {
|
||||
this._showToggleFeedback(command, value);
|
||||
this._clearCurrentToken();
|
||||
} else {
|
||||
// Fallback: use legacy settings API
|
||||
const setting = app.ui.settings.settingsById?.[settingId];
|
||||
if (setting) {
|
||||
app.ui.settings.setSettingValue(settingId, value);
|
||||
this._showToggleFeedback(command, value);
|
||||
this._clearCurrentToken();
|
||||
}
|
||||
throw new Error('settings API unavailable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Lora Manager] Failed to toggle setting:', error);
|
||||
@@ -2893,7 +3021,7 @@ class AutoComplete {
|
||||
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
|
||||
detail: command.feedbackDetail || (enabled
|
||||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||||
: 'Tag autocomplete is now OFF. Use /autocomplete to re-enable.'),
|
||||
: 'Tag autocomplete is now OFF. Use /autocomplete or the node right-click menu to re-enable.'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import {
|
||||
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
|
||||
getPromptTagAutocompletePreference,
|
||||
setLoraManagerSettingValue,
|
||||
} from "./settings.js";
|
||||
import { showToast } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Extension for PromptLM node to support dynamic trigger_words inputs.
|
||||
@@ -93,6 +99,48 @@ app.registerExtension({
|
||||
|
||||
return onConnectionsChange?.apply?.(this, arguments);
|
||||
};
|
||||
|
||||
// Expose the tag autocomplete toggle in the node's right-click menu so
|
||||
// users can discover the switch where the behavior actually happens,
|
||||
// instead of only via slash commands or the global settings dialog.
|
||||
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
getExtraMenuOptions?.apply?.(this, arguments);
|
||||
|
||||
options.push(null);
|
||||
|
||||
const autocompleteEnabled = getPromptTagAutocompletePreference();
|
||||
options.push({
|
||||
content: autocompleteEnabled
|
||||
? "Tag Autocomplete: ON (/noautocomplete to disable)"
|
||||
: "Tag Autocomplete: OFF (/autocomplete to enable)",
|
||||
callback: async () => {
|
||||
const newValue = !autocompleteEnabled;
|
||||
try {
|
||||
const success = await setLoraManagerSettingValue(PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, newValue);
|
||||
if (!success) {
|
||||
throw new Error("settings API unavailable");
|
||||
}
|
||||
showToast({
|
||||
severity: newValue ? 'success' : 'secondary',
|
||||
summary: newValue ? 'Autocomplete Enabled' : 'Autocomplete Disabled',
|
||||
detail: newValue
|
||||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||||
: 'Tag autocomplete is now OFF. Type /autocomplete in the prompt field to re-enable.',
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Lora Manager] Failed to toggle setting:', error);
|
||||
showToast({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to toggle autocomplete setting',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
nodeCreated(node, app) {
|
||||
|
||||
+23
-1
@@ -172,6 +172,26 @@ const getPromptTagAutocompletePreference = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Persist a LoRA Manager setting through ComfyUI's setting API.
|
||||
* Returns true when the setting was written successfully.
|
||||
*/
|
||||
const setLoraManagerSettingValue = async (settingId, value) => {
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (settingManager && typeof settingManager.set === "function") {
|
||||
await settingManager.set(settingId, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
const setting = app?.ui?.settings?.settingsById?.[settingId];
|
||||
if (setting) {
|
||||
app.ui.settings.setSettingValue(settingId, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getAutocompleteAppendCommaPreference = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
@@ -422,7 +442,7 @@ app.registerExtension({
|
||||
name: "Enable Tag Autocomplete in Prompt Nodes",
|
||||
type: "boolean",
|
||||
defaultValue: PROMPT_TAG_AUTOCOMPLETE_DEFAULT,
|
||||
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
|
||||
tooltip: "When enabled, typing in a Prompt (LoraManager) node triggers tag autocomplete suggestions. You can also toggle it by typing /autocomplete or /noautocomplete in the node, or from the node's right-click menu. Slash commands (e.g., /character, /artist) always work regardless of this setting.",
|
||||
category: ["LoRA Manager", "Autocomplete", "Prompt"],
|
||||
},
|
||||
{
|
||||
@@ -576,6 +596,7 @@ app.registerExtension({
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
|
||||
getWheelSensitivity,
|
||||
getAutoPathCorrectionPreference,
|
||||
getAutocompleteAppendCommaPreference,
|
||||
@@ -587,4 +608,5 @@ export {
|
||||
getNewTabTemplatePreference,
|
||||
getStrengthStepPreference,
|
||||
getLoraActiveFiltersAutocompletePreference,
|
||||
setLoraManagerSettingValue,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user