fix(autocomplete): sync active filters via server-side store (#1091)

The LoRA Manager page kept its active filters in localStorage, which the
ComfyUI-side autocomplete read directly. When the two run in different
browsers, origins, or the ComfyUI Desktop Electron shell, localStorage is
not shared and the active-filters search silently did nothing.

The manager page now mirrors its filter state to a server-side in-memory
store (PUT /api/lm/{prefix}/active-filters), pushed on every change via a
storage-listener hook and once on page load. The autocomplete widget sends
only use_active_filters=true, and the relative-paths endpoint injects the
stored filters into the search, with explicit query params taking
precedence.
This commit is contained in:
Will Miao
2026-09-02 14:33:44 +08:00
parent 6b41c3bbb4
commit 00095a5398
14 changed files with 952 additions and 112 deletions
@@ -0,0 +1,136 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};
vi.mock(API_MODULE, () => ({
api: {
fetchApi: fetchApiMock,
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
extensionManager: {
setting: {
get: settingGetMock,
set: vi.fn(),
},
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
}));
async function createAutoComplete(modelType, activeFiltersEnabled) {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return activeFiltersEnabled;
}
if (key === 'loramanager.autocomplete_append_comma') return false;
if (key === 'loramanager.autocomplete_auto_format') return false;
if (key === 'loramanager.autocomplete_accept_key') return 'both';
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: [] }),
});
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, modelType, { debounceDelay: 0, showPreview: false });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
return autoComplete;
}
describe('AutoComplete active-filters flag', () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = '';
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset();
settingGetMock.mockReset();
caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
});
afterEach(() => {
vi.useRealTimers();
});
it('sends use_active_filters for loras when the setting is enabled', async () => {
await createAutoComplete('loras', true);
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true'
);
});
it('omits the flag when the setting is disabled', async () => {
await createAutoComplete('loras', false);
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('omits the flag for non-lora model types even when enabled', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [] }),
});
await createAutoComplete('prompt', true);
for (const call of fetchApiMock.mock.calls) {
expect(call[0]).not.toContain('use_active_filters');
}
});
it('does not read filter state from localStorage anymore', async () => {
localStorage.setItem('lora_manager_loras_activeFolder', 'SD_XL');
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ baseModel: ['SDXL 1.0'] }));
await createAutoComplete('loras', true);
for (const call of fetchApiMock.mock.calls) {
expect(call[0]).not.toContain('folder=');
expect(call[0]).not.toContain('base_model=');
}
});
});
@@ -1789,7 +1789,7 @@ describe('AutoComplete widget interactions', () => {
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('appends active filter params to loras autocomplete requests when enabled', async () => {
it('sends only the use_active_filters flag when enabled (filters resolved server-side)', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1799,12 +1799,11 @@ describe('AutoComplete widget interactions', () => {
return undefined;
});
// Stored manager-page filters must NOT leak into the request URL; the
// backend injects them from its server-side store.
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
autoTags: { I2V: 'include' },
modelTypes: ['standard'],
tagLogic: 'all',
tags: { anime: 'include', nsfw: 'exclude' },
license: { noCredit: 'include', allowSelling: 'exclude' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
@@ -1830,19 +1829,7 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
expect(calledUrl).toContain('folder=MyLoras');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('tag_include=anime');
expect(calledUrl).toContain('tag_exclude=nsfw');
expect(calledUrl).toContain('tag_exclude=__no_tags__');
expect(calledUrl).toContain('auto_tag_include=I2V');
expect(calledUrl).toContain('tag_logic=all');
expect(calledUrl).toContain('credit_required=false');
expect(calledUrl).toContain('allow_selling_generated_content=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
expect(parsed.searchParams.get('model_type')).toBe('standard');
expect(calledUrl).toBe('/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true');
});
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
@@ -1870,10 +1857,12 @@ describe('AutoComplete widget interactions', () => {
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('sends the filter-pipeline signal even when no filters are stored', async () => {
it('sends the filter-pipeline flag even when no filters are stored', async () => {
// Regression: with filter mode on but no folder/filters stored, the request
// carried no params, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint.
// carried no signal, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint. The flag
// makes the backend run the pipeline (injecting nothing when its store
// is empty).
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1907,10 +1896,13 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('use_active_filters=true');
});
it('omits folder param when active folder is root and recursion is enabled', async () => {
it('leaves folder params to the backend when active folder is root with recursion enabled', async () => {
// The root-folder/recursion semantics now live server-side (see
// active_filters_store.active_filters_to_query_kwargs); the client only
// sends the flag.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1948,10 +1940,12 @@ describe('AutoComplete widget interactions', () => {
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('use_active_filters=true');
});
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
it('leaves the root+non-recursive folder mapping to the backend', async () => {
// Root with recursion disabled maps to folder='' server-side (mirroring
// the page list); the client no longer encodes this in the URL.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1988,15 +1982,14 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=');
expect(calledUrl).toContain('recursive=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('folder')).toBe('');
expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('use_active_filters=true');
});
it('applies the active folder even when no filter-panel filters are set', async () => {
it('sends the flag even when only a folder is stored (no filter-panel filters)', async () => {
// Regression: folder was skipped when lora_manager_loras_filters was
// missing because the filters key gate returned early.
// missing because the filters key gate returned early. The flag is now
// unconditional, and the backend injects the folder from its store.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -2029,8 +2022,8 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('use_active_filters=true');
expect(calledUrl).not.toContain('folder=');
});
describe('discoverability hints', () => {
@@ -0,0 +1,130 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { setStorageItem, removeStorageItem, setActiveFiltersListener } from '../../../static/js/utils/storageHelpers.js';
import { initActiveFiltersSync, pushActiveFilters } from '../../../static/js/utils/activeFiltersSync.js';
const okResponse = () => ({ ok: true, status: 200 });
describe('activeFiltersSync', () => {
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(() => Promise.resolve(okResponse()));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('pushes current state immediately on init', async () => {
setStorageItem('loras_activeFolder', 'SD_XL');
setStorageItem('loras_recursiveSearch', false);
setStorageItem('loras_filters', { baseModel: ['SDXL 1.0'], tags: { anime: 'include' } });
initActiveFiltersSync('loras');
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/lm/loras/active-filters');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({
activeFolder: 'SD_XL',
recursiveSearch: false,
filters: { baseModel: ['SDXL 1.0'], tags: { anime: 'include' } },
});
});
it('syncs with debounce when a filter key changes', async () => {
vi.useFakeTimers();
initActiveFiltersSync('loras');
fetchMock.mockClear();
setStorageItem('loras_activeFolder', 'anime');
setStorageItem('loras_activeFolder', 'anime/sub');
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.activeFolder).toBe('anime/sub');
});
it('does not sync for unrelated storage keys', async () => {
vi.useFakeTimers();
initActiveFiltersSync('loras');
fetchMock.mockClear();
setStorageItem('loras_sort', 'name');
setStorageItem('theme', 'dark');
await vi.advanceTimersByTimeAsync(1000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('pushes null filters after the filters key is removed', async () => {
vi.useFakeTimers();
setStorageItem('loras_filters', { baseModel: ['Pony'] });
initActiveFiltersSync('loras');
fetchMock.mockClear();
removeStorageItem('loras_filters');
await vi.advanceTimersByTimeAsync(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.filters).toBeNull();
});
it('normalizes the legacy "null" folder string to null', async () => {
localStorage.setItem('lora_manager_loras_activeFolder', 'null');
await pushActiveFilters('loras');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.activeFolder).toBeNull();
expect(body.recursiveSearch).toBe(true);
});
it('warns instead of throwing when the request fails', async () => {
fetchMock.mockRejectedValue(new Error('network down'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(pushActiveFilters('loras')).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('storageHelpers active-filter listener', () => {
afterEach(() => {
setActiveFiltersListener(null);
});
it('notifies with the page type for filter keys', () => {
const listener = vi.fn();
setActiveFiltersListener(listener);
setStorageItem('loras_activeFolder', 'a');
setStorageItem('checkpoints_recursiveSearch', true);
removeStorageItem('embeddings_filters');
expect(listener.mock.calls.map((call) => call[0])).toEqual([
'loras',
'checkpoints',
'embeddings',
]);
});
it('ignores non-filter keys', () => {
const listener = vi.fn();
setActiveFiltersListener(listener);
setStorageItem('loras_sort', 'name');
removeStorageItem('version_info');
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,197 @@
import json
import logging
from types import SimpleNamespace
import pytest
from multidict import MultiDict
from py.routes.handlers.model_handlers import ModelQueryHandler
from py.services.active_filters_store import ActiveFiltersStore
class DummyService:
model_type = "loras"
def __init__(self):
self.calls = []
async def search_relative_paths(self, search, limit, offset, **kwargs):
self.calls.append((search, limit, offset, kwargs))
return []
def make_handler(service=None):
return ModelQueryHandler(
service=service or DummyService(), logger=logging.getLogger(__name__)
)
def make_request(query=None, body=None, raise_on_json=False):
async def json_body():
if raise_on_json:
raise ValueError("bad json")
return body
return SimpleNamespace(
query=MultiDict(query or {}),
json=json_body,
)
@pytest.fixture(autouse=True)
def reset_store():
ActiveFiltersStore.reset_instance()
yield
ActiveFiltersStore.reset_instance()
@pytest.mark.asyncio
async def test_update_active_filters_stores_sanitized_payload():
handler = make_handler()
response = await handler.update_active_filters(
make_request(
body={
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {"baseModel": ["SDXL 1.0"], "rogue": "dropped"},
"rogue": "dropped",
}
)
)
assert response.status == 200
stored = ActiveFiltersStore.get_instance().get_filters("loras")
assert stored == {
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {"baseModel": ["SDXL 1.0"]},
}
@pytest.mark.asyncio
async def test_update_active_filters_rejects_invalid_json():
handler = make_handler()
response = await handler.update_active_filters(
make_request(raise_on_json=True)
)
assert response.status == 400
@pytest.mark.asyncio
async def test_update_active_filters_rejects_non_object_body():
handler = make_handler()
response = await handler.update_active_filters(make_request(body=["not", "dict"]))
assert response.status == 400
@pytest.mark.asyncio
async def test_get_active_filters_returns_stored_payload():
ActiveFiltersStore.get_instance().set_filters(
"loras", {"activeFolder": "anime", "recursiveSearch": True, "filters": None}
)
handler = make_handler()
response = await handler.get_active_filters(make_request())
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["filters"]["activeFolder"] == "anime"
@pytest.mark.asyncio
async def test_get_active_filters_returns_null_when_unset():
handler = make_handler()
response = await handler.get_active_filters(make_request())
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["filters"] is None
@pytest.mark.asyncio
async def test_relative_paths_injects_stored_active_filters():
ActiveFiltersStore.get_instance().set_filters(
"loras",
{
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {
"baseModel": ["SDXL 1.0"],
"tags": {"anime": "include"},
"tagLogic": "all",
},
},
)
service = DummyService()
handler = make_handler(service)
response = await handler.get_relative_paths(
make_request({"search": "cartoon", "use_active_filters": "true"})
)
assert response.status == 200
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] == "SD_XL"
assert kwargs["recursive"] is False
assert kwargs["base_models"] == ["SDXL 1.0"]
assert kwargs["tags"] == {"anime": "include"}
assert kwargs["tag_logic"] == "all"
assert kwargs["apply_filters"] is True
@pytest.mark.asyncio
async def test_relative_paths_explicit_params_take_precedence():
ActiveFiltersStore.get_instance().set_filters(
"loras",
{
"activeFolder": "SD_XL",
"recursiveSearch": True,
"filters": {"baseModel": ["SDXL 1.0"]},
},
)
service = DummyService()
handler = make_handler(service)
await handler.get_relative_paths(
make_request(
{
"search": "cartoon",
"use_active_filters": "true",
"folder": "pony",
"base_model": "Pony",
}
)
)
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] == "pony"
assert kwargs["base_models"] == ["Pony"]
@pytest.mark.asyncio
async def test_relative_paths_empty_store_still_runs_filter_pipeline():
service = DummyService()
handler = make_handler(service)
await handler.get_relative_paths(
make_request({"search": "cartoon", "use_active_filters": "true"})
)
_, _, _, kwargs = service.calls[0]
assert kwargs["apply_filters"] is True
assert kwargs["folder"] is None
assert kwargs["base_models"] == []
@pytest.mark.asyncio
async def test_relative_paths_without_flag_ignores_store():
ActiveFiltersStore.get_instance().set_filters(
"loras", {"activeFolder": "SD_XL", "recursiveSearch": True, "filters": None}
)
service = DummyService()
handler = make_handler(service)
await handler.get_relative_paths(make_request({"search": "cartoon"}))
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] is None
assert kwargs["apply_filters"] is False
+128
View File
@@ -0,0 +1,128 @@
import pytest
from py.services.active_filters_store import (
ActiveFiltersStore,
active_filters_to_query_kwargs,
)
@pytest.fixture(autouse=True)
def reset_store():
ActiveFiltersStore.reset_instance()
yield
ActiveFiltersStore.reset_instance()
def test_store_roundtrip():
store = ActiveFiltersStore.get_instance()
payload = {
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {"baseModel": ["SDXL 1.0"], "tags": {"anime": "include"}},
}
store.set_filters("loras", payload)
assert store.get_filters("loras") == payload
assert store.get_filters("checkpoints") is None
def test_store_sanitizes_payload():
store = ActiveFiltersStore.get_instance()
store.set_filters(
"loras",
{
"activeFolder": "anime",
"recursiveSearch": True,
"filters": {"baseModel": [], "unexpected": "dropped"},
"extra": "dropped",
},
)
stored = store.get_filters("loras")
assert stored == {
"activeFolder": "anime",
"recursiveSearch": True,
"filters": {"baseModel": []},
}
def test_store_non_dict_filters_become_none():
store = ActiveFiltersStore.get_instance()
store.set_filters("loras", {"activeFolder": None, "filters": "garbage"})
assert store.get_filters("loras")["filters"] is None
def test_store_clear():
store = ActiveFiltersStore.get_instance()
store.set_filters("loras", {"activeFolder": "x"})
store.clear("loras")
assert store.get_filters("loras") is None
def test_mapping_empty_payload():
assert active_filters_to_query_kwargs(None) == {}
assert active_filters_to_query_kwargs({}) == {}
assert active_filters_to_query_kwargs({"activeFolder": None}) == {"recursive": True}
def test_mapping_folder():
assert active_filters_to_query_kwargs(
{"activeFolder": "SD_XL", "recursiveSearch": True}
) == {"folder": "SD_XL", "recursive": True}
def test_mapping_root_folder_non_recursive():
# Root folder with recursion disabled matches only root-level files
assert active_filters_to_query_kwargs(
{"activeFolder": None, "recursiveSearch": False}
) == {"folder": "", "recursive": False}
def test_mapping_legacy_null_string_folder():
assert active_filters_to_query_kwargs(
{"activeFolder": "null", "recursiveSearch": True}
) == {"recursive": True}
def test_mapping_full_filters():
kwargs = active_filters_to_query_kwargs(
{
"activeFolder": "anime",
"recursiveSearch": True,
"filters": {
"baseModel": ["SDXL 1.0", "Pony"],
"tags": {"anime": "include", "3d": "exclude", "junk": "ignored"},
"autoTags": {"cute": "include"},
"modelTypes": ["LoRA"],
"tagLogic": "all",
"license": {"noCredit": "include", "allowSelling": "exclude"},
},
}
)
assert kwargs == {
"folder": "anime",
"recursive": True,
"base_models": ["SDXL 1.0", "Pony"],
"tags": {"anime": "include", "3d": "exclude"},
"auto_tags": {"cute": "include"},
"model_types": ["LoRA"],
"tag_logic": "all",
"credit_required": False,
"allow_selling_generated_content": False,
}
def test_mapping_license_exclude_variants():
kwargs = active_filters_to_query_kwargs(
{
"filters": {
"license": {"noCredit": "exclude", "allowSelling": "include"},
},
}
)
assert kwargs["credit_required"] is True
assert kwargs["allow_selling_generated_content"] is True