From 00095a5398ddd33e7b067cc61f3426878ffe5bc6 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 2 Sep 2026 14:33:44 +0800 Subject: [PATCH] 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. --- py/routes/handlers/model_handlers.py | 90 +++++++- py/routes/model_route_registrar.py | 2 + py/services/active_filters_store.py | 135 ++++++++++++ static/js/checkpoints.js | 4 + static/js/embeddings.js | 4 + static/js/loras.js | 4 + static/js/utils/activeFiltersSync.js | 61 ++++++ static/js/utils/storageHelpers.js | 31 ++- .../autocomplete.activeFilters.test.js | 136 ++++++++++++ .../components/autocomplete.behavior.test.js | 59 +++--- .../frontend/utils/activeFiltersSync.test.js | 130 ++++++++++++ tests/routes/test_active_filters_handlers.py | 197 ++++++++++++++++++ tests/services/test_active_filters_store.py | 128 ++++++++++++ web/comfyui/autocomplete.js | 83 +------- 14 files changed, 952 insertions(+), 112 deletions(-) create mode 100644 py/services/active_filters_store.py create mode 100644 static/js/utils/activeFiltersSync.js create mode 100644 tests/frontend/components/autocomplete.activeFilters.test.js create mode 100644 tests/frontend/utils/activeFiltersSync.test.js create mode 100644 tests/routes/test_active_filters_handlers.py create mode 100644 tests/services/test_active_filters_store.py diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 8b3784fd..7a849fde 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -15,6 +15,10 @@ from aiohttp import web import jinja2 from ...config import config +from ...services.active_filters_store import ( + ActiveFiltersStore, + active_filters_to_query_kwargs, +) from ...services.download_coordinator import DownloadCoordinator from ...services.connectivity_guard import ( OFFLINE_FRIENDLY_MESSAGE, @@ -1595,12 +1599,50 @@ class ModelQueryHandler: allow_selling_generated_content.lower() not in ("false", "0", "") ) + # When requested, merge the manager page's active filters stored + # server-side. Explicit query parameters take precedence over the + # stored values. + use_active_filters = ( + request.query.get("use_active_filters", "").lower() in ("1", "true") + ) + if use_active_filters: + stored = ActiveFiltersStore.get_instance().get_filters( + self._service.model_type + ) + injected = active_filters_to_query_kwargs(stored) + if folder is None and "folder" in injected: + folder = injected["folder"] + if "recursive" not in request.query and "recursive" in injected: + recursive = injected["recursive"] + if not base_models and injected.get("base_models"): + base_models = injected["base_models"] + if not model_types and injected.get("model_types"): + model_types = injected["model_types"] + if not tag_filters and injected.get("tags"): + tag_filters = injected["tags"] + if not auto_tag_filters and injected.get("auto_tags"): + auto_tag_filters = injected["auto_tags"] + if "tag_logic" not in request.query and injected.get("tag_logic"): + injected_logic = str(injected["tag_logic"]).lower() + if injected_logic in ("any", "all"): + tag_logic = injected_logic + if credit_required is None and "credit_required" in injected: + credit_required = injected["credit_required"] + if ( + allow_selling_generated_content is None + and "allow_selling_generated_content" in injected + ): + allow_selling_generated_content = injected[ + "allow_selling_generated_content" + ] + # The presence of the recursive param (always sent by the loras # widget when filter mode is on) signals that the filter pipeline # must run even when no concrete filter is set, so global settings # like show_only_sfw stay consistent with the list endpoint. apply_filters = ( - "recursive" in request.query + use_active_filters + or "recursive" in request.query or folder is not None or bool(base_models) or bool(model_types) @@ -1634,6 +1676,50 @@ class ModelQueryHandler: ) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def update_active_filters(self, request: web.Request) -> web.Response: + """Store the manager page's active filters for this model type.""" + try: + payload = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) + + if not isinstance(payload, dict): + return web.json_response( + {"success": False, "error": "Body must be a JSON object"}, status=400 + ) + + try: + ActiveFiltersStore.get_instance().set_filters( + self._service.model_type, payload + ) + return web.json_response({"success": True}) + except Exception as exc: + self._logger.error( + "Error updating active filters for %s: %s", + self._service.model_type, + exc, + exc_info=True, + ) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_active_filters(self, request: web.Request) -> web.Response: + """Return the stored active filters for this model type.""" + try: + filters = ActiveFiltersStore.get_instance().get_filters( + self._service.model_type + ) + return web.json_response({"success": True, "filters": filters}) + except Exception as exc: + self._logger.error( + "Error getting active filters for %s: %s", + self._service.model_type, + exc, + exc_info=True, + ) + return web.json_response({"success": False, "error": str(exc)}, status=500) + class ModelDownloadHandler: """Coordinate downloads and progress reporting.""" @@ -3339,6 +3425,8 @@ class ModelHandlerSet: "get_model_metadata": self.query.get_model_metadata, "get_model_description": self.query.get_model_description, "get_relative_paths": self.query.get_relative_paths, + "update_active_filters": self.query.update_active_filters, + "get_active_filters": self.query.get_active_filters, "refresh_model_updates": self.updates.refresh_model_updates, "fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data, "set_model_update_ignore": self.updates.set_model_update_ignore, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 935eb4c8..8796d3f9 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -68,6 +68,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( "GET", "/api/lm/{prefix}/model-description", "get_model_description" ), RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"), + RouteDefinition("PUT", "/api/lm/{prefix}/active-filters", "update_active_filters"), + RouteDefinition("GET", "/api/lm/{prefix}/active-filters", "get_active_filters"), RouteDefinition( "GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions" ), diff --git a/py/services/active_filters_store.py b/py/services/active_filters_store.py new file mode 100644 index 00000000..cee004d8 --- /dev/null +++ b/py/services/active_filters_store.py @@ -0,0 +1,135 @@ +"""In-memory store for the LoRA Manager page's active filters. + +The manager page keeps its filter state in localStorage for its own +restoration, but the ComfyUI node autocomplete runs in a potentially +different browser/origin (or Electron shell) where that storage is not +shared. This store mirrors the active filters server-side so the +``/api/lm/{prefix}/relative-paths`` endpoint can inject them into +autocomplete searches regardless of which client set them. + +State is process-local and intentionally not persisted; the manager page +re-pushes its restored state on load. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +# Keys copied from the manager page's persisted filter snapshot. +_FILTER_KEYS = ( + "baseModel", + "tags", + "autoTags", + "modelTypes", + "tagLogic", + "license", +) + + +class ActiveFiltersStore: + """Process-local store of active filters, keyed by model type.""" + + _instance: Optional["ActiveFiltersStore"] = None + + def __init__(self) -> None: + self._filters: Dict[str, Dict[str, Any]] = {} + + @classmethod + def get_instance(cls) -> "ActiveFiltersStore": + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def reset_instance(cls) -> None: + """Drop the singleton (test isolation).""" + cls._instance = None + + def set_filters(self, model_type: str, payload: Dict[str, Any]) -> None: + """Replace the stored active filters for a model type. + + Only recognized keys are kept; everything else is discarded. + """ + filters = payload.get("filters") + sanitized: Dict[str, Any] = { + "activeFolder": payload.get("activeFolder"), + "recursiveSearch": bool(payload.get("recursiveSearch", True)), + "filters": ( + {key: filters[key] for key in _FILTER_KEYS if key in filters} + if isinstance(filters, dict) + else None + ), + } + self._filters[model_type] = sanitized + + def get_filters(self, model_type: str) -> Optional[Dict[str, Any]]: + """Return the stored payload for a model type, or None if unset.""" + return self._filters.get(model_type) + + def clear(self, model_type: str) -> None: + self._filters.pop(model_type, None) + + +def active_filters_to_query_kwargs(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Map a stored active-filters payload to ``search_relative_paths`` kwargs. + + Mirrors the query-param mapping that the ComfyUI autocomplete used to + build client-side from localStorage (web/comfyui/autocomplete.js). + """ + kwargs: Dict[str, Any] = {} + if not payload: + return kwargs + + active_folder = payload.get("activeFolder") + recursive = payload.get("recursiveSearch", True) + + if active_folder and active_folder != "null": + kwargs["folder"] = active_folder + elif not recursive: + # Root folder with recursion disabled mirrors the page list, + # which matches only root-level files via folder=''. + kwargs["folder"] = "" + + filters = payload.get("filters") + if isinstance(filters, dict): + base_models = filters.get("baseModel") + if isinstance(base_models, list): + kwargs["base_models"] = [m for m in base_models if m] + + for source_key, target_key in (("tags", "tags"), ("autoTags", "auto_tags")): + states = filters.get(source_key) + if isinstance(states, dict): + mapped = { + tag: state + for tag, state in states.items() + if state in ("include", "exclude") + } + if mapped: + kwargs[target_key] = mapped + + model_types = filters.get("modelTypes") + if isinstance(model_types, list): + kwargs["model_types"] = [t for t in model_types if t] + + tag_logic = filters.get("tagLogic") + if tag_logic: + kwargs["tag_logic"] = tag_logic + + license_filter = filters.get("license") + if isinstance(license_filter, dict): + no_credit = license_filter.get("noCredit") + if no_credit == "include": + kwargs["credit_required"] = False + elif no_credit == "exclude": + kwargs["credit_required"] = True + allow_selling = license_filter.get("allowSelling") + if allow_selling == "include": + kwargs["allow_selling_generated_content"] = True + elif allow_selling == "exclude": + kwargs["allow_selling_generated_content"] = False + + kwargs["recursive"] = recursive + return kwargs diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index de341008..84d51485 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr import { createPageControls } from './components/controls/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; +import { initActiveFiltersSync } from './utils/activeFiltersSync.js'; // Initialize the Checkpoints page export class CheckpointsPageManager { @@ -32,6 +33,9 @@ export class CheckpointsPageManager { // Initialize common page features (including context menus) appCore.initializePageFeatures(); + // Mirror active filters to the backend for the ComfyUI-side autocomplete + initActiveFiltersSync(MODEL_TYPES.CHECKPOINT); + console.log('Checkpoints Manager initialized'); } } diff --git a/static/js/embeddings.js b/static/js/embeddings.js index 654d250c..90547d78 100644 --- a/static/js/embeddings.js +++ b/static/js/embeddings.js @@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr import { createPageControls } from './components/controls/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; +import { initActiveFiltersSync } from './utils/activeFiltersSync.js'; // Initialize the Embeddings page class EmbeddingsPageManager { @@ -32,6 +33,9 @@ class EmbeddingsPageManager { // Initialize common page features (including context menus) appCore.initializePageFeatures(); + // Mirror active filters to the backend for the ComfyUI-side autocomplete + initActiveFiltersSync(MODEL_TYPES.EMBEDDING); + console.log('Embeddings Manager initialized'); } } diff --git a/static/js/loras.js b/static/js/loras.js index 3e2ccec8..86bb9d8a 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -4,6 +4,7 @@ import { updateCardsForBulkMode } from './components/shared/ModelCard.js'; import { createPageControls } from './components/controls/index.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { initActiveFiltersSync } from './utils/activeFiltersSync.js'; // Initialize the LoRA page export class LoraPageManager { @@ -41,6 +42,9 @@ export class LoraPageManager { // Initialize common page features (including context menus and virtual scroll) appCore.initializePageFeatures(); + + // Mirror active filters to the backend for the ComfyUI-side autocomplete + initActiveFiltersSync('loras'); } } diff --git a/static/js/utils/activeFiltersSync.js b/static/js/utils/activeFiltersSync.js new file mode 100644 index 00000000..1500f2d4 --- /dev/null +++ b/static/js/utils/activeFiltersSync.js @@ -0,0 +1,61 @@ +/** + * Mirrors the manager page's active filter state to the backend's in-memory + * store, so the ComfyUI-side autocomplete can apply it even when the manager + * page and ComfyUI run in different browsers/origins (localStorage is not + * shared there). + */ + +import { getStorageItem, setActiveFiltersListener } from './storageHelpers.js'; +import { debounce } from './debounce.js'; + +const SYNC_DEBOUNCE_MS = 300; + +const debouncedPushByPage = {}; + +function buildActiveFiltersPayload(pageType) { + const activeFolder = getStorageItem(`${pageType}_activeFolder`); + const recursiveSearch = getStorageItem(`${pageType}_recursiveSearch`, true); + const filters = getStorageItem(`${pageType}_filters`); + + return { + // null stays null; legacy "null" string is normalized to null + activeFolder: activeFolder && activeFolder !== 'null' ? activeFolder : null, + recursiveSearch: recursiveSearch !== false, + filters: filters && typeof filters === 'object' ? filters : null, + }; +} + +export async function pushActiveFilters(pageType) { + try { + const response = await fetch(`/api/lm/${pageType}/active-filters`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildActiveFiltersPayload(pageType)), + }); + if (!response.ok) { + console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}: HTTP ${response.status}`); + } + } catch (error) { + console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}:`, error); + } +} + +export function syncActiveFilters(pageType) { + if (!debouncedPushByPage[pageType]) { + debouncedPushByPage[pageType] = debounce(() => { + pushActiveFilters(pageType); + }, SYNC_DEBOUNCE_MS); + } + debouncedPushByPage[pageType](); +} + +/** + * Register the storage listener and push the current (restored) state once. + * The initial push covers server restarts, where the backend store is empty + * until the manager page re-publishes its localStorage-restored filters. + * @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' + */ +export function initActiveFiltersSync(pageType) { + setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType)); + pushActiveFilters(pageType); +} diff --git a/static/js/utils/storageHelpers.js b/static/js/utils/storageHelpers.js index 6b7a8f4f..4250605a 100644 --- a/static/js/utils/storageHelpers.js +++ b/static/js/utils/storageHelpers.js @@ -6,6 +6,31 @@ // Namespace prefix for all localStorage keys const STORAGE_PREFIX = 'lora_manager_'; +// Matches keys that carry the manager page's active filter state +// (e.g. 'loras_activeFolder', 'checkpoints_filters'). +const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/; + +let activeFiltersListener = null; + +/** + * Register a listener invoked with the page type whenever one of the + * active-filter storage keys changes. Used to mirror filter state to the + * backend so the ComfyUI-side autocomplete can pick it up across + * browsers/origins where localStorage is not shared. + * @param {function(string): void} listener + */ +export function setActiveFiltersListener(listener) { + activeFiltersListener = listener; +} + +function notifyActiveFiltersChanged(key) { + if (!activeFiltersListener) return; + const match = ACTIVE_FILTER_KEY_PATTERN.exec(key); + if (match) { + activeFiltersListener(match[1]); + } +} + /** * Get an item from localStorage with namespace support and fallback to legacy keys * @param {string} key - The key without prefix @@ -51,13 +76,15 @@ export function getStorageItem(key, defaultValue = null) { */ export function setStorageItem(key, value) { const prefixedKey = STORAGE_PREFIX + key; - + // Convert objects and arrays to JSON strings if (typeof value === 'object' && value !== null) { localStorage.setItem(prefixedKey, JSON.stringify(value)); } else { localStorage.setItem(prefixedKey, value); } + + notifyActiveFiltersChanged(key); } /** @@ -67,6 +94,8 @@ export function setStorageItem(key, value) { export function removeStorageItem(key) { localStorage.removeItem(STORAGE_PREFIX + key); localStorage.removeItem(key); // Also remove legacy key + + notifyActiveFiltersChanged(key); } /** diff --git a/tests/frontend/components/autocomplete.activeFilters.test.js b/tests/frontend/components/autocomplete.activeFilters.test.js new file mode 100644 index 00000000..9e8d6a79 --- /dev/null +++ b/tests/frontend/components/autocomplete.activeFilters.test.js @@ -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='); + } + }); +}); diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index e3da890a..c0b7ae65 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -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', () => { diff --git a/tests/frontend/utils/activeFiltersSync.test.js b/tests/frontend/utils/activeFiltersSync.test.js new file mode 100644 index 00000000..60621f6d --- /dev/null +++ b/tests/frontend/utils/activeFiltersSync.test.js @@ -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(); + }); +}); diff --git a/tests/routes/test_active_filters_handlers.py b/tests/routes/test_active_filters_handlers.py new file mode 100644 index 00000000..0b7c369c --- /dev/null +++ b/tests/routes/test_active_filters_handlers.py @@ -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 diff --git a/tests/services/test_active_filters_store.py b/tests/services/test_active_filters_store.py new file mode 100644 index 00000000..3da48c04 --- /dev/null +++ b/tests/services/test_active_filters_store.py @@ -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 diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 90de6639..61681ae0 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -1317,88 +1317,17 @@ class AutoComplete { } /** - * Build a URL-encoded query string from the LoRA Manager page's active - * filters in localStorage, or null when not applicable. + * Return the query flag that tells the backend to inject the LoRA Manager + * page's active filters (stored server-side) into the search, or null when + * not applicable. The filters themselves are synced to the backend by the + * manager page, so this works across browsers/origins where localStorage + * is not shared. */ _getActiveLoraFilters() { if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) { return null; } - try { - const params = new URLSearchParams(); - - const folder = localStorage.getItem('lora_manager_loras_activeFolder'); - const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch'); - const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true'; - - if (folder && folder !== 'null') { - params.append('folder', folder); - } else if (!recursive) { - // Root folder with recursion disabled mirrors the page list, - // which matches only root-level files via folder=''. - params.append('folder', ''); - } - - const raw = localStorage.getItem('lora_manager_loras_filters'); - if (raw) { - const filters = JSON.parse(raw); - - if (Array.isArray(filters.baseModel)) { - filters.baseModel.forEach((m) => m && params.append('base_model', m)); - } - - if (filters.tags && typeof filters.tags === 'object') { - Object.entries(filters.tags).forEach(([tag, state]) => { - if (state === 'include') { - params.append('tag_include', tag); - } else if (state === 'exclude') { - params.append('tag_exclude', tag); - } - }); - } - - if (filters.autoTags && typeof filters.autoTags === 'object') { - Object.entries(filters.autoTags).forEach(([tag, state]) => { - if (state === 'include') { - params.append('auto_tag_include', tag); - } else if (state === 'exclude') { - params.append('auto_tag_exclude', tag); - } - }); - } - - if (Array.isArray(filters.modelTypes)) { - filters.modelTypes.forEach((t) => t && params.append('model_type', t)); - } - - if (filters.tagLogic) { - params.append('tag_logic', filters.tagLogic); - } - - if (filters.license) { - if (filters.license.noCredit === 'include') { - params.append('credit_required', 'false'); - } else if (filters.license.noCredit === 'exclude') { - params.append('credit_required', 'true'); - } - if (filters.license.allowSelling === 'include') { - params.append('allow_selling_generated_content', 'true'); - } else if (filters.license.allowSelling === 'exclude') { - params.append('allow_selling_generated_content', 'false'); - } - } - } - - // Always send recursive in filter mode — its presence also signals - // the backend to run the filter pipeline (e.g. show_only_sfw) even - // when no concrete filter is set, matching the list endpoint. - params.append('recursive', String(recursive)); - - return params.toString(); - } catch (error) { - console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error); - return null; - } + return 'use_active_filters=true'; } async search(term = '', endpoint = null) {