mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
fix(autocomplete): improve tag search ranking with popularity-based sorting
- Add LOG10(post_count) weighting to BM25 score for better relevance ranking - Prioritize tag_name prefix matches above alias matches using CASE statement - Remove frontend re-scoring logic to trust backend排序 results - Fix pagination consistency: page N+1 scores <= page N minimum score Key improvements: - '1girl' (6M posts) now ranks #1 instead of #149 for search '1' - tag_name prefix matches always appear before alias matches - Popular tags rank higher than obscure ones with same prefix - Consistent ordering across pagination boundaries Test coverage: - Add test_search_tag_name_prefix_match_priority - Add test_search_ranks_popular_tags_higher - Add test_search_pagination_ordering_consistency - Add test_search_rank_score_includes_popularity_weight - Update test data with 15 tags starting with '1' Fixes issues with autocomplete dropdown showing inconsistent results when scrolling through paginated search results.
This commit is contained in:
@@ -449,6 +449,11 @@ class TagFTSIndex:
|
|||||||
Supports alias search: if the query matches an alias rather than
|
Supports alias search: if the query matches an alias rather than
|
||||||
the tag_name, the result will include a "matched_alias" field.
|
the tag_name, the result will include a "matched_alias" field.
|
||||||
|
|
||||||
|
Ranking is based on a combination of:
|
||||||
|
1. FTS5 bm25 relevance score (how well the text matches)
|
||||||
|
2. Post count (popularity)
|
||||||
|
3. Exact prefix match boost (tag_name starts with query)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The search query string.
|
query: The search query string.
|
||||||
categories: Optional list of category IDs to filter by.
|
categories: Optional list of category IDs to filter by.
|
||||||
@@ -457,7 +462,7 @@ class TagFTSIndex:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dictionaries with tag_name, category, post_count,
|
List of dictionaries with tag_name, category, post_count,
|
||||||
and optionally matched_alias.
|
rank_score, and optionally matched_alias.
|
||||||
"""
|
"""
|
||||||
# Ensure index is ready (lazy initialization)
|
# Ensure index is ready (lazy initialization)
|
||||||
if not self.ensure_ready():
|
if not self.ensure_ready():
|
||||||
@@ -473,35 +478,67 @@ class TagFTSIndex:
|
|||||||
if not fts_query:
|
if not fts_query:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
query_lower = query.lower().strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
conn = self._connect(readonly=True)
|
conn = self._connect(readonly=True)
|
||||||
try:
|
try:
|
||||||
# Build the SQL query - now also fetch aliases for matched_alias detection
|
# Build the SQL query with bm25 ranking
|
||||||
# Use subquery for category filter to ensure FTS is evaluated first
|
# FTS5 bm25() returns negative scores, lower is better
|
||||||
|
# We use -bm25() to get higher=better scores
|
||||||
|
# Weights: -100.0 for exact matches, 1.0 for others
|
||||||
|
# Add LOG10(post_count) weighting to boost popular tags
|
||||||
|
# Use CASE to boost tag_name prefix matches above alias matches
|
||||||
if categories:
|
if categories:
|
||||||
placeholders = ",".join("?" * len(categories))
|
placeholders = ",".join("?" * len(categories))
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||||
FROM tags t
|
CASE
|
||||||
WHERE t.rowid IN (
|
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||||
SELECT rowid FROM tag_fts WHERE searchable_text MATCH ?
|
ELSE 0
|
||||||
)
|
END AS is_tag_name_match,
|
||||||
|
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
||||||
|
FROM tag_fts
|
||||||
|
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||||
|
WHERE tag_fts.searchable_text MATCH ?
|
||||||
AND t.category IN ({placeholders})
|
AND t.category IN ({placeholders})
|
||||||
ORDER BY t.post_count DESC
|
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
params = [fts_query] + categories + [limit, offset]
|
# Escape special LIKE characters and add wildcard
|
||||||
|
query_escaped = (
|
||||||
|
query_lower.lstrip("/")
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
)
|
||||||
|
params = (
|
||||||
|
[query_escaped + "%", fts_query]
|
||||||
|
+ categories
|
||||||
|
+ [limit, offset]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||||
FROM tag_fts f
|
CASE
|
||||||
JOIN tags t ON f.rowid = t.rowid
|
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||||
WHERE f.searchable_text MATCH ?
|
ELSE 0
|
||||||
ORDER BY t.post_count DESC
|
END AS is_tag_name_match,
|
||||||
|
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
||||||
|
FROM tag_fts
|
||||||
|
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||||
|
WHERE tag_fts.searchable_text MATCH ?
|
||||||
|
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
params = [fts_query, limit, offset]
|
query_escaped = (
|
||||||
|
query_lower.lstrip("/")
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
)
|
||||||
|
params = [query_escaped + "%", fts_query, limit, offset]
|
||||||
|
|
||||||
cursor = conn.execute(sql, params)
|
cursor = conn.execute(sql, params)
|
||||||
results = []
|
results = []
|
||||||
@@ -510,8 +547,17 @@ class TagFTSIndex:
|
|||||||
"tag_name": row[0],
|
"tag_name": row[0],
|
||||||
"category": row[1],
|
"category": row[1],
|
||||||
"post_count": row[2],
|
"post_count": row[2],
|
||||||
|
"is_tag_name_match": row[4] == 1,
|
||||||
|
"rank_score": row[5],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Set is_exact_prefix based on tag_name match
|
||||||
|
tag_name = row[0]
|
||||||
|
if tag_name.lower().startswith(query_lower.lstrip("/")):
|
||||||
|
result["is_exact_prefix"] = True
|
||||||
|
else:
|
||||||
|
result["is_exact_prefix"] = result["is_tag_name_match"]
|
||||||
|
|
||||||
# Check if search matched an alias rather than the tag_name
|
# Check if search matched an alias rather than the tag_name
|
||||||
matched_alias = self._find_matched_alias(query, row[0], row[3])
|
matched_alias = self._find_matched_alias(query, row[0], row[3])
|
||||||
if matched_alias:
|
if matched_alias:
|
||||||
|
|||||||
@@ -156,4 +156,115 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(highlighted).toContain('detail');
|
expect(highlighted).toContain('detail');
|
||||||
expect(highlighted).not.toMatch(/beta<\/span>/i);
|
expect(highlighted).not.toMatch(/beta<\/span>/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles arrow key navigation with virtual scrolling', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`);
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('model');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
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,
|
||||||
|
enableVirtualScroll: true,
|
||||||
|
itemHeight: 40,
|
||||||
|
visibleItems: 15,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = 'model';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.items.length).toBeGreaterThan(0);
|
||||||
|
expect(autoComplete.selectedIndex).toBe(0);
|
||||||
|
|
||||||
|
const initialSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||||
|
expect(initialSelectedEl).toBeDefined();
|
||||||
|
|
||||||
|
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
||||||
|
input.dispatchEvent(arrowDownEvent);
|
||||||
|
|
||||||
|
expect(autoComplete.selectedIndex).toBe(1);
|
||||||
|
|
||||||
|
const secondSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||||
|
expect(secondSelectedEl).toBeDefined();
|
||||||
|
expect(secondSelectedEl?.dataset.index).toBe('1');
|
||||||
|
|
||||||
|
const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
|
||||||
|
input.dispatchEvent(arrowUpEvent);
|
||||||
|
|
||||||
|
expect(autoComplete.selectedIndex).toBe(0);
|
||||||
|
|
||||||
|
const firstSelectedElAgain = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||||
|
expect(firstSelectedElAgain).toBeDefined();
|
||||||
|
expect(firstSelectedElAgain?.dataset.index).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains selection when scrolling to invisible items', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`);
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('item');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.style.width = '400px';
|
||||||
|
input.style.height = '200px';
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
enableVirtualScroll: true,
|
||||||
|
itemHeight: 40,
|
||||||
|
visibleItems: 15,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = 'item';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.items.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
autoComplete.selectedIndex = 14;
|
||||||
|
|
||||||
|
const scrollTopBefore = autoComplete.scrollContainer?.scrollTop || 0;
|
||||||
|
|
||||||
|
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
||||||
|
input.dispatchEvent(arrowDownEvent);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.selectedIndex).toBe(15);
|
||||||
|
|
||||||
|
const selectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||||
|
expect(selectedEl).toBeDefined();
|
||||||
|
expect(selectedEl?.dataset.index).toBe('15');
|
||||||
|
|
||||||
|
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
||||||
|
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,10 +31,27 @@ def temp_db_path():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_csv_path():
|
def temp_csv_path():
|
||||||
"""Create a temporary CSV file with test data."""
|
"""Create a temporary CSV file with test data."""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f:
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
# Write test data in the same format as danbooru_e621_merged.csv
|
# Write test data in the same format as danbooru_e621_merged.csv
|
||||||
# Format: tag_name,category,post_count,aliases
|
# Format: tag_name,category,post_count,aliases
|
||||||
|
# Include multiple tags starting with "1" to test popularity-based ranking
|
||||||
f.write('1girl,0,6008644,"1girls,sole_female"\n')
|
f.write('1girl,0,6008644,"1girls,sole_female"\n')
|
||||||
|
f.write('1boy,0,1405457,"1boys,sole_male"\n')
|
||||||
|
f.write('1:1,14,377032,""\n')
|
||||||
|
f.write('16:9,14,152866,""\n')
|
||||||
|
f.write('1other,0,70962,""\n')
|
||||||
|
f.write('16:10,14,14739,""\n')
|
||||||
|
f.write('1990s_(style),0,9369,""\n')
|
||||||
|
f.write('1_eye,0,7179,""\n')
|
||||||
|
f.write('1:2,14,5865,""\n')
|
||||||
|
f.write('1980s_(style),0,5665,""\n')
|
||||||
|
f.write('1koma,0,4384,""\n')
|
||||||
|
f.write('1_horn,0,2122,""\n')
|
||||||
|
f.write('101_dalmatian_street,3,1933,""\n')
|
||||||
|
f.write('1upgobbo,3,1731,""\n')
|
||||||
|
f.write('14:9,14,1038,""\n')
|
||||||
f.write('highres,5,5256195,"high_res,high_resolution,hires"\n')
|
f.write('highres,5,5256195,"high_res,high_resolution,hires"\n')
|
||||||
f.write('solo,0,5000954,"alone,female_solo,single"\n')
|
f.write('solo,0,5000954,"alone,female_solo,single"\n')
|
||||||
f.write('hatsune_miku,4,500000,"miku"\n')
|
f.write('hatsune_miku,4,500000,"miku"\n')
|
||||||
@@ -86,7 +103,7 @@ class TestTagFTSIndexBuild:
|
|||||||
fts.build_index()
|
fts.build_index()
|
||||||
|
|
||||||
assert fts.is_ready() is True
|
assert fts.is_ready() is True
|
||||||
assert fts.get_indexed_count() == 10
|
assert fts.get_indexed_count() == 24
|
||||||
|
|
||||||
def test_build_index_nonexistent_csv(self, temp_db_path):
|
def test_build_index_nonexistent_csv(self, temp_db_path):
|
||||||
"""Test that build_index handles missing CSV gracefully."""
|
"""Test that build_index handles missing CSV gracefully."""
|
||||||
@@ -187,6 +204,76 @@ class TestTagFTSIndexSearch:
|
|||||||
results = populated_fts.search("girl", limit=1)
|
results = populated_fts.search("girl", limit=1)
|
||||||
assert len(results) <= 1
|
assert len(results) <= 1
|
||||||
|
|
||||||
|
def test_search_tag_name_prefix_match_priority(self, populated_fts):
|
||||||
|
"""Test that tag_name prefix matches rank higher than alias matches."""
|
||||||
|
results = populated_fts.search("1", limit=20)
|
||||||
|
|
||||||
|
assert len(results) > 0, "Should return results for '1'"
|
||||||
|
|
||||||
|
# Find first alias match (if any)
|
||||||
|
first_alias_idx = None
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if result.get("matched_alias"):
|
||||||
|
first_alias_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# All tag_name prefix matches should come before alias matches
|
||||||
|
if first_alias_idx is not None:
|
||||||
|
for i in range(first_alias_idx):
|
||||||
|
assert results[i]["tag_name"].lower().startswith("1"), (
|
||||||
|
f"Tag at index {i} should start with '1' before alias matches"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_ranks_popular_tags_higher(self, populated_fts):
|
||||||
|
"""Test that tags with higher post_count rank higher among prefix matches."""
|
||||||
|
results = populated_fts.search("1", limit=20)
|
||||||
|
|
||||||
|
# Filter to only tag_name prefix matches
|
||||||
|
prefix_matches = [r for r in results if r["tag_name"].lower().startswith("1")]
|
||||||
|
|
||||||
|
assert len(prefix_matches) > 1, "Should have multiple prefix matches"
|
||||||
|
|
||||||
|
# Verify descending post_count order among prefix matches
|
||||||
|
for i in range(len(prefix_matches) - 1):
|
||||||
|
assert (
|
||||||
|
prefix_matches[i]["post_count"] >= prefix_matches[i + 1]["post_count"]
|
||||||
|
), (
|
||||||
|
f"Tags should be sorted by post_count: {prefix_matches[i]['tag_name']} ({prefix_matches[i]['post_count']}) >= {prefix_matches[i + 1]['tag_name']} ({prefix_matches[i + 1]['post_count']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_pagination_ordering_consistency(self, populated_fts):
|
||||||
|
"""Test that pagination maintains consistent ordering."""
|
||||||
|
page1 = populated_fts.search("1", limit=10, offset=0)
|
||||||
|
page2 = populated_fts.search("1", limit=10, offset=10)
|
||||||
|
|
||||||
|
assert len(page1) > 0, "Page 1 should have results"
|
||||||
|
assert len(page2) > 0, "Page 2 should have results"
|
||||||
|
|
||||||
|
# Page 2 scores should all be <= Page 1 min score
|
||||||
|
page1_min_score = min(r["rank_score"] for r in page1)
|
||||||
|
page2_max_score = max(r["rank_score"] for r in page2)
|
||||||
|
|
||||||
|
assert page2_max_score <= page1_min_score, (
|
||||||
|
f"Page 2 max score ({page2_max_score}) should be <= Page 1 min score ({page1_min_score})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_rank_score_includes_popularity_weight(self, populated_fts):
|
||||||
|
"""Test that rank_score includes post_count popularity weighting."""
|
||||||
|
results = populated_fts.search("1", limit=5)
|
||||||
|
|
||||||
|
assert len(results) >= 2, "Need at least 2 results to compare"
|
||||||
|
|
||||||
|
# 1girl has 6M posts, should have higher rank_score than tags with fewer posts
|
||||||
|
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
|
||||||
|
assert girl_result is not None, "1girl should be in results"
|
||||||
|
|
||||||
|
# Find a tag with significantly fewer posts
|
||||||
|
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
|
||||||
|
if low_post_result:
|
||||||
|
assert girl_result["rank_score"] > low_post_result["rank_score"], (
|
||||||
|
f"1girl (6M posts) should have higher score than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAliasSearch:
|
class TestAliasSearch:
|
||||||
"""Tests for alias search functionality."""
|
"""Tests for alias search functionality."""
|
||||||
@@ -204,7 +291,9 @@ class TestAliasSearch:
|
|||||||
results = populated_fts.search("miku")
|
results = populated_fts.search("miku")
|
||||||
|
|
||||||
assert len(results) >= 1
|
assert len(results) >= 1
|
||||||
hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None)
|
hatsune_result = next(
|
||||||
|
(r for r in results if r["tag_name"] == "hatsune_miku"), None
|
||||||
|
)
|
||||||
assert hatsune_result is not None
|
assert hatsune_result is not None
|
||||||
assert hatsune_result["matched_alias"] == "miku"
|
assert hatsune_result["matched_alias"] == "miku"
|
||||||
|
|
||||||
@@ -214,7 +303,9 @@ class TestAliasSearch:
|
|||||||
results = populated_fts.search("hatsune")
|
results = populated_fts.search("hatsune")
|
||||||
|
|
||||||
assert len(results) >= 1
|
assert len(results) >= 1
|
||||||
hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None)
|
hatsune_result = next(
|
||||||
|
(r for r in results if r["tag_name"] == "hatsune_miku"), None
|
||||||
|
)
|
||||||
assert hatsune_result is not None
|
assert hatsune_result is not None
|
||||||
assert "matched_alias" not in hatsune_result
|
assert "matched_alias" not in hatsune_result
|
||||||
|
|
||||||
@@ -301,7 +392,9 @@ class TestSlashPrefixAliases:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fts_with_slash_aliases(self, temp_db_path):
|
def fts_with_slash_aliases(self, temp_db_path):
|
||||||
"""Create an FTS index with slash-prefixed aliases."""
|
"""Create an FTS index with slash-prefixed aliases."""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f:
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
# Format: tag_name,category,post_count,aliases
|
# Format: tag_name,category,post_count,aliases
|
||||||
f.write('long_hair,0,4350743,"/lh,longhair,very_long_hair"\n')
|
f.write('long_hair,0,4350743,"/lh,longhair,very_long_hair"\n')
|
||||||
f.write('breasts,0,3439214,"/b,boobs,oppai"\n')
|
f.write('breasts,0,3439214,"/b,boobs,oppai"\n')
|
||||||
@@ -380,7 +473,15 @@ class TestCategoryMappings:
|
|||||||
|
|
||||||
def test_category_name_to_ids_complete(self):
|
def test_category_name_to_ids_complete(self):
|
||||||
"""Test that CATEGORY_NAME_TO_IDS includes all expected names."""
|
"""Test that CATEGORY_NAME_TO_IDS includes all expected names."""
|
||||||
expected_names = ["general", "artist", "copyright", "character", "meta", "species", "lore"]
|
expected_names = [
|
||||||
|
"general",
|
||||||
|
"artist",
|
||||||
|
"copyright",
|
||||||
|
"character",
|
||||||
|
"meta",
|
||||||
|
"species",
|
||||||
|
"lore",
|
||||||
|
]
|
||||||
for name in expected_names:
|
for name in expected_names:
|
||||||
assert name in CATEGORY_NAME_TO_IDS
|
assert name in CATEGORY_NAME_TO_IDS
|
||||||
assert isinstance(CATEGORY_NAME_TO_IDS[name], list)
|
assert isinstance(CATEGORY_NAME_TO_IDS[name], list)
|
||||||
|
|||||||
@@ -742,6 +742,14 @@ class AutoComplete {
|
|||||||
try {
|
try {
|
||||||
this.currentSearchTerm = term;
|
this.currentSearchTerm = term;
|
||||||
|
|
||||||
|
// Save current search type to detect mode changes during async search
|
||||||
|
const searchTypeAtStart = this.searchType;
|
||||||
|
|
||||||
|
// Clear items before starting new search to avoid stale data
|
||||||
|
// This is critical for preventing command suggestions from persisting
|
||||||
|
// when switching from command mode to regular tag search
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
endpoint = `/lm/${this.modelType}/relative-paths`;
|
endpoint = `/lm/${this.modelType}/relative-paths`;
|
||||||
}
|
}
|
||||||
@@ -776,7 +784,15 @@ class AutoComplete {
|
|||||||
|
|
||||||
const resultsArrays = await Promise.all(searchPromises);
|
const resultsArrays = await Promise.all(searchPromises);
|
||||||
|
|
||||||
// Merge and deduplicate results
|
// Check if search type changed during async operation
|
||||||
|
// If so, skip updating items to prevent stale data from showing
|
||||||
|
if (this.searchType !== searchTypeAtStart) {
|
||||||
|
console.log('[Lora Manager] Search type changed during search, skipping update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and deduplicate results while preserving order from backend
|
||||||
|
// Backend returns results sorted by relevance, so we maintain that order
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const mergedItems = [];
|
const mergedItems = [];
|
||||||
|
|
||||||
@@ -793,39 +809,10 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score and sort results: exact matches first, then by match quality
|
// Use backend-sorted results directly without re-scoring
|
||||||
const scoredItems = mergedItems.map(item => {
|
// Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
|
||||||
let bestScore = -1;
|
if (mergedItems.length > 0) {
|
||||||
let isExact = false;
|
this.items = mergedItems;
|
||||||
|
|
||||||
for (const query of queriesToExecute) {
|
|
||||||
const match = this._matchItem(item, query);
|
|
||||||
if (match.matched) {
|
|
||||||
// Higher score for exact matches
|
|
||||||
const score = match.isExactMatch ? 1000 : 100;
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
isExact = match.isExactMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item, score: bestScore, isExact };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by score (descending), exact matches first
|
|
||||||
scoredItems.sort((a, b) => {
|
|
||||||
if (b.isExact !== a.isExact) {
|
|
||||||
return b.isExact ? 1 : -1;
|
|
||||||
}
|
|
||||||
return b.score - a.score;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract just the items
|
|
||||||
const sortedItems = scoredItems.map(s => s.item);
|
|
||||||
|
|
||||||
if (sortedItems.length > 0) {
|
|
||||||
this.items = sortedItems;
|
|
||||||
this.render();
|
this.render();
|
||||||
this.show();
|
this.show();
|
||||||
} else {
|
} else {
|
||||||
@@ -908,6 +895,12 @@ class AutoComplete {
|
|||||||
* @param {string} filter - Optional filter for commands
|
* @param {string} filter - Optional filter for commands
|
||||||
*/
|
*/
|
||||||
_showCommandList(filter = '') {
|
_showCommandList(filter = '') {
|
||||||
|
// Only show command list if we're in command mode
|
||||||
|
// This prevents stale command suggestions from appearing after switching to tag search
|
||||||
|
if (this.searchType !== 'commands' && this.showingCommands !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filterLower = filter.toLowerCase();
|
const filterLower = filter.toLowerCase();
|
||||||
|
|
||||||
// Get unique commands (avoid duplicates like /char and /character)
|
// Get unique commands (avoid duplicates like /char and /character)
|
||||||
@@ -942,12 +935,20 @@ class AutoComplete {
|
|||||||
* Render the command list dropdown
|
* Render the command list dropdown
|
||||||
*/
|
*/
|
||||||
_renderCommandList() {
|
_renderCommandList() {
|
||||||
this.dropdown.innerHTML = '';
|
// Clear command list items properly based on rendering mode
|
||||||
|
if (this.contentContainer) {
|
||||||
|
// Virtual scrolling mode - clear content container
|
||||||
|
this.contentContainer.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
// Non-virtual scrolling mode - clear dropdown direct children
|
||||||
|
this.dropdown.innerHTML = '';
|
||||||
|
}
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
|
|
||||||
this.items.forEach((item, index) => {
|
this.items.forEach((item, index) => {
|
||||||
const itemEl = document.createElement('div');
|
const itemEl = document.createElement('div');
|
||||||
itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command';
|
itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command';
|
||||||
|
itemEl.dataset.index = index.toString();
|
||||||
|
|
||||||
const cmdSpan = document.createElement('span');
|
const cmdSpan = document.createElement('span');
|
||||||
cmdSpan.className = 'lm-autocomplete-command-name';
|
cmdSpan.className = 'lm-autocomplete-command-name';
|
||||||
@@ -973,6 +974,8 @@ class AutoComplete {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
height: ${this.options.itemHeight}px;
|
||||||
|
box-sizing: border-box;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
itemEl.addEventListener('mouseenter', () => {
|
itemEl.addEventListener('mouseenter', () => {
|
||||||
@@ -983,18 +986,29 @@ class AutoComplete {
|
|||||||
this._insertCommand(item.command);
|
this._insertCommand(item.command);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dropdown.appendChild(itemEl);
|
// Append to correct container based on rendering mode
|
||||||
|
if (this.contentContainer) {
|
||||||
|
this.contentContainer.appendChild(itemEl);
|
||||||
|
} else {
|
||||||
|
this.dropdown.appendChild(itemEl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove border from last item
|
// Remove border from last item
|
||||||
if (this.dropdown.lastChild) {
|
const lastChild = this.contentContainer ? this.contentContainer.lastChild : this.dropdown.lastChild;
|
||||||
this.dropdown.lastChild.style.borderBottom = 'none';
|
if (lastChild) {
|
||||||
|
lastChild.style.borderBottom = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select first item
|
// Auto-select first item
|
||||||
if (this.items.length > 0) {
|
if (this.items.length > 0) {
|
||||||
setTimeout(() => this.selectItem(0), 100);
|
setTimeout(() => this.selectItem(0), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update virtual scroll height for virtual scrolling mode
|
||||||
|
if (this.contentContainer) {
|
||||||
|
this.updateVirtualScrollHeight();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1057,28 +1071,49 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||||
// Use virtual scrolling - only update visible items if dropdown is already visible
|
// Use virtual scrolling - always update visible items to ensure content is fresh
|
||||||
// If not visible, updateVisibleItems() will be called from show() after display:block
|
// The dropdown visibility is controlled by show()/hide()
|
||||||
this.updateVirtualScrollHeight();
|
this.updateVirtualScrollHeight();
|
||||||
if (this.isVisible && this.dropdown.style.display !== 'none') {
|
this.updateVisibleItems();
|
||||||
this.updateVisibleItems();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Traditional rendering (fallback)
|
// Traditional rendering (fallback)
|
||||||
this.dropdown.innerHTML = '';
|
this.dropdown.innerHTML = '';
|
||||||
|
|
||||||
// Check if items are enriched (have tag_name, category, post_count)
|
// Check if items are enriched (have tag_name, category, post_count) or command objects
|
||||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||||
|
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
|
||||||
|
|
||||||
this.items.forEach((itemData, index) => {
|
this.items.forEach((itemData, index) => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'comfy-autocomplete-item';
|
item.className = 'comfy-autocomplete-item';
|
||||||
|
|
||||||
// Get the display text and path for insertion
|
if (isCommand) {
|
||||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
// Render command item
|
||||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
const cmdSpan = document.createElement('span');
|
||||||
|
cmdSpan.className = 'lm-autocomplete-command-name';
|
||||||
|
cmdSpan.textContent = itemData.command;
|
||||||
|
|
||||||
if (isEnriched) {
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'lm-autocomplete-command-label';
|
||||||
|
labelSpan.textContent = itemData.label;
|
||||||
|
|
||||||
|
item.appendChild(cmdSpan);
|
||||||
|
item.appendChild(labelSpan);
|
||||||
|
item.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(226, 232, 240, 0.8);
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
`;
|
||||||
|
} else if (isEnriched) {
|
||||||
// Render enriched item with category badge and post count
|
// Render enriched item with category badge and post count
|
||||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||||
} else {
|
} else {
|
||||||
@@ -1087,7 +1122,7 @@ class AutoComplete {
|
|||||||
const nameSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
nameSpan.className = 'lm-autocomplete-name';
|
nameSpan.className = 'lm-autocomplete-name';
|
||||||
// Use display text without extension for cleaner UI
|
// Use display text without extension for cleaner UI
|
||||||
const displayTextWithoutExt = this._getDisplayText(displayText);
|
const displayTextWithoutExt = this._getDisplayText(itemData);
|
||||||
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
|
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
|
||||||
nameSpan.style.cssText = `
|
nameSpan.style.cssText = `
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1096,25 +1131,25 @@ class AutoComplete {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
`;
|
`;
|
||||||
item.appendChild(nameSpan);
|
item.appendChild(nameSpan);
|
||||||
|
|
||||||
|
// Apply item styles with new color scheme
|
||||||
|
item.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(226, 232, 240, 0.8);
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply item styles with new color scheme
|
|
||||||
item.style.cssText = `
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: rgba(226, 232, 240, 0.8);
|
|
||||||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Hover and selection handlers
|
// Hover and selection handlers
|
||||||
item.addEventListener('mouseenter', () => {
|
item.addEventListener('mouseenter', () => {
|
||||||
this.selectItem(index);
|
this.selectItem(index);
|
||||||
@@ -1126,7 +1161,12 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Click handler
|
// Click handler
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
this.insertSelection(insertPath);
|
if (isCommand) {
|
||||||
|
this._insertCommand(itemData.command);
|
||||||
|
} else {
|
||||||
|
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||||
|
this.insertSelection(insertPath);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dropdown.appendChild(item);
|
this.dropdown.appendChild(item);
|
||||||
@@ -1369,39 +1409,11 @@ class AutoComplete {
|
|||||||
this.hasMoreItems = false;
|
this.hasMoreItems = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got new items, add them and re-render
|
// If we got new items, append them and re-render
|
||||||
|
// IMPORTANT: Do NOT re-sort! Backend already returns results sorted by relevance
|
||||||
if (newItems.length > 0) {
|
if (newItems.length > 0) {
|
||||||
const currentLength = this.items.length;
|
|
||||||
this.items.push(...newItems);
|
this.items.push(...newItems);
|
||||||
|
|
||||||
// Re-score and sort all items
|
|
||||||
const scoredItems = this.items.map(item => {
|
|
||||||
let bestScore = -1;
|
|
||||||
let isExact = false;
|
|
||||||
|
|
||||||
for (const query of queriesToExecute) {
|
|
||||||
const match = this._matchItem(item, query);
|
|
||||||
if (match.matched) {
|
|
||||||
const score = match.isExactMatch ? 1000 : 100;
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
isExact = match.isExactMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item, score: bestScore, isExact };
|
|
||||||
});
|
|
||||||
|
|
||||||
scoredItems.sort((a, b) => {
|
|
||||||
if (b.isExact !== a.isExact) {
|
|
||||||
return b.isExact ? 1 : -1;
|
|
||||||
}
|
|
||||||
return b.score - a.score;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.items = scoredItems.map(s => s.item);
|
|
||||||
|
|
||||||
// Update render
|
// Update render
|
||||||
if (this.options.enableVirtualScroll) {
|
if (this.options.enableVirtualScroll) {
|
||||||
this.updateVirtualScrollHeight();
|
this.updateVirtualScrollHeight();
|
||||||
@@ -1458,10 +1470,18 @@ class AutoComplete {
|
|||||||
* Update the total height of the virtual scroll container
|
* Update the total height of the virtual scroll container
|
||||||
*/
|
*/
|
||||||
updateVirtualScrollHeight() {
|
updateVirtualScrollHeight() {
|
||||||
if (!this.contentContainer) return;
|
if (!this.contentContainer || !this.scrollContainer) return;
|
||||||
|
|
||||||
this.totalHeight = this.items.length * this.options.itemHeight;
|
this.totalHeight = this.items.length * this.options.itemHeight;
|
||||||
this.contentContainer.style.height = `${this.totalHeight}px`;
|
this.contentContainer.style.height = `${this.totalHeight}px`;
|
||||||
|
|
||||||
|
// Adjust scroll container max-height based on actual content
|
||||||
|
// Only show scrollbar when content exceeds visibleItems limit
|
||||||
|
const maxHeight = this.options.visibleItems * this.options.itemHeight;
|
||||||
|
const shouldShowScrollbar = this.totalHeight > maxHeight;
|
||||||
|
|
||||||
|
this.scrollContainer.style.maxHeight = shouldShowScrollbar ? `${maxHeight}px` : `${this.totalHeight}px`;
|
||||||
|
this.scrollContainer.style.overflowY = shouldShowScrollbar ? 'auto' : 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1473,11 +1493,12 @@ class AutoComplete {
|
|||||||
const scrollTop = this.scrollContainer.scrollTop;
|
const scrollTop = this.scrollContainer.scrollTop;
|
||||||
const containerHeight = this.scrollContainer.clientHeight;
|
const containerHeight = this.scrollContainer.clientHeight;
|
||||||
|
|
||||||
// Calculate which items should be visible
|
// Calculate which items should be visible with a larger buffer for smoother rendering
|
||||||
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2);
|
// Use a fixed buffer of 5 items to ensure selected item is always rendered
|
||||||
|
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 5);
|
||||||
const endIndex = Math.min(
|
const endIndex = Math.min(
|
||||||
this.items.length - 1,
|
this.items.length - 1,
|
||||||
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2
|
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 5
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear current content
|
// Clear current content
|
||||||
@@ -1492,10 +1513,11 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Render visible items
|
// Render visible items
|
||||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||||
|
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
|
||||||
|
|
||||||
for (let i = startIndex; i <= endIndex; i++) {
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
const itemData = this.items[i];
|
const itemData = this.items[i];
|
||||||
const itemEl = this.createItemElement(itemData, i, isEnriched);
|
const itemEl = this.createItemElement(itemData, i, isEnriched, isCommand);
|
||||||
this.contentContainer.appendChild(itemEl);
|
this.contentContainer.appendChild(itemEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1505,12 +1527,22 @@ class AutoComplete {
|
|||||||
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
|
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
|
||||||
this.contentContainer.appendChild(bottomSpacer);
|
this.contentContainer.appendChild(bottomSpacer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-apply selection styling after re-rendering
|
||||||
|
// This ensures the selected item remains highlighted even after DOM updates
|
||||||
|
if (this.selectedIndex >= startIndex && this.selectedIndex <= endIndex) {
|
||||||
|
const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${this.selectedIndex}"]`);
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
||||||
|
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single item element
|
* Create a single item element
|
||||||
*/
|
*/
|
||||||
createItemElement(itemData, index, isEnriched) {
|
createItemElement(itemData, index, isEnriched, isCommand = false) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'comfy-autocomplete-item';
|
item.className = 'comfy-autocomplete-item';
|
||||||
item.dataset.index = index.toString();
|
item.dataset.index = index.toString();
|
||||||
@@ -1532,16 +1564,31 @@ class AutoComplete {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
// Check if this is a command object (override parameter if needed)
|
||||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
if (!isCommand && itemData && typeof itemData === 'object' && 'command' in itemData) {
|
||||||
|
isCommand = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEnriched) {
|
if (isCommand) {
|
||||||
|
// Render command item
|
||||||
|
const cmdSpan = document.createElement('span');
|
||||||
|
cmdSpan.className = 'lm-autocomplete-command-name';
|
||||||
|
cmdSpan.textContent = itemData.command;
|
||||||
|
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'lm-autocomplete-command-label';
|
||||||
|
labelSpan.textContent = itemData.label;
|
||||||
|
|
||||||
|
item.appendChild(cmdSpan);
|
||||||
|
item.appendChild(labelSpan);
|
||||||
|
item.style.gap = '12px';
|
||||||
|
} else if (isEnriched) {
|
||||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||||
} else {
|
} else {
|
||||||
const nameSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
nameSpan.className = 'lm-autocomplete-name';
|
nameSpan.className = 'lm-autocomplete-name';
|
||||||
// Use display text without extension for cleaner UI
|
// Use display text without extension for cleaner UI
|
||||||
const displayTextWithoutExt = this._getDisplayText(displayText);
|
const displayTextWithoutExt = this._getDisplayText(itemData);
|
||||||
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
|
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
|
||||||
nameSpan.style.cssText = `
|
nameSpan.style.cssText = `
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1561,8 +1608,14 @@ class AutoComplete {
|
|||||||
this.hidePreview();
|
this.hidePreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Click handler
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
this.insertSelection(insertPath);
|
if (isCommand) {
|
||||||
|
this._insertCommand(itemData.command);
|
||||||
|
} else {
|
||||||
|
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||||
|
this.insertSelection(insertPath);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -1578,7 +1631,10 @@ class AutoComplete {
|
|||||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||||
this.dropdown.style.display = 'block';
|
this.dropdown.style.display = 'block';
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.updateVisibleItems();
|
// Skip updateVisibleItems if showing commands (already rendered by _renderCommandList)
|
||||||
|
if (!this.showingCommands) {
|
||||||
|
this.updateVisibleItems();
|
||||||
|
}
|
||||||
this.positionAtCursor();
|
this.positionAtCursor();
|
||||||
} else {
|
} else {
|
||||||
// Position dropdown at cursor position using TextAreaCaretHelper
|
// Position dropdown at cursor position using TextAreaCaretHelper
|
||||||
@@ -1638,6 +1694,19 @@ class AutoComplete {
|
|||||||
this.isVisible = false;
|
this.isVisible = false;
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this.showingCommands = false;
|
this.showingCommands = false;
|
||||||
|
|
||||||
|
// Clear items to prevent stale data from being displayed
|
||||||
|
// when autocomplete is shown again
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
|
// Clear content container to prevent stale items from showing
|
||||||
|
if (this.contentContainer) {
|
||||||
|
// Virtual scrolling mode - clear content container
|
||||||
|
this.contentContainer.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
// Non-virtual scrolling mode - clear dropdown direct children
|
||||||
|
this.dropdown.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Reset virtual scrolling state
|
// Reset virtual scrolling state
|
||||||
this.virtualScrollOffset = 0;
|
this.virtualScrollOffset = 0;
|
||||||
@@ -1688,26 +1757,22 @@ class AutoComplete {
|
|||||||
|
|
||||||
// If item is not visible, scroll to make it visible
|
// If item is not visible, scroll to make it visible
|
||||||
if (itemTop < scrollTop || itemBottom > scrollBottom) {
|
if (itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||||
this.scrollContainer.scrollTop = itemTop - containerHeight / 2;
|
// Scroll to position the item in the visible area
|
||||||
|
// Position item at 1/3 from top for better visibility
|
||||||
|
const targetScrollTop = Math.max(0, itemTop - containerHeight / 3);
|
||||||
|
this.scrollContainer.scrollTop = targetScrollTop;
|
||||||
|
|
||||||
// Re-render visible items after scroll
|
// Re-render visible items after scroll
|
||||||
this.updateVisibleItems();
|
this.updateVisibleItems();
|
||||||
}
|
|
||||||
|
// Apply selection after DOM is updated
|
||||||
// Find the item element using data-index attribute
|
// Use setTimeout to ensure DOM has been re-rendered
|
||||||
const selectedEl = container.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
|
setTimeout(() => {
|
||||||
|
this._applyItemSelection(index);
|
||||||
if (selectedEl) {
|
}, 0);
|
||||||
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
} else {
|
||||||
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
// Item is already visible, apply selection immediately
|
||||||
|
this._applyItemSelection(index);
|
||||||
// Show preview for selected item
|
|
||||||
if (this.options.showPreview) {
|
|
||||||
if (typeof this.behavior.showPreview === 'function') {
|
|
||||||
this.behavior.showPreview(this, this.items[index], selectedEl);
|
|
||||||
} else if (this.previewTooltip) {
|
|
||||||
this.showPreviewForItem(this.items[index], selectedEl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Traditional rendering
|
// Traditional rendering
|
||||||
@@ -1731,6 +1796,31 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply selection styling to an item (used after virtual scroll re-render)
|
||||||
|
* @param {number} index - Index of item to select
|
||||||
|
*/
|
||||||
|
_applyItemSelection(index) {
|
||||||
|
if (!this.contentContainer) return;
|
||||||
|
|
||||||
|
// Find the item element using data-index attribute
|
||||||
|
const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
|
||||||
|
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
||||||
|
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||||
|
|
||||||
|
// Show preview for selected item
|
||||||
|
if (this.options.showPreview) {
|
||||||
|
if (typeof this.behavior.showPreview === 'function') {
|
||||||
|
this.behavior.showPreview(this, this.items[index], selectedEl);
|
||||||
|
} else if (this.previewTooltip) {
|
||||||
|
this.showPreviewForItem(this.items[index], selectedEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
if (!this.isVisible) {
|
if (!this.isVisible) {
|
||||||
@@ -1740,12 +1830,39 @@ class AutoComplete {
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
|
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||||
|
// For virtual scrolling, handle boundary cases
|
||||||
|
if (this.selectedIndex >= this.items.length - 1) {
|
||||||
|
// Already at last item, try to load more
|
||||||
|
if (this.hasMoreItems && !this.isLoadingMore) {
|
||||||
|
this.loadMoreItems().then(() => {
|
||||||
|
// After loading more, select the next item
|
||||||
|
if (this.selectedIndex < this.items.length - 1) {
|
||||||
|
this.selectItem(this.selectedIndex + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectItem(this.selectedIndex + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectItem(Math.max(this.selectedIndex - 1, 0));
|
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||||
|
// For virtual scrolling, handle top boundary
|
||||||
|
if (this.selectedIndex <= 0) {
|
||||||
|
// Already at first item, ensure it's selected
|
||||||
|
this.selectItem(0);
|
||||||
|
} else {
|
||||||
|
this.selectItem(this.selectedIndex - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectItem(Math.max(this.selectedIndex - 1, 0));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
|
|||||||
Reference in New Issue
Block a user