mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 11:34:08 -03:00
Compare commits
2
Commits
c11f4b5c68
...
56acefbd6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56acefbd6c | ||
|
|
5ab06c4aae |
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
|
|||||||
--cov-report=xml:coverage/backend/coverage.xml
|
--cov-report=xml:coverage/backend/coverage.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development (Standalone Web UI)
|
### Frontend Development (LoRA Manager Web UI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
|
|||||||
|
|
||||||
## Frontend UI Architecture
|
## Frontend UI Architecture
|
||||||
|
|
||||||
### 1. Standalone Web UI
|
### 1. LoRA Manager Web UI
|
||||||
- Location: `./static/` and `./templates/`
|
- 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
|
- Tests via npm in root directory
|
||||||
|
|
||||||
### 2. ComfyUI Custom Node Widgets
|
### 2. ComfyUI Custom Node Widgets
|
||||||
|
|||||||
@@ -1488,8 +1488,73 @@ class ModelQueryHandler:
|
|||||||
search = request.query.get("search", "").strip()
|
search = request.query.get("search", "").strip()
|
||||||
limit = min(int(request.query.get("limit", "15")), 100)
|
limit = min(int(request.query.get("limit", "15")), 100)
|
||||||
offset = max(0, int(request.query.get("offset", "0")))
|
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(
|
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(
|
return web.json_response(
|
||||||
{"success": True, "relative_paths": matching_paths}
|
{"success": True, "relative_paths": matching_paths}
|
||||||
|
|||||||
@@ -1259,19 +1259,87 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def search_relative_paths(
|
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]:
|
) -> 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()
|
cache = await self.scanner.get_cached_data()
|
||||||
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
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 = []
|
matching_paths = []
|
||||||
|
|
||||||
# Get model roots for path calculation
|
# Get model roots for path calculation
|
||||||
model_roots = self.scanner.get_model_roots()
|
model_roots = self.scanner.get_model_roots()
|
||||||
|
|
||||||
# Collect all matching paths first (needed for proper sorting and offset)
|
# 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", "")
|
file_path = model.get("file_path", "")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1666,4 +1666,374 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
// Entire phrase should be replaced with selected tag
|
// Entire phrase should be replaced with selected tag
|
||||||
expect(input.value).toBe('looking_to_the_side,');
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_search_relative_paths_supports_multiple_tokens():
|
async def test_search_relative_paths_supports_multiple_tokens():
|
||||||
scanner = FakeScanner(
|
scanner = FakeScanner(
|
||||||
@@ -101,3 +108,274 @@ async def test_search_safe_does_not_match_all_files():
|
|||||||
matching = await service.search_relative_paths("safe")
|
matching = await service.search_relative_paths("safe")
|
||||||
|
|
||||||
assert len(matching) == 0
|
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,
|
getAutocompleteAppendCommaPreference,
|
||||||
getAutocompleteAutoFormatPreference,
|
getAutocompleteAutoFormatPreference,
|
||||||
getAutocompleteAcceptKeyPreference,
|
getAutocompleteAcceptKeyPreference,
|
||||||
|
getLoraActiveFiltersAutocompletePreference,
|
||||||
getPromptTagAutocompletePreference,
|
getPromptTagAutocompletePreference,
|
||||||
getTagSpaceReplacementPreference,
|
getTagSpaceReplacementPreference,
|
||||||
} from "./settings.js";
|
} 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
|
// Category display information
|
||||||
const CATEGORY_INFO = {
|
const CATEGORY_INFO = {
|
||||||
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||||||
@@ -719,6 +761,36 @@ class AutoComplete {
|
|||||||
searchTerm = (match[1] || '').trim();
|
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
|
// For prompt model type, check if we're searching embeddings, commands, or tags
|
||||||
if (this.modelType === 'prompt') {
|
if (this.modelType === 'prompt') {
|
||||||
const match = rawSearchTerm.match(/^emb:(.*)$/i);
|
const match = rawSearchTerm.match(/^emb:(.*)$/i);
|
||||||
@@ -1095,7 +1167,11 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isSelectableInfoItem(item) {
|
_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();
|
return (match?.[1] || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.modelType === 'loras') {
|
||||||
|
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||||||
|
return commandResult.searchTerm ?? rawSearchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.modelType === 'prompt') {
|
if (this.modelType === 'prompt') {
|
||||||
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
|
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
|
||||||
if (embeddingMatch) {
|
if (embeddingMatch) {
|
||||||
@@ -1245,6 +1326,91 @@ class AutoComplete {
|
|||||||
return this._getPreferredSelectedIndex(searchTerm);
|
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) {
|
async search(term = '', endpoint = null) {
|
||||||
try {
|
try {
|
||||||
this.currentSearchTerm = term;
|
this.currentSearchTerm = term;
|
||||||
@@ -1262,6 +1428,10 @@ class AutoComplete {
|
|||||||
endpoint = `/lm/${this.modelType}/relative-paths`;
|
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
|
// Generate multiple query variations for better matching, but avoid
|
||||||
// sending duplicate-equivalent requests that normalize to the same
|
// sending duplicate-equivalent requests that normalize to the same
|
||||||
// backend search term.
|
// backend search term.
|
||||||
@@ -1281,9 +1451,10 @@ class AutoComplete {
|
|||||||
const url = endpoint.includes('?')
|
const url = endpoint.includes('?')
|
||||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
|
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
|
||||||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
|
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
|
||||||
|
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.fetchApi(url);
|
const response = await api.fetchApi(finalUrl);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
items: data.success ? (data.relative_paths || data.words || []) : [],
|
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
|
* Parse command input to detect command mode
|
||||||
* @param {string} rawInput - Raw input text
|
* @param {string} rawInput - Raw input text
|
||||||
@@ -1379,8 +1559,8 @@ class AutoComplete {
|
|||||||
const partialCommand = trimmed.toLowerCase();
|
const partialCommand = trimmed.toLowerCase();
|
||||||
|
|
||||||
// Check for exact command match
|
// Check for exact command match
|
||||||
if (TAG_COMMANDS[partialCommand]) {
|
if (this._getCommands()[partialCommand]) {
|
||||||
const cmd = TAG_COMMANDS[partialCommand];
|
const cmd = this._getCommands()[partialCommand];
|
||||||
// Filter out toggle commands that don't meet their condition
|
// Filter out toggle commands that don't meet their condition
|
||||||
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
||||||
return { showCommands: false, command: null, searchTerm: '' };
|
return { showCommands: false, command: null, searchTerm: '' };
|
||||||
@@ -1405,8 +1585,8 @@ class AutoComplete {
|
|||||||
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
|
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
|
||||||
const searchPart = trimmed.slice(spaceIndex + 1).trim();
|
const searchPart = trimmed.slice(spaceIndex + 1).trim();
|
||||||
|
|
||||||
if (TAG_COMMANDS[commandPart]) {
|
if (this._getCommands()[commandPart]) {
|
||||||
const cmd = TAG_COMMANDS[commandPart];
|
const cmd = this._getCommands()[commandPart];
|
||||||
// Filter out toggle commands that don't meet their condition
|
// Filter out toggle commands that don't meet their condition
|
||||||
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
||||||
return { showCommands: false, command: null, searchTerm: trimmed };
|
return { showCommands: false, command: null, searchTerm: trimmed };
|
||||||
@@ -1437,7 +1617,7 @@ class AutoComplete {
|
|||||||
|
|
||||||
const commands = [];
|
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
|
// Filter out toggle commands that don't meet their condition
|
||||||
if (info.type === 'toggle_setting' && info.condition) {
|
if (info.type === 'toggle_setting' && info.condition) {
|
||||||
if (!info.condition()) continue;
|
if (!info.condition()) continue;
|
||||||
@@ -1902,7 +2082,8 @@ class AutoComplete {
|
|||||||
|
|
||||||
showPreviewForItem(relativePath, itemElement) {
|
showPreviewForItem(relativePath, itemElement) {
|
||||||
if (!this.options.showPreview || !this.previewTooltip) return;
|
if (!this.options.showPreview || !this.previewTooltip) return;
|
||||||
|
if (typeof relativePath !== 'string' || !relativePath) return;
|
||||||
|
|
||||||
// Extract filename without extension for preview
|
// Extract filename without extension for preview
|
||||||
const fileName = relativePath.split(/[/\\]/).pop();
|
const fileName = relativePath.split(/[/\\]/).pop();
|
||||||
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||||
@@ -1984,14 +2165,18 @@ class AutoComplete {
|
|||||||
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
|
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
|
||||||
const offset = this.items.length;
|
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
|
// Execute all queries in parallel with offset
|
||||||
const searchPromises = queriesToExecute.map(async (query) => {
|
const searchPromises = queriesToExecute.map(async (query) => {
|
||||||
const url = endpoint.includes('?')
|
const url = endpoint.includes('?')
|
||||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
||||||
: `${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 {
|
try {
|
||||||
const response = await api.fetchApi(url);
|
const response = await api.fetchApi(finalUrl);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.success ? (data.relative_paths || data.words || []) : [];
|
return data.success ? (data.relative_paths || data.words || []) : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2692,14 +2877,14 @@ class AutoComplete {
|
|||||||
const settingManager = app?.extensionManager?.setting;
|
const settingManager = app?.extensionManager?.setting;
|
||||||
if (settingManager && typeof settingManager.set === 'function') {
|
if (settingManager && typeof settingManager.set === 'function') {
|
||||||
await settingManager.set(settingId, value);
|
await settingManager.set(settingId, value);
|
||||||
this._showToggleFeedback(value);
|
this._showToggleFeedback(command, value);
|
||||||
this._clearCurrentToken();
|
this._clearCurrentToken();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: use legacy settings API
|
// Fallback: use legacy settings API
|
||||||
const setting = app.ui.settings.settingsById?.[settingId];
|
const setting = app.ui.settings.settingsById?.[settingId];
|
||||||
if (setting) {
|
if (setting) {
|
||||||
app.ui.settings.setSettingValue(settingId, value);
|
app.ui.settings.setSettingValue(settingId, value);
|
||||||
this._showToggleFeedback(value);
|
this._showToggleFeedback(command, value);
|
||||||
this._clearCurrentToken();
|
this._clearCurrentToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2718,15 +2903,16 @@ class AutoComplete {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Show visual feedback for toggle action using toast
|
* Show visual feedback for toggle action using toast
|
||||||
|
* @param {Object} command - The toggle command that was executed
|
||||||
* @param {boolean} enabled - New autocomplete state
|
* @param {boolean} enabled - New autocomplete state
|
||||||
*/
|
*/
|
||||||
_showToggleFeedback(enabled) {
|
_showToggleFeedback(command, enabled) {
|
||||||
showToast({
|
showToast({
|
||||||
severity: enabled ? 'success' : 'secondary',
|
severity: enabled ? 'success' : 'secondary',
|
||||||
summary: enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled',
|
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
|
||||||
detail: enabled
|
detail: command.feedbackDetail || (enabled
|
||||||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||||||
: 'Tag autocomplete is now OFF. Use /ac to re-enable.',
|
: 'Tag autocomplete is now OFF. Use /ac to re-enable.'),
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
|
|||||||
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
|
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
|
||||||
const STRENGTH_STEP_DEFAULT = 0.05;
|
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
|
// 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
|
// 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.",
|
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"],
|
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,
|
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
|
||||||
name: "Append comma after autocomplete",
|
name: "Append comma after autocomplete",
|
||||||
@@ -549,4 +586,5 @@ export {
|
|||||||
getUsageStatisticsPreference,
|
getUsageStatisticsPreference,
|
||||||
getNewTabTemplatePreference,
|
getNewTabTemplatePreference,
|
||||||
getStrengthStepPreference,
|
getStrengthStepPreference,
|
||||||
|
getLoraActiveFiltersAutocompletePreference,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user