mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 03:24:09 -03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56acefbd6c | ||
|
|
5ab06c4aae |
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov-report=xml:coverage/backend/coverage.xml
|
||||
```
|
||||
|
||||
### Frontend Development (Standalone Web UI)
|
||||
### Frontend Development (LoRA Manager Web UI)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
### 1. Standalone Web UI
|
||||
### 1. LoRA Manager Web UI
|
||||
- Location: `./static/` and `./templates/`
|
||||
- Tech: Vanilla JS + CSS, served by standalone server
|
||||
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
|
||||
- Tests via npm in root directory
|
||||
|
||||
### 2. ComfyUI Custom Node Widgets
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+202
-16
@@ -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