feat(autocomplete): search loras within active filters of LoRA Manager page

Add /af and /noaf toggle commands (plus /activefilters aliases) to the
loras autocomplete widget. When enabled (default off), suggestions are
matched within the active filters (folder, base model, tags, auto-tags,
license, tag logic) persisted by the LoRA Manager page in localStorage,
keeping the match pool consistent with the list endpoint, including the
global show_only_sfw setting.

Backend: /lm/{prefix}/relative-paths accepts the filter query params and
pre-filters the scanner cache with ModelFilterSet. The presence of the
recursive param signals the filter pipeline to run even without concrete
filters so global settings stay in parity with the list endpoint.
This commit is contained in:
Will Miao
2026-08-07 20:07:34 +08:00
parent 5ab06c4aae
commit 56acefbd6c
6 changed files with 1025 additions and 20 deletions

View File

@@ -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}

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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",
]

View File

@@ -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
});
}

View File

@@ -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,
};