diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 5ecfab05..585916a2 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1488,8 +1488,73 @@ class ModelQueryHandler: search = request.query.get("search", "").strip() limit = min(int(request.query.get("limit", "15")), 100) offset = max(0, int(request.query.get("offset", "0"))) + + folder = request.query.get("folder") + recursive = request.query.get("recursive", "true").lower() == "true" + base_models = list(request.query.getall("base_model", [])) + model_types = list(request.query.getall("model_type", [])) + + tag_filters: Dict[str, str] = {} + for tag in request.query.getall("tag_include", []): + if tag: + tag_filters[tag] = "include" + for tag in request.query.getall("tag_exclude", []): + if tag: + tag_filters[tag] = "exclude" + + auto_tag_filters: Dict[str, str] = {} + for tag in request.query.getall("auto_tag_include", []): + if tag: + auto_tag_filters[tag] = "include" + for tag in request.query.getall("auto_tag_exclude", []): + if tag: + auto_tag_filters[tag] = "exclude" + + tag_logic = request.query.get("tag_logic", "any").lower() + if tag_logic not in ("any", "all"): + tag_logic = "any" + + credit_required = request.query.get("credit_required") + if credit_required is not None: + credit_required = credit_required.lower() not in ("false", "0", "") + + allow_selling_generated_content = request.query.get( + "allow_selling_generated_content" + ) + if allow_selling_generated_content is not None: + allow_selling_generated_content = ( + allow_selling_generated_content.lower() not in ("false", "0", "") + ) + + # 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 + or folder is not None + or bool(base_models) + or bool(model_types) + or bool(tag_filters) + or bool(auto_tag_filters) + or credit_required is not None + or allow_selling_generated_content is not None + ) + matching_paths = await self._service.search_relative_paths( - search, limit, offset + search, + limit, + offset, + folder=folder, + recursive=recursive, + base_models=base_models, + model_types=model_types, + tags=tag_filters, + auto_tags=auto_tag_filters, + tag_logic=tag_logic, + credit_required=credit_required, + allow_selling_generated_content=allow_selling_generated_content, + apply_filters=apply_filters, ) return web.json_response( {"success": True, "relative_paths": matching_paths} diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 300bae66..2cd61f11 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -1259,19 +1259,87 @@ class BaseModelService(ABC): ) async def search_relative_paths( - self, search_term: str, limit: int = 15, offset: int = 0 + self, + search_term: str, + limit: int = 15, + offset: int = 0, + *, + folder: Optional[str] = None, + folder_include: Optional[list] = None, + folder_exclude: Optional[list] = None, + base_models: Optional[list] = None, + model_types: Optional[list] = None, + tags: Optional[dict] = None, + auto_tags: Optional[dict] = None, + tag_logic: str = "any", + credit_required: Optional[bool] = None, + allow_selling_generated_content: Optional[bool] = None, + recursive: bool = True, + apply_filters: bool = False, ) -> List[str]: - """Search model relative file paths for autocomplete functionality""" + """Search model relative file paths for autocomplete functionality. + + Optional filter kwargs mirror the filters used by the list endpoint + (/api/lm/{prefix}/list). When no filter kwargs are provided the + behavior is identical to plain token-based path matching. + """ cache = await self.scanner.get_cached_data() include_terms, exclude_terms = self._parse_search_tokens(search_term) + data = cache.raw_data + has_filters = any( + [ + apply_filters, + folder is not None, + folder_include, + folder_exclude, + base_models, + model_types, + tags, + auto_tags, + credit_required is not None, + allow_selling_generated_content is not None, + ] + ) + if has_filters: + # Auto-tags are not stored in the scanner cache — they are computed + # on the fly. Pre-compute them only when an auto-tag filter is + # active to avoid mutating cache entries unnecessarily. + if auto_tags: + from .auto_tag_service import extract_auto_tags + + for item in data: + if not item.get("auto_tags"): + item["auto_tags"] = extract_auto_tags(item) + + criteria = FilterCriteria( + folder=folder, + folder_include=folder_include, + folder_exclude=folder_exclude, + base_models=base_models, + model_types=model_types, + tags=tags, + auto_tags=auto_tags, + search_options={"recursive": recursive}, + tag_logic=tag_logic, + ) + data = self.filter_set.apply(data, criteria) + if credit_required is not None: + data = await self._apply_credit_required_filter( + data, credit_required + ) + if allow_selling_generated_content is not None: + data = await self._apply_allow_selling_filter( + data, allow_selling_generated_content + ) + matching_paths = [] # Get model roots for path calculation model_roots = self.scanner.get_model_roots() # Collect all matching paths first (needed for proper sorting and offset) - for model in cache.raw_data: + for model in data: file_path = model.get("file_path", "") if not file_path: continue diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index 7c5a7b53..bd909bd2 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -1666,4 +1666,374 @@ describe('AutoComplete widget interactions', () => { // Entire phrase should be replaced with selected tag expect(input.value).toBe('looking_to_the_side,'); }); + + it('shows /af command for loras when active-filters autocomplete is off (default)', async () => { + const input = document.createElement('textarea'); + input.value = '/'; + input.selectionStart = input.value.length; + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + + const commandNames = autoComplete.items.map((item) => item.command); + expect(commandNames).toContain('/af'); + expect(commandNames).not.toContain('/noaf'); + expect(commandNames).toContain('/activefilters'); + expect(commandNames).not.toContain('/noactivefilters'); + }); + + it('does not trigger preview for command items when selecting the loras command list', async () => { + // Regression: with showPreview enabled (the default for loras widgets), the + // auto-selected first command item was passed to showPreviewForItem() as a + // relative path, crashing on relativePath.split. + const input = document.createElement('textarea'); + input.value = '/'; + input.selectionStart = input.value.length; + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { showPreview: true, minChars: 1 }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + + // Allow the async preview tooltip import to resolve + await Promise.resolve(); + await Promise.resolve(); + + const commandNames = autoComplete.items.map((item) => item.command); + expect(commandNames).toContain('/af'); + expect(previewTooltipMock.show).not.toHaveBeenCalled(); + }); + + it('shows /noaf command for loras when active-filters autocomplete is on', async () => { + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.lora_active_filters_autocomplete') { + return true; + } + return undefined; + }); + + const input = document.createElement('textarea'); + input.value = '/'; + input.selectionStart = input.value.length; + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + + const commandNames = autoComplete.items.map((item) => item.command); + expect(commandNames).toContain('/noaf'); + expect(commandNames).not.toContain('/af'); + expect(commandNames).toContain('/noactivefilters'); + expect(commandNames).not.toContain('/activefilters'); + }); + + it('toggles the active-filters setting when /activefilters alias is used', async () => { + const input = document.createElement('textarea'); + input.value = '/activefilters'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 }); + + const commandResult = autoComplete._parseCommandInput('/activefilters'); + expect(commandResult.command).toBeDefined(); + expect(commandResult.command.type).toBe('toggle_setting'); + expect(commandResult.command.value).toBe(true); + + await autoComplete._handleToggleSettingCommand(commandResult.command); + + expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true); + }); + + it('toggles the active-filters setting when /af is accepted', async () => { + const input = document.createElement('textarea'); + input.value = '/'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + + const afItem = autoComplete.items.find((item) => item.command === '/af'); + expect(afItem).toBeDefined(); + + // Simulate the input being cleared after the command is accepted so the + // cleared-token input event does not re-trigger command parsing. + caretHelperInstance.getBeforeCursor.mockReturnValue(''); + await autoComplete._handleToggleSettingCommand(afItem); + + expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true); + }); + + it('appends active filter params to loras autocomplete requests when enabled', async () => { + vi.useFakeTimers(); + + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.lora_active_filters_autocomplete') { + return true; + } + return undefined; + }); + + 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', + license: { noCredit: 'include', allowSelling: 'exclude' }, + })); + localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras'); + localStorage.setItem('lora_manager_loras_recursiveSearch', 'true'); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 }); + + input.value = 'example'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + 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'); + }); + + it('keeps the default loras autocomplete URL when active-filters mode is off', async () => { + vi.useFakeTimers(); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 }); + + input.value = 'example'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100'); + }); + + it('sends the filter-pipeline signal 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. + vi.useFakeTimers(); + + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.lora_active_filters_autocomplete') { + return true; + } + return undefined; + }); + + localStorage.removeItem('lora_manager_loras_filters'); + localStorage.removeItem('lora_manager_loras_activeFolder'); + localStorage.removeItem('lora_manager_loras_recursiveSearch'); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 }); + + input.value = 'example'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + const calledUrl = fetchApiMock.mock.calls[0][0]; + expect(calledUrl).toContain('recursive=true'); + }); + + it('omits folder param when active folder is root and recursion is enabled', async () => { + vi.useFakeTimers(); + + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.lora_active_filters_autocomplete') { + return true; + } + return undefined; + }); + + localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ + baseModel: ['SD 1.5'], + tags: { anime: 'include' }, + })); + localStorage.setItem('lora_manager_loras_activeFolder', ''); + localStorage.removeItem('lora_manager_loras_recursiveSearch'); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 }); + + input.value = 'example'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + const calledUrl = fetchApiMock.mock.calls[0][0]; + expect(calledUrl).not.toContain('folder='); + expect(calledUrl).toContain('recursive=true'); + }); + + it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => { + vi.useFakeTimers(); + + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.lora_active_filters_autocomplete') { + return true; + } + return undefined; + }); + + localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ + baseModel: ['SD 1.5'], + tags: { anime: 'include' }, + })); + localStorage.setItem('lora_manager_loras_activeFolder', ''); + localStorage.setItem('lora_manager_loras_recursiveSearch', 'false'); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 }); + + input.value = 'example'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + 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(''); + }); + + it('applies the active folder even when no filter-panel filters are set', async () => { + // Regression: folder was skipped when lora_manager_loras_filters was + // missing because the filters key gate returned early. + vi.useFakeTimers(); + + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.lora_active_filters_autocomplete') { + return true; + } + return undefined; + }); + + localStorage.removeItem('lora_manager_loras_filters'); + localStorage.setItem('lora_manager_loras_activeFolder', 'Flux.1 D/style'); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: ['Flux.1 D/style/3D_Fairytales.safetensors'] }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('3D'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 }); + + input.value = '3D'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + const calledUrl = fetchApiMock.mock.calls[0][0]; + expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle'); + expect(calledUrl).toContain('recursive=true'); + }); }); diff --git a/tests/services/test_relative_path_search.py b/tests/services/test_relative_path_search.py index 1c68ef8f..e093a3af 100644 --- a/tests/services/test_relative_path_search.py +++ b/tests/services/test_relative_path_search.py @@ -27,6 +27,13 @@ class FakeScanner: return list(self._roots) +class StubSettings: + """Settings stub that returns defaults, avoiding the real settings singleton.""" + + def get(self, key, default=None): + return default + + @pytest.mark.asyncio async def test_search_relative_paths_supports_multiple_tokens(): scanner = FakeScanner( @@ -101,3 +108,274 @@ async def test_search_safe_does_not_match_all_files(): matching = await service.search_relative_paths("safe") assert len(matching) == 0 + + +class SfwStubSettings(StubSettings): + """Settings stub with the global SFW filter enabled.""" + + def get(self, key, default=None): + if key == "show_only_sfw": + return True + return default + + +@pytest.mark.asyncio +async def test_search_relative_paths_respects_global_sfw_setting(): + """Filtered search applies show_only_sfw like the list endpoint (parity).""" + scanner = FakeScanner( + [ + {"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0}, + {"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings() + ) + + matching = await service.search_relative_paths("model", apply_filters=True) + + assert matching == ["sfw-model.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_sfw_only_applied_when_filter_mode_is_on(): + """Global settings (show_only_sfw) apply only when the filter pipeline runs.""" + scanner = FakeScanner( + [ + {"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0}, + {"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings() + ) + + default_matching = await service.search_relative_paths("model") + + assert default_matching == [ + "sfw-model.safetensors", + "nsfw-model.safetensors", + ] + + +@pytest.mark.asyncio +async def test_search_relative_paths_folder_filter_recursive(): + """folder filter with recursive=True (default) matches subfolders.""" + scanner = FakeScanner( + [ + {"file_path": "/models/anime/model-a.safetensors", "folder": "anime"}, + { + "file_path": "/models/anime/nsfw/model-b.safetensors", + "folder": "anime/nsfw", + }, + {"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths("model", folder="anime") + + assert matching == [ + f"anime{os.sep}model-a.safetensors", + f"anime{os.sep}nsfw{os.sep}model-b.safetensors", + ] + + +@pytest.mark.asyncio +async def test_search_relative_paths_folder_filter_exact(): + """folder filter with recursive=False matches only the exact folder.""" + scanner = FakeScanner( + [ + {"file_path": "/models/anime/model-a.safetensors", "folder": "anime"}, + { + "file_path": "/models/anime/nsfw/model-b.safetensors", + "folder": "anime/nsfw", + }, + {"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths( + "model", folder="anime", recursive=False + ) + + assert matching == [f"anime{os.sep}model-a.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_base_model_filter(): + scanner = FakeScanner( + [ + {"file_path": "/models/model-a.safetensors", "base_model": "SD 1.5"}, + {"file_path": "/models/model-b.safetensors", "base_model": "SDXL"}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths("model", base_models=["SD 1.5"]) + + assert matching == ["model-a.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_tag_include(): + scanner = FakeScanner( + [ + {"file_path": "/models/model-a.safetensors", "tags": ["anime"]}, + {"file_path": "/models/model-b.safetensors", "tags": ["realistic"]}, + {"file_path": "/models/model-c.safetensors", "tags": ["anime", "realistic"]}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths("model", tags={"anime": "include"}) + + assert set(matching) == {"model-a.safetensors", "model-c.safetensors"} + + +@pytest.mark.asyncio +async def test_search_relative_paths_tag_exclude(): + scanner = FakeScanner( + [ + {"file_path": "/models/model-a.safetensors", "tags": ["anime"]}, + {"file_path": "/models/model-b.safetensors", "tags": ["realistic"]}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths("model", tags={"anime": "exclude"}) + + assert matching == ["model-b.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_auto_tag_include(): + scanner = FakeScanner( + [ + { + "file_path": "/models/model-i2v.safetensors", + "file_name": "model-i2v.safetensors", + }, + { + "file_path": "/models/model-t2v.safetensors", + "file_name": "model-t2v.safetensors", + }, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths( + "model", auto_tags={"I2V": "include"} + ) + + assert matching == ["model-i2v.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_tag_logic_all(): + scanner = FakeScanner( + [ + {"file_path": "/models/model-a.safetensors", "tags": ["anime", "style"]}, + {"file_path": "/models/model-b.safetensors", "tags": ["anime"]}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths( + "model", tags={"anime": "include", "style": "include"}, tag_logic="all" + ) + + assert matching == ["model-a.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_credit_required_filter(): + # license_flags bit0: 1 = no credit required, 0 = credit required + scanner = FakeScanner( + [ + {"file_path": "/models/model-a.safetensors", "license_flags": 127}, + {"file_path": "/models/model-b.safetensors", "license_flags": 0}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths("model", credit_required=True) + assert matching == ["model-b.safetensors"] + + matching = await service.search_relative_paths("model", credit_required=False) + assert matching == ["model-a.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_allow_selling_filter(): + # license_flags bit1: 1 = commercial image use allowed, 0 = not allowed + scanner = FakeScanner( + [ + {"file_path": "/models/model-a.safetensors", "license_flags": 2}, + {"file_path": "/models/model-b.safetensors", "license_flags": 1}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths( + "model", allow_selling_generated_content=True + ) + assert matching == ["model-a.safetensors"] + + matching = await service.search_relative_paths( + "model", allow_selling_generated_content=False + ) + assert matching == ["model-b.safetensors"] + + +@pytest.mark.asyncio +async def test_search_relative_paths_no_filters_regression(): + """No filter kwargs -> behavior is byte-identical to plain token matching.""" + scanner = FakeScanner( + [ + {"file_path": "/models/flux/detail-model.safetensors"}, + {"file_path": "/models/flux/only-flux.safetensors"}, + ], + ["/models"], + ) + service = DummyService( + "stub", scanner, BaseModelMetadata, settings_provider=StubSettings() + ) + + matching = await service.search_relative_paths("flux") + + assert matching == [ + f"flux{os.sep}only-flux.safetensors", + f"flux{os.sep}detail-model.safetensors", + ] + diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 74e5a0cd..8ae0e862 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -14,6 +14,7 @@ import { getAutocompleteAppendCommaPreference, getAutocompleteAutoFormatPreference, getAutocompleteAcceptKeyPreference, + getLoraActiveFiltersAutocompletePreference, getPromptTagAutocompletePreference, getTagSpaceReplacementPreference, } from "./settings.js"; @@ -48,6 +49,47 @@ const TAG_COMMANDS = { }, }; +// Command definitions for LoRA active-filters search +// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding +const LORAS_COMMANDS = { + '/af': { + type: 'toggle_setting', + settingId: 'loramanager.lora_active_filters_autocomplete', + value: true, + label: 'Active Filters: ON', + feedbackSummary: 'Active Filters Search: ON', + feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.', + condition: () => !getLoraActiveFiltersAutocompletePreference() + }, + '/noaf': { + type: 'toggle_setting', + settingId: 'loramanager.lora_active_filters_autocomplete', + value: false, + label: 'Active Filters: OFF', + feedbackSummary: 'Active Filters Search: OFF', + feedbackDetail: 'LoRA autocomplete searches the full library again.', + condition: () => getLoraActiveFiltersAutocompletePreference() + }, + '/activefilters': { + type: 'toggle_setting', + settingId: 'loramanager.lora_active_filters_autocomplete', + value: true, + label: 'Active Filters: ON', + feedbackSummary: 'Active Filters Search: ON', + feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.', + condition: () => !getLoraActiveFiltersAutocompletePreference() + }, + '/noactivefilters': { + type: 'toggle_setting', + settingId: 'loramanager.lora_active_filters_autocomplete', + value: false, + label: 'Active Filters: OFF', + feedbackSummary: 'Active Filters Search: OFF', + feedbackDetail: 'LoRA autocomplete searches the full library again.', + condition: () => getLoraActiveFiltersAutocompletePreference() + }, +}; + // Category display information const CATEGORY_INFO = { 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' }, @@ -719,6 +761,36 @@ class AutoComplete { searchTerm = (match[1] || '').trim(); } + // For loras model type, check if we're in command mode (/af, /noaf) + if (this.modelType === 'loras') { + const commandResult = this._parseCommandInput(rawSearchTerm); + + if (commandResult.showCommands) { + // Show command list dropdown + this.showingCommands = true; + this.activeCommand = null; + this.searchType = 'commands'; + this._showCommandList(commandResult.commandFilter); + return; + } else if (commandResult.command?.type === 'toggle_setting') { + // Handle toggle setting command (/af, /noaf) + this._handleToggleSettingCommand(commandResult.command); + return; + } else if (commandResult.command) { + // Command is active, use filtered search + this.showingCommands = false; + this.activeCommand = null; + this.searchType = null; + searchTerm = commandResult.searchTerm || rawSearchTerm; + } else { + // No command - regular lora search + this.showingCommands = false; + this.activeCommand = null; + this.searchType = null; + searchTerm = rawSearchTerm; + } + } + // For prompt model type, check if we're searching embeddings, commands, or tags if (this.modelType === 'prompt') { const match = rawSearchTerm.match(/^emb:(.*)$/i); @@ -1095,7 +1167,11 @@ class AutoComplete { } _isSelectableInfoItem(item) { - return isWildcardInfoItem(item); + if (isWildcardInfoItem(item)) { + return true; + } + // Command items are not model paths — never show preview for them + return item && typeof item === 'object' && 'command' in item; } /** @@ -1159,6 +1235,11 @@ class AutoComplete { return (match?.[1] || '').trim(); } + if (this.modelType === 'loras') { + const commandResult = this._parseCommandInput(rawSearchTerm); + return commandResult.searchTerm ?? rawSearchTerm; + } + if (this.modelType === 'prompt') { const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i); if (embeddingMatch) { @@ -1245,6 +1326,91 @@ class AutoComplete { return this._getPreferredSelectedIndex(searchTerm); } + /** + * Build a URL-encoded query string from the LoRA Manager page's active + * filters in localStorage, or null when not applicable. + */ + _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; + } + } + async search(term = '', endpoint = null) { try { this.currentSearchTerm = term; @@ -1262,6 +1428,10 @@ class AutoComplete { endpoint = `/lm/${this.modelType}/relative-paths`; } + // Active-filter query params for loras (null when setting off or + // model type is not loras, so appending is safe for all types) + const activeFiltersQuery = this._getActiveLoraFilters(); + // Generate multiple query variations for better matching, but avoid // sending duplicate-equivalent requests that normalize to the same // backend search term. @@ -1281,9 +1451,10 @@ class AutoComplete { const url = endpoint.includes('?') ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}` : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`; + const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url; try { - const response = await api.fetchApi(url); + const response = await api.fetchApi(finalUrl); const data = await response.json(); return { items: data.success ? (data.relative_paths || data.words || []) : [], @@ -1358,6 +1529,15 @@ class AutoComplete { } } + /** + * Return the command map for the current model type. + * Lora model types get the active-filters toggle commands, all others + * keep the prompt tag commands. + */ + _getCommands() { + return this.modelType === 'loras' ? LORAS_COMMANDS : TAG_COMMANDS; + } + /** * Parse command input to detect command mode * @param {string} rawInput - Raw input text @@ -1379,8 +1559,8 @@ class AutoComplete { const partialCommand = trimmed.toLowerCase(); // Check for exact command match - if (TAG_COMMANDS[partialCommand]) { - const cmd = TAG_COMMANDS[partialCommand]; + if (this._getCommands()[partialCommand]) { + const cmd = this._getCommands()[partialCommand]; // Filter out toggle commands that don't meet their condition if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) { return { showCommands: false, command: null, searchTerm: '' }; @@ -1405,8 +1585,8 @@ class AutoComplete { const commandPart = trimmed.slice(0, spaceIndex).toLowerCase(); const searchPart = trimmed.slice(spaceIndex + 1).trim(); - if (TAG_COMMANDS[commandPart]) { - const cmd = TAG_COMMANDS[commandPart]; + if (this._getCommands()[commandPart]) { + const cmd = this._getCommands()[commandPart]; // Filter out toggle commands that don't meet their condition if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) { return { showCommands: false, command: null, searchTerm: trimmed }; @@ -1437,7 +1617,7 @@ class AutoComplete { const commands = []; - for (const [cmd, info] of Object.entries(TAG_COMMANDS)) { + for (const [cmd, info] of Object.entries(this._getCommands())) { // Filter out toggle commands that don't meet their condition if (info.type === 'toggle_setting' && info.condition) { if (!info.condition()) continue; @@ -1902,7 +2082,8 @@ class AutoComplete { showPreviewForItem(relativePath, itemElement) { if (!this.options.showPreview || !this.previewTooltip) return; - + if (typeof relativePath !== 'string' || !relativePath) return; + // Extract filename without extension for preview const fileName = relativePath.split(/[/\\]/).pop(); const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); @@ -1984,14 +2165,18 @@ class AutoComplete { const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm); const offset = this.items.length; + // Active-filter query params for loras (null when setting off) + const activeFiltersQuery = this._getActiveLoraFilters(); + // Execute all queries in parallel with offset const searchPromises = queriesToExecute.map(async (query) => { const url = endpoint.includes('?') ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}` : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`; + const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url; try { - const response = await api.fetchApi(url); + const response = await api.fetchApi(finalUrl); const data = await response.json(); return data.success ? (data.relative_paths || data.words || []) : []; } catch (error) { @@ -2692,14 +2877,14 @@ class AutoComplete { const settingManager = app?.extensionManager?.setting; if (settingManager && typeof settingManager.set === 'function') { await settingManager.set(settingId, value); - this._showToggleFeedback(value); + this._showToggleFeedback(command, value); this._clearCurrentToken(); } else { // Fallback: use legacy settings API const setting = app.ui.settings.settingsById?.[settingId]; if (setting) { app.ui.settings.setSettingValue(settingId, value); - this._showToggleFeedback(value); + this._showToggleFeedback(command, value); this._clearCurrentToken(); } } @@ -2718,15 +2903,16 @@ class AutoComplete { /** * Show visual feedback for toggle action using toast + * @param {Object} command - The toggle command that was executed * @param {boolean} enabled - New autocomplete state */ - _showToggleFeedback(enabled) { + _showToggleFeedback(command, enabled) { showToast({ severity: enabled ? 'success' : 'secondary', - summary: enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled', - detail: enabled - ? 'Tag autocomplete is now ON. Type to see suggestions.' - : 'Tag autocomplete is now OFF. Use /ac to re-enable.', + summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'), + detail: command.feedbackDetail || (enabled + ? 'Tag autocomplete is now ON. Type to see suggestions.' + : 'Tag autocomplete is now OFF. Use /ac to re-enable.'), life: 3000 }); } diff --git a/web/comfyui/settings.js b/web/comfyui/settings.js index fedd4957..a5d2afe0 100644 --- a/web/comfyui/settings.js +++ b/web/comfyui/settings.js @@ -39,6 +39,9 @@ const NEW_TAB_ZOOM_LEVEL = 0.8; const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step"; const STRENGTH_STEP_DEFAULT = 0.05; +const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID = "loramanager.lora_active_filters_autocomplete"; +const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT = false; + // ============================================================================ // Helper Functions // ============================================================================ @@ -360,6 +363,32 @@ const getStrengthStepPreference = (() => { }; })(); +const getLoraActiveFiltersAutocompletePreference = (() => { + let settingsUnavailableLogged = false; + + return () => { + const settingManager = app?.extensionManager?.setting; + if (!settingManager || typeof settingManager.get !== "function") { + if (!settingsUnavailableLogged) { + console.warn("LoRA Manager: settings API unavailable, using default lora active filters autocomplete setting."); + settingsUnavailableLogged = true; + } + return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT; + } + + try { + const value = settingManager.get(LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID); + return value ?? LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT; + } catch (error) { + if (!settingsUnavailableLogged) { + console.warn("LoRA Manager: unable to read lora active filters autocomplete setting, using default.", error); + settingsUnavailableLogged = true; + } + return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT; + } + }; +})(); + // ============================================================================ // Register Extension with All Settings // ============================================================================ @@ -396,6 +425,14 @@ app.registerExtension({ tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.", category: ["LoRA Manager", "Autocomplete", "Prompt"], }, + { + id: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID, + name: "Search LoRA autocomplete within active filters", + type: "boolean", + defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT, + tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. Commands /af and /noaf toggle this mode.", + category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"], + }, { id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID, name: "Append comma after autocomplete", @@ -549,4 +586,5 @@ export { getUsageStatisticsPreference, getNewTabTemplatePreference, getStrengthStepPreference, + getLoraActiveFiltersAutocompletePreference, };