feat(autocomplete): match LoRA titles when the node shows model names

Node rows can show library titles (Settings > LoRA Manager > Node LoRA
Name Display), but the textarea autocomplete only ever searched file
names. In "Model Name" mode a title fragment that does not appear in the
file name therefore returned nothing, even though the LoRA was visible
on the node.

Title matching follows the existing label setting instead of adding a
new toggle: File Name mode (the default) sends the unchanged request and
gets the same results and ordering as before. Only Model Name mode asks
the endpoint for title matches, which are appended after every filename
match so a path search is never reordered or hidden. The dropdown then
shows the title as the primary row label with the file name as secondary
text, matching what the node rows display.

Also cue results that are already on the node. The set is read from the
node's LoRA list (falling back to the textarea tags), normalized like the
backend resolves names, so legacy bare-name entries are recognized too.

Co-authored-by: smhc <6404304+smhc@users.noreply.github.com>
This commit is contained in:
Will Miao
2026-11-07 20:06:23 +08:00
co-authored by smhc
parent 638675449d
commit f0a8bb85c8
10 changed files with 747 additions and 45 deletions
+33 -4
View File
@@ -1841,10 +1841,16 @@ class ModelQueryHandler:
or allow_selling_generated_content is not None
)
matching_paths = await self._service.search_relative_paths(
search,
limit,
offset,
match_model_name = request.query.get("match_model_name", "").lower() in (
"1",
"true",
)
include_model_name = request.query.get("include_model_name", "").lower() in (
"1",
"true",
)
search_kwargs = dict(
folder=folder,
recursive=recursive,
base_models=base_models,
@@ -1856,6 +1862,29 @@ class ModelQueryHandler:
allow_selling_generated_content=allow_selling_generated_content,
apply_filters=apply_filters,
)
if match_model_name or include_model_name:
matches = await self._service.search_relative_path_matches(
search,
limit,
offset,
match_model_name=match_model_name,
**search_kwargs,
)
payload = {
"success": True,
"relative_paths": [match["relative_path"] for match in matches],
}
if include_model_name:
payload["items"] = matches
return web.json_response(payload)
matching_paths = await self._service.search_relative_paths(
search,
limit,
offset,
**search_kwargs,
)
return web.json_response(
{"success": True, "relative_paths": matching_paths}
)
+64 -14
View File
@@ -1344,6 +1344,23 @@ class BaseModelService(ABC):
)
async def search_relative_paths(
self,
search_term: str,
limit: int = 15,
offset: int = 0,
**kwargs: Any,
) -> List[str]:
"""Search relative paths for autocomplete (paths only).
Thin wrapper over :meth:`search_relative_path_matches` that keeps the
historical response shape: a list of relative path strings.
"""
matches = await self.search_relative_path_matches(
search_term, limit, offset, **kwargs
)
return [match["relative_path"] for match in matches]
async def search_relative_path_matches(
self,
search_term: str,
limit: int = 15,
@@ -1361,12 +1378,19 @@ class BaseModelService(ABC):
allow_selling_generated_content: Optional[bool] = None,
recursive: bool = True,
apply_filters: bool = False,
) -> List[str]:
"""Search model relative file paths for autocomplete functionality.
match_model_name: bool = False,
) -> List[Dict[str, str]]:
"""Search model entries for autocomplete, returning path and title.
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.
With ``match_model_name`` enabled, entries whose library title matches
the same tokens are also returned. Title-only matches are appended
after every path match, so the historical filename ordering (and the
ability to find a file by name) is unchanged. Tokens must all hit the
same field: a query is either a path query or a title query.
"""
cache = await self.scanner.get_cached_data()
include_terms, exclude_terms = self._parse_search_tokens(search_term)
@@ -1418,12 +1442,15 @@ class BaseModelService(ABC):
data, allow_selling_generated_content
)
matching_paths = []
path_matches: List[Dict[str, str]] = []
title_only_matches: List[Dict[str, str]] = []
# Get model roots for path calculation
model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset)
# Collect all matching entries first (needed for proper sorting and
# offset). Path matches keep the historical ordering; title-only
# matches are collected separately so they can never outrank them.
for model in data:
file_path = model.get("file_path", "")
if not file_path:
@@ -1446,22 +1473,45 @@ class BaseModelService(ABC):
if not relative_path:
continue
model_name = str(model.get("model_name") or "")
relative_lower = relative_path.lower()
if self._relative_path_matches_tokens(
relative_lower, include_terms, exclude_terms
):
matching_paths.append(relative_path)
path_matches.append(
{"relative_path": relative_path, "model_name": model_name}
)
continue
# Group by folder (root first, then alphabetically) and sort by
# relevance (prefix and earliest hits, then length and alphabetically)
# within each folder group.
matching_paths.sort(
key=lambda relative: self._relative_path_folder_group_sort_key(
relative, include_terms
if not match_model_name or not model_name:
continue
if self._relative_path_matches_tokens(
model_name.lower(), include_terms, exclude_terms
):
title_only_matches.append(
{"relative_path": relative_path, "model_name": model_name}
)
# Group path matches by folder (root first, then alphabetically) and
# sort by relevance within each folder group. Title-only matches follow
# in title order, so a title search adds rows without reordering the
# filename results users already rely on.
path_matches.sort(
key=lambda match: self._relative_path_folder_group_sort_key(
match["relative_path"], include_terms
)
)
title_only_matches.sort(
key=lambda match: (
match["model_name"].lower(),
match["relative_path"].lower(),
)
)
matches = path_matches + title_only_matches
# Apply offset and limit
start = min(offset, len(matching_paths))
end = min(start + limit, len(matching_paths))
return matching_paths[start:end]
start = min(offset, len(matches))
end = min(start + limit, len(matches))
return matches[start:end]
@@ -0,0 +1,268 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const settingSetMock = vi.fn();
const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};
const previewTooltipMock = {
show: vi.fn(),
hide: vi.fn(),
cleanup: vi.fn(),
};
vi.mock(API_MODULE, () => ({
api: {
fetchApi: fetchApiMock,
addEventListener: vi.fn(),
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
extensionManager: {
setting: {
get: settingGetMock,
set: settingSetMock,
},
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => previewTooltipMock),
}));
const PATH = 'portraits/example_entry.safetensors';
const TITLE = 'Golden Hour Portrait';
const respondWith = ({ paths = [PATH], items = null } = {}) => {
const payload = { success: true, relative_paths: paths };
if (items) {
payload.items = items;
}
return {
json: () => Promise.resolve(payload),
};
};
const mountAutocomplete = async ({ node } = {}) => {
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
node,
});
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
return { input, autoComplete };
};
const itemEls = (autoComplete) =>
Array.from(autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item'));
describe('AutoComplete LoRA title matching and added cue', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset();
settingGetMock.mockReset();
settingSetMock.mockReset();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') return true;
if (key === 'loramanager.autocomplete_auto_format') return true;
if (key === 'loramanager.autocomplete_accept_key') return 'both';
if (key === 'loramanager.prompt_tag_autocomplete') return true;
if (key === 'loramanager.tag_space_replacement') return false;
if (key === 'loramanager.node_lora_name_display') return 'File Name';
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue('');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
previewTooltipMock.show.mockReset();
previewTooltipMock.hide.mockReset();
previewTooltipMock.cleanup.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps the legacy request and rendering in File Name mode', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { autoComplete } = await mountAutocomplete();
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100',
);
const [item] = itemEls(autoComplete);
expect(item).toBeTruthy();
expect(item.querySelector('.lm-autocomplete-name').textContent).toBe(
'portraits/example_entry',
);
expect(item.querySelector('.lm-autocomplete-subtitle')).toBeNull();
expect(item.querySelector('.lm-autocomplete-added')).toBeNull();
});
it('requests and shows library titles in Model Name mode', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.node_lora_name_display') return 'Model Name';
return undefined;
});
fetchApiMock.mockResolvedValue(
respondWith({
items: [{ relative_path: PATH, model_name: TITLE }],
}),
);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { autoComplete } = await mountAutocomplete();
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100&match_model_name=true&include_model_name=true',
);
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-name').textContent).toBe(TITLE);
expect(item.querySelector('.lm-autocomplete-subtitle').textContent).toBe(
'portraits/example_entry',
);
});
it('falls back to the file name when the entry has no title', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.node_lora_name_display') return 'Model Name';
return undefined;
});
fetchApiMock.mockResolvedValue(
respondWith({
items: [{ relative_path: PATH, model_name: '' }],
}),
);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { autoComplete } = await mountAutocomplete();
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-name').textContent).toBe(
'portraits/example_entry',
);
expect(item.querySelector('.lm-autocomplete-subtitle')).toBeNull();
});
it('marks a LoRA that is already on the node list', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {
lorasWidget: {
value: [{ name: 'portraits/example_entry.safetensors', active: false }],
},
};
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
const cue = item.querySelector('.lm-autocomplete-added');
expect(cue).toBeTruthy();
expect(cue.textContent).toBe('✓');
expect(cue.getAttribute('aria-label')).toBe('Already added');
});
it('matches legacy bare-name list entries by file name', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = { lorasWidget: { value: [{ name: 'example_entry' }] } };
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-added')).toBeTruthy();
});
it('does not mark a LoRA that is not on the node list', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {
lorasWidget: { value: [{ name: 'styles/other_entry' }] },
};
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-added')).toBeNull();
});
it('falls back to parsing the textarea when the node has no LoRA list', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {};
const { autoComplete, input } = await mountAutocomplete({ node });
input.value = 'masterpiece, <lora:portraits/example_entry:0.8>';
autoComplete.render();
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-added')).toBeTruthy();
});
it('does not request titles for non-LoRA autocompletes', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.node_lora_name_display') return 'Model Name';
return undefined;
});
fetchApiMock.mockResolvedValue({ json: () => Promise.resolve({ success: true, words: [] }) });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] || '';
expect(requestedUrl).not.toContain('match_model_name');
expect(requestedUrl).not.toContain('include_model_name');
});
});
@@ -14,11 +14,23 @@ class DummyService:
def __init__(self):
self.calls = []
self.match_calls = []
async def search_relative_paths(self, search, limit, offset, **kwargs):
self.calls.append((search, limit, offset, kwargs))
return []
async def search_relative_path_matches(
self, search, limit, offset, *, match_model_name=False, **kwargs
):
self.match_calls.append((search, limit, offset, match_model_name, kwargs))
return [
{
"relative_path": "portraits/example.safetensors",
"model_name": "Example Title",
}
]
def make_handler(service=None):
return ModelQueryHandler(
@@ -195,3 +207,58 @@ async def test_relative_paths_without_flag_ignores_store():
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] is None
assert kwargs["apply_filters"] is False
@pytest.mark.asyncio
async def test_relative_paths_title_search_returns_items():
service = DummyService()
handler = make_handler(service)
response = await handler.get_relative_paths(
make_request(
{
"search": "example",
"match_model_name": "true",
"include_model_name": "true",
}
)
)
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["relative_paths"] == ["portraits/example.safetensors"]
assert payload["items"] == [
{
"relative_path": "portraits/example.safetensors",
"model_name": "Example Title",
}
]
assert service.match_calls[0][3] is True
assert service.calls == []
@pytest.mark.asyncio
async def test_relative_paths_title_search_without_items_payload():
service = DummyService()
handler = make_handler(service)
response = await handler.get_relative_paths(
make_request({"search": "example", "match_model_name": "1"})
)
payload = json.loads(response.text)
assert payload["relative_paths"] == ["portraits/example.safetensors"]
assert "items" not in payload
@pytest.mark.asyncio
async def test_relative_paths_default_response_has_no_items():
service = DummyService()
handler = make_handler(service)
response = await handler.get_relative_paths(make_request({"search": "cartoon"}))
payload = json.loads(response.text)
assert payload["relative_paths"] == []
assert "items" not in payload
assert service.match_calls == []
+102
View File
@@ -453,3 +453,105 @@ async def test_search_relative_paths_no_filters_regression():
f"flux{os.sep}detail-model.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_path_matches_returns_library_titles():
scanner = FakeScanner(
[
{
"file_path": "/models/portraits/8f3c1a9d2b7e4f60.safetensors",
"model_name": "Golden Hour Portrait",
},
{
"file_path": "/models/styles/watercolour_wash.safetensors",
"model_name": "Watercolour Wash",
},
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
matches = await service.search_relative_path_matches(
"golden", match_model_name=True
)
assert matches == [
{
"relative_path": f"portraits{os.sep}8f3c1a9d2b7e4f60.safetensors",
"model_name": "Golden Hour Portrait",
}
]
@pytest.mark.asyncio
async def test_title_matching_is_opt_in():
scanner = FakeScanner(
[
{
"file_path": "/models/portraits/8f3c1a9d2b7e4f60.safetensors",
"model_name": "Golden Hour Portrait",
}
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
assert await service.search_relative_path_matches("golden") == []
assert await service.search_relative_paths("golden") == []
@pytest.mark.asyncio
async def test_title_only_matches_rank_after_every_path_match():
"""Title matches append; they never reorder or hide filename results."""
scanner = FakeScanner(
[
{
"file_path": "/models/zz-first/portrait-helper.safetensors",
"model_name": "Unrelated Helper",
},
{
"file_path": "/models/a-early/portrait-base.safetensors",
"model_name": "Base Model",
},
{
"file_path": "/models/styles/random-blend.safetensors",
"model_name": "Portrait Master",
},
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
matches = await service.search_relative_path_matches(
"portrait", match_model_name=True
)
# Path matches keep their folder-group order even though the title-only
# entry lives in a folder ("portraits") that sorts before "zz-first".
assert [match["relative_path"] for match in matches] == [
f"a-early{os.sep}portrait-base.safetensors",
f"zz-first{os.sep}portrait-helper.safetensors",
f"styles{os.sep}random-blend.safetensors",
]
assert matches[2]["model_name"] == "Portrait Master"
@pytest.mark.asyncio
async def test_title_matching_honours_exclude_tokens():
scanner = FakeScanner(
[
{
"file_path": "/models/portraits/random-blend.safetensors",
"model_name": "Golden Hour Portrait",
}
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
assert (
await service.search_relative_path_matches(
"golden -hour", match_model_name=True
)
== []
)
@@ -115,7 +115,7 @@ const showClearButton = computed(() => hasText.value)
useAutocomplete(
textareaRef,
props.modelType ?? 'loras',
{ showPreview: props.showPreview ?? true }
{ showPreview: props.showPreview ?? true, node: props.node }
)
const updateHasTextState = () => {
@@ -12,6 +12,9 @@ interface AutocompleteOptions {
minChars?: number
debounceDelay?: number
showPreview?: boolean
// LiteGraph node owning the textarea; the autocomplete reads its LoRA list
// to mark results that are already added.
node?: unknown
}
interface AutoCompleteInstance {
@@ -25,6 +28,7 @@ export interface UseAutocompleteOptions {
maxItems?: number
minChars?: number
debounceDelay?: number
node?: unknown
}
export function useAutocomplete(
+187 -5
View File
@@ -15,10 +15,13 @@ import {
getAutocompleteAutoFormatPreference,
getAutocompleteAcceptKeyPreference,
getLoraActiveFiltersAutocompletePreference,
getNodeLoraNameDisplayPreference,
getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference,
setLoraManagerSettingValue,
} from "./settings.js";
import { normalizeLoraNameKey } from "./loras_widget_utils.js";
import { parseLoraCodeTags } from "./lora_selection.js";
import { showToast } from "./utils.js";
// localStorage key for the one-time "how to disable" hint in the dropdown
@@ -588,6 +591,12 @@ class AutoComplete {
this.selectedIndex = -1;
this.hasManualSelection = false;
this.items = [];
// Relative path -> library title, filled from `items` in the
// relative-paths response when the node rows show model names.
this.itemModelNames = new Map();
// Snapshot of the LoRA names already on the node, refreshed per render
// so the "already added" cue cannot go stale between keystrokes.
this.addedNamesForRender = null;
this.debounceTimer = null;
this.isVisible = false;
this.currentSearchTerm = '';
@@ -1408,6 +1417,91 @@ class AutoComplete {
return 'use_active_filters=true';
}
/**
* Whether the node rows show library titles.
*
* Title matching follows the existing label setting instead of adding a new
* toggle: the default "File Name" mode keeps the historical filename-only
* search (and therefore the same request URL and payload). Only a user who
* chose to read the node by model name gets title matches.
*/
_shouldUseLoraTitles() {
if (this.modelType !== 'loras') {
return false;
}
try {
return getNodeLoraNameDisplayPreference() === 'model_name';
} catch (error) {
return false;
}
}
/**
* Extra query parameters for title-aware searches (null keeps the legacy
* request untouched).
*/
_getLoraTitleQuery() {
return this._shouldUseLoraTitles()
? 'match_model_name=true&include_model_name=true'
: null;
}
/**
* Snapshot of the LoRA names already on the node, normalized the same way
* the node stores them. Prefers the LoRA list when the node has one (the
* list is authoritative there); text-only nodes fall back to the tags in
* the textarea.
*/
_collectAddedLoraNames() {
if (this.modelType !== 'loras') {
return null;
}
const node = this.options.node;
if (!node) {
return null;
}
const names = new Set();
const lorasWidget = node.lorasWidget;
if (lorasWidget && Array.isArray(lorasWidget.value)) {
for (const lora of lorasWidget.value) {
const key = normalizeLoraNameKey(lora?.name);
if (key) {
names.add(key);
}
}
return names;
}
for (const tag of parseLoraCodeTags(this.inputElement?.value || '')) {
names.add(tag.name);
}
return names;
}
/** Library title for a result path, or '' when it is unknown. */
_getLoraTitle(relativePath) {
return this.itemModelNames.get(relativePath) || '';
}
/** Whether the current results include this path in the node's LoRA list. */
_isLoraAdded(relativePath) {
if (!this.addedNamesForRender || this.addedNamesForRender.size === 0) {
return false;
}
const key = normalizeLoraNameKey(relativePath);
if (!key) {
return false;
}
if (this.addedNamesForRender.has(key)) {
return true;
}
// Legacy workflows store entries without their folder; the backend
// resolves those by bare file name, so the cue must too.
const bareName = key.slice(key.lastIndexOf('/') + 1);
return bareName !== key && this.addedNamesForRender.has(bareName);
}
async search(term = '', endpoint = null) {
try {
this.currentSearchTerm = term;
@@ -1428,6 +1522,10 @@ class AutoComplete {
// 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();
// Title matching + titles are opt-in and follow the node label
// setting, so the default filename mode sends the legacy request.
const titleQuery = this._getLoraTitleQuery();
this.itemModelNames = new Map();
// Generate multiple query variations for better matching, but avoid
// sending duplicate-equivalent requests that normalize to the same
@@ -1448,14 +1546,24 @@ 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;
const baseUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
const finalUrl = titleQuery ? `${baseUrl}&${titleQuery}` : baseUrl;
try {
const response = await api.fetchApi(finalUrl);
const data = await response.json();
const names = new Map();
if (Array.isArray(data.items)) {
for (const entry of data.items) {
if (entry && typeof entry === 'object' && entry.relative_path) {
names.set(entry.relative_path, entry.model_name || '');
}
}
}
return {
items: data.success ? (data.relative_paths || data.words || []) : [],
meta: data?.meta || null,
names,
};
} catch (error) {
console.warn(`Search query failed for "${query}":`, error);
@@ -1486,6 +1594,12 @@ class AutoComplete {
this.wildcardMeta = result.meta;
}
if (result?.names instanceof Map) {
for (const [relativePath, modelName] of result.names) {
this.itemModelNames.set(relativePath, modelName);
}
}
const resultArray = result?.items || [];
for (const item of resultArray) {
const itemKey = typeof item === 'object' && item.tag_name
@@ -1895,6 +2009,8 @@ class AutoComplete {
render() {
this.selectedIndex = -1;
this.hasManualSelection = false;
// Refresh the "already added" set once per render instead of per row.
this.addedNamesForRender = this._collectAddedLoraNames();
// Command-list state hints do not belong to regular search results
this._removeCommandListFooter();
@@ -2082,6 +2198,13 @@ class AutoComplete {
itemEl.appendChild(actions);
}
_escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
highlightMatch(text, searchTerm) {
const { include } = parseSearchTokens(searchTerm);
const sanitizedTokens = include
@@ -2242,17 +2365,28 @@ class AutoComplete {
// Active-filter query params for loras (null when setting off)
const activeFiltersQuery = this._getActiveLoraFilters();
// Keep pagination consistent with the initial search: if titles
// were requested there, later pages must carry them too.
const titleQuery = this._getLoraTitleQuery();
// 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;
const baseUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
const finalUrl = titleQuery ? `${baseUrl}&${titleQuery}` : baseUrl;
try {
const response = await api.fetchApi(finalUrl);
const data = await response.json();
if (Array.isArray(data.items)) {
for (const entry of data.items) {
if (entry && typeof entry === 'object' && entry.relative_path) {
this.itemModelNames.set(entry.relative_path, entry.model_name || '');
}
}
}
return data.success ? (data.relative_paths || data.words || []) : [];
} catch (error) {
console.warn(`Search query failed for "${query}":`, error);
@@ -2376,6 +2510,9 @@ class AutoComplete {
*/
updateVisibleItems() {
if (!this.scrollContainer || !this.contentContainer) return;
// Scrolling re-renders rows without going through render(), so refresh
// the snapshot here too (cheap: one pass over the node's LoRA list).
this.addedNamesForRender = this._collectAddedLoraNames();
if (this._containsInformationalItems()) {
this.contentContainer.innerHTML = '';
@@ -2480,11 +2617,21 @@ class AutoComplete {
} else if (isEnriched) {
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
// Use display text without extension for cleaner UI
const displayTextWithoutExt = this._getDisplayText(itemData);
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
const title = this._getLoraTitle(itemData);
const useTitle = this._shouldUseLoraTitles() && Boolean(title);
const primaryText = useTitle ? title : displayTextWithoutExt;
const secondaryText = useTitle
? displayTextWithoutExt
: (this.modelType === 'loras' ? title : '');
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
nameSpan.innerHTML = this.highlightMatch(
this._escapeHtml(primaryText),
this.currentSearchTerm,
);
nameSpan.style.cssText = `
flex: 1;
min-width: 0;
@@ -2492,6 +2639,41 @@ class AutoComplete {
text-overflow: ellipsis;
`;
item.appendChild(nameSpan);
// Show the other name (path when the title is shown, title when
// the path is) so a row never hides which file it refers to.
if (secondaryText && secondaryText !== primaryText) {
const subtitle = document.createElement('span');
subtitle.className = 'lm-autocomplete-subtitle';
subtitle.textContent = secondaryText;
subtitle.style.cssText = `
flex: 0 1 45%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--lm-fg-subtle, #9aa0a6);
font-size: 11px;
text-align: right;
`;
item.appendChild(subtitle);
}
// "Already added" cue: the node's own list decides, so a LoRA that
// is only present in the textarea is still reported as added.
if (this._isLoraAdded(itemData)) {
const addedCue = document.createElement('span');
addedCue.className = 'lm-autocomplete-added';
addedCue.textContent = '✓';
addedCue.title = 'Already added';
addedCue.setAttribute('aria-label', 'Already added');
addedCue.style.cssText = `
flex: none;
font-weight: 600;
color: var(--lm-accent, #6aa9ff);
`;
item.appendChild(addedCue);
}
}
// Prevent textarea from losing focus when clicking dropdown items.
+20 -20
View File
@@ -2123,14 +2123,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-a0c1830b] {
.autocomplete-text-widget[data-v-ba49c841] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-a0c1830b] {
.input-wrapper[data-v-ba49c841] {
position: relative;
flex: 1;
display: flex;
@@ -2140,7 +2140,7 @@ to { transform: rotate(360deg);
/* Theme tokens (--lm-*) come from the widget container, which ComfyUI's
renderer switches between the canvas widget palette and the Vue node
widget palette. Text size matches ComfyUI's own textarea in both modes. */
.text-input[data-v-a0c1830b] {
.text-input[data-v-ba49c841] {
flex: 1;
width: 100%;
background-color: var(--lm-surface);
@@ -2160,18 +2160,18 @@ to { transform: rotate(360deg);
/* Vue DOM mode: same surface/foreground as the built-in WidgetTextarea,
only the box metrics differ. */
.text-input.vue-dom-mode[data-v-a0c1830b] {
.text-input.vue-dom-mode[data-v-ba49c841] {
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
margin: 0 0 4px;
border-radius: 6px; /* matches ComfyUI's rounded-md textarea */
font-family: inherit;
}
.text-input[data-v-a0c1830b]:focus {
.text-input[data-v-ba49c841]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-a0c1830b] {
.clear-button[data-v-ba49c841] {
position: absolute;
right: calc(6px + var(--lm-vscrollbar-width, 0px));
bottom: 6px; /* Changed from top to bottom */
@@ -2194,28 +2194,28 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-a0c1830b] {
.input-wrapper:hover .clear-button[data-v-ba49c841] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-a0c1830b]:hover {
.clear-button[data-v-ba49c841]:hover {
opacity: 1;
background: var(--lm-danger-soft);
color: var(--lm-danger-text);
}
.clear-button svg[data-v-a0c1830b] {
.clear-button svg[data-v-ba49c841] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-a0c1830b] {
.text-input.vue-dom-mode ~ .clear-button[data-v-ba49c841] {
right: calc(8px + var(--lm-vscrollbar-width, 0px));
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-a0c1830b] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-ba49c841] {
width: 14px;
height: 14px;
}
@@ -15233,7 +15233,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
useAutocomplete(
textareaRef,
props.modelType ?? "loras",
{ showPreview: props.showPreview ?? true }
{ showPreview: props.showPreview ?? true, node: props.node }
);
const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
@@ -15410,7 +15410,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-a0c1830b"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-ba49c841"]]);
const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" };
@@ -15751,6 +15751,13 @@ function createVueWidgetCleanup(vueApp, onCleanup) {
onCleanup == null ? void 0 : onCleanup();
};
}
function getComfyUIBasePath() {
const { pathname } = window.location;
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
function lmUrl(path) {
return `${getComfyUIBasePath()}${path}`;
}
let _loraSyntaxFormatCache = null;
let _loraSyntaxFormatRefreshPromise = null;
async function _fetchLoraSyntaxFormat() {
@@ -15865,13 +15872,6 @@ function stripAutocompleteMetadataFromPromptResult(result) {
}
return result;
}
function getComfyUIBasePath() {
const { pathname } = window.location;
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
function lmUrl(path) {
return `${getComfyUIBasePath()}${path}`;
}
const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES$1 = [
"Lora Stacker (LoraManager)",
File diff suppressed because one or more lines are too long