mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 14:30:15 -03:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user