feat(ui): improve /activefilters discoverability on loras nodes

Mirror the /noautocomplete discoverability pattern for the loras\nactive-filters search toggle:\n\n- autocomplete.js: extend the slash-command-list footer and the\n  one-time first-run hint to loras nodes, advertising\n  /activefilters and /noactivefilters\n- lora_loader.js: add an 'Active Filters Search: ON/OFF' entry to the\n  right-click menu of all loras-autocomplete node classes\n- settings.js: broadcast a 'lora-manager:setting-toggled' window event\n  on every setLoraManagerSettingValue write\n- AutocompleteTextWidget.vue: add a persistent filter indicator chip\n  (loras mode only) that reflects and toggles the setting and stays in\n  sync via the setting-toggled event\n- tests: footer/hint/event coverage, context-menu tests for all four\n  node classes, widget indicator tests; rebuild vue-widgets bundle
This commit is contained in:
Will Miao
2026-09-03 22:42:45 +08:00
parent 03569c62df
commit 6ba64ebb3c
9 changed files with 860 additions and 69 deletions
@@ -133,4 +133,157 @@ describe('AutoComplete active-filters flag', () => {
expect(call[0]).not.toContain('base_model=');
}
});
const typeLorasSlashCommand = 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, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
return autoComplete;
};
it('shows the active-filters state below the loras slash command list', async () => {
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: OFF');
expect(footer.textContent).toContain('/activefilters to enable');
});
it('shows how to disable active-filters search in the footer when it is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: ON');
expect(footer.textContent).toContain('/noactivefilters to disable');
});
it('shows a dismissible first-run hint on loras suggestions and remembers dismissal', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const triggerSearch = async () => {
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
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('/activefilters');
hint.querySelector('button').click();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
expect(localStorage.getItem('lm:activefilters-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();
});
it('does not show the loras first-run hint when active-filters search is already on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
it('broadcasts a setting-toggled window event when /activefilters is accepted', async () => {
const events = [];
const listener = (event) => events.push(event.detail);
window.addEventListener('lora-manager:setting-toggled', listener);
try {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// The command token is cleared after acceptance; simulate the caret
// helper seeing the cleared input so the synthetic input event does
// not re-trigger command parsing (same pattern as behavior tests).
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await Promise.resolve();
await Promise.resolve();
expect(events).toContainEqual({
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
});
} finally {
window.removeEventListener('lora-manager:setting-toggled', listener);
}
});
});
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
SETTINGS_MODULE,
LORA_LOADER_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
SETTINGS_MODULE: new URL("../../../web/comfyui/settings.js", import.meta.url).pathname,
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
}));
const extensionState = { current: null };
const registerExtensionMock = vi.fn((extension) => {
extensionState.current = extension;
});
vi.mock(APP_MODULE, () => ({
app: {
registerExtension: registerExtensionMock,
graph: {},
},
}));
vi.mock(API_MODULE, () => ({
api: {
addEventListener: vi.fn(),
},
}));
const showToastMock = vi.fn();
vi.mock(UTILS_MODULE, () => ({
collectActiveLorasFromChain: vi.fn(),
updateConnectedTriggerWords: vi.fn(),
mergeLoras: vi.fn(),
chainCallback: (proto, property, callback) => {
proto[property] = callback;
},
getAllGraphNodes: vi.fn(),
getNodeFromGraph: vi.fn(),
getWidgetByName: vi.fn(),
getWidgetSerializedValue: vi.fn(),
showToast: showToastMock,
}));
const getActiveFiltersPreferenceMock = vi.fn();
const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete",
SETTING_TOGGLED_EVENT_NAME: "lora-manager:setting-toggled",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock,
}));
async function registerNodeType(comfyClass) {
await import(LORA_LOADER_MODULE);
const extension = extensionState.current;
expect(extension).toBeDefined();
const nodeType = { comfyClass, prototype: {} };
await extension.beforeRegisterNodeDef(nodeType, {}, {});
return nodeType;
}
function getMenuOption(nodeType, enabled) {
getActiveFiltersPreferenceMock.mockReturnValue(enabled);
const options = [];
nodeType.prototype.getExtraMenuOptions(null, options);
return options.find(
(option) =>
option &&
typeof option.content === "string" &&
option.content.startsWith("Active Filters Search:")
);
}
describe("Lora Loader active-filters context menu", () => {
beforeEach(() => {
vi.resetModules();
extensionState.current = null;
registerExtensionMock.mockClear();
showToastMock.mockClear();
getActiveFiltersPreferenceMock.mockReset();
setSettingValueMock.mockReset();
setSettingValueMock.mockResolvedValue(true);
});
it.each([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
])("adds the toggle entry to the %s context menu", async (comfyClass) => {
const nodeType = await registerNodeType(comfyClass);
const option = getMenuOption(nodeType, false);
expect(option).toBeDefined();
expect(option.content).toContain("Active Filters Search: OFF");
expect(option.content).toContain("/activefilters to enable");
});
it("shows the disable hint when active-filters search is on", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const option = getMenuOption(nodeType, true);
expect(option.content).toContain("Active Filters Search: ON");
expect(option.content).toContain("/noactivefilters to disable");
});
it("toggles the setting and toasts feedback", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const enableOption = getMenuOption(nodeType, false);
await enableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
true
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Enabled" })
);
const disableOption = getMenuOption(nodeType, true);
await disableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
false
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Disabled" })
);
});
});