mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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:
@@ -15,6 +15,10 @@ from aiohttp import web
|
|||||||
import jinja2
|
import jinja2
|
||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
|
from ...services.active_filters_store import (
|
||||||
|
ActiveFiltersStore,
|
||||||
|
active_filters_to_query_kwargs,
|
||||||
|
)
|
||||||
from ...services.download_coordinator import DownloadCoordinator
|
from ...services.download_coordinator import DownloadCoordinator
|
||||||
from ...services.connectivity_guard import (
|
from ...services.connectivity_guard import (
|
||||||
OFFLINE_FRIENDLY_MESSAGE,
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
@@ -1595,12 +1599,50 @@ class ModelQueryHandler:
|
|||||||
allow_selling_generated_content.lower() not in ("false", "0", "")
|
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
|
# The presence of the recursive param (always sent by the loras
|
||||||
# widget when filter mode is on) signals that the filter pipeline
|
# widget when filter mode is on) signals that the filter pipeline
|
||||||
# must run even when no concrete filter is set, so global settings
|
# must run even when no concrete filter is set, so global settings
|
||||||
# like show_only_sfw stay consistent with the list endpoint.
|
# like show_only_sfw stay consistent with the list endpoint.
|
||||||
apply_filters = (
|
apply_filters = (
|
||||||
"recursive" in request.query
|
use_active_filters
|
||||||
|
or "recursive" in request.query
|
||||||
or folder is not None
|
or folder is not None
|
||||||
or bool(base_models)
|
or bool(base_models)
|
||||||
or bool(model_types)
|
or bool(model_types)
|
||||||
@@ -1634,6 +1676,50 @@ class ModelQueryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
class ModelDownloadHandler:
|
||||||
"""Coordinate downloads and progress reporting."""
|
"""Coordinate downloads and progress reporting."""
|
||||||
@@ -3339,6 +3425,8 @@ class ModelHandlerSet:
|
|||||||
"get_model_metadata": self.query.get_model_metadata,
|
"get_model_metadata": self.query.get_model_metadata,
|
||||||
"get_model_description": self.query.get_model_description,
|
"get_model_description": self.query.get_model_description,
|
||||||
"get_relative_paths": self.query.get_relative_paths,
|
"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,
|
"refresh_model_updates": self.updates.refresh_model_updates,
|
||||||
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
|
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
|
||||||
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
|
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
|
||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
|
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(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
|
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
// Initialize the Checkpoints page
|
// Initialize the Checkpoints page
|
||||||
export class CheckpointsPageManager {
|
export class CheckpointsPageManager {
|
||||||
@@ -32,6 +33,9 @@ export class CheckpointsPageManager {
|
|||||||
// Initialize common page features (including context menus)
|
// Initialize common page features (including context menus)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync(MODEL_TYPES.CHECKPOINT);
|
||||||
|
|
||||||
console.log('Checkpoints Manager initialized');
|
console.log('Checkpoints Manager initialized');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
// Initialize the Embeddings page
|
// Initialize the Embeddings page
|
||||||
class EmbeddingsPageManager {
|
class EmbeddingsPageManager {
|
||||||
@@ -32,6 +33,9 @@ class EmbeddingsPageManager {
|
|||||||
// Initialize common page features (including context menus)
|
// Initialize common page features (including context menus)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync(MODEL_TYPES.EMBEDDING);
|
||||||
|
|
||||||
console.log('Embeddings Manager initialized');
|
console.log('Embeddings Manager initialized');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
// Initialize the LoRA page
|
// Initialize the LoRA page
|
||||||
export class LoraPageManager {
|
export class LoraPageManager {
|
||||||
@@ -41,6 +42,9 @@ export class LoraPageManager {
|
|||||||
|
|
||||||
// Initialize common page features (including context menus and virtual scroll)
|
// Initialize common page features (including context menus and virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync('loras');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,31 @@
|
|||||||
// Namespace prefix for all localStorage keys
|
// Namespace prefix for all localStorage keys
|
||||||
const STORAGE_PREFIX = 'lora_manager_';
|
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
|
* Get an item from localStorage with namespace support and fallback to legacy keys
|
||||||
* @param {string} key - The key without prefix
|
* @param {string} key - The key without prefix
|
||||||
@@ -51,13 +76,15 @@ export function getStorageItem(key, defaultValue = null) {
|
|||||||
*/
|
*/
|
||||||
export function setStorageItem(key, value) {
|
export function setStorageItem(key, value) {
|
||||||
const prefixedKey = STORAGE_PREFIX + key;
|
const prefixedKey = STORAGE_PREFIX + key;
|
||||||
|
|
||||||
// Convert objects and arrays to JSON strings
|
// Convert objects and arrays to JSON strings
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem(prefixedKey, value);
|
localStorage.setItem(prefixedKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyActiveFiltersChanged(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +94,8 @@ export function setStorageItem(key, value) {
|
|||||||
export function removeStorageItem(key) {
|
export function removeStorageItem(key) {
|
||||||
localStorage.removeItem(STORAGE_PREFIX + key);
|
localStorage.removeItem(STORAGE_PREFIX + key);
|
||||||
localStorage.removeItem(key); // Also remove legacy key
|
localStorage.removeItem(key); // Also remove legacy key
|
||||||
|
|
||||||
|
notifyActiveFiltersChanged(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
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();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1799,12 +1799,11 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
return undefined;
|
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({
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
||||||
baseModel: ['SD 1.5'],
|
baseModel: ['SD 1.5'],
|
||||||
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
|
tags: { anime: 'include', nsfw: 'exclude' },
|
||||||
autoTags: { I2V: 'include' },
|
|
||||||
modelTypes: ['standard'],
|
|
||||||
tagLogic: 'all',
|
|
||||||
license: { noCredit: 'include', allowSelling: 'exclude' },
|
license: { noCredit: 'include', allowSelling: 'exclude' },
|
||||||
}));
|
}));
|
||||||
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
||||||
@@ -1830,19 +1829,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
|
expect(calledUrl).toBe('/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
|
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');
|
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
|
// Regression: with filter mode on but no folder/filters stored, the request
|
||||||
// carried no params, so the backend skipped the filter pipeline and global
|
// carried no signal, so the backend skipped the filter pipeline and global
|
||||||
// settings like show_only_sfw diverged from the list endpoint.
|
// 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();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1907,10 +1896,13 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
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();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1948,10 +1940,12 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).not.toContain('folder=');
|
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();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1988,15 +1982,14 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('folder=');
|
expect(calledUrl).not.toContain('folder=');
|
||||||
expect(calledUrl).toContain('recursive=false');
|
expect(calledUrl).toContain('use_active_filters=true');
|
||||||
const parsed = new URL(calledUrl, 'https://example.com');
|
|
||||||
expect(parsed.searchParams.get('folder')).toBe('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// 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();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -2029,8 +2022,8 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
expect(calledUrl).toContain('use_active_filters=true');
|
||||||
expect(calledUrl).toContain('recursive=true');
|
expect(calledUrl).not.toContain('folder=');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('discoverability hints', () => {
|
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
|
||||||
@@ -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
|
||||||
@@ -1317,88 +1317,17 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a URL-encoded query string from the LoRA Manager page's active
|
* Return the query flag that tells the backend to inject the LoRA Manager
|
||||||
* filters in localStorage, or null when not applicable.
|
* 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() {
|
_getActiveLoraFilters() {
|
||||||
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
|
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
return 'use_active_filters=true';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(term = '', endpoint = null) {
|
async search(term = '', endpoint = null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user