feat(autocomplete): implement virtual scrolling and pagination

- Add virtual scrolling with configurable visible items (default: 15)
- Implement pagination with offset/limit for backend APIs
- Support loading more items on scroll
- Fix width calculation for suggestions dropdown
- Update backend services to support offset parameter

Files modified:
- web/comfyui/autocomplete.js (virtual scroll, pagination)
- py/services/base_model_service.py (offset support)
- py/services/custom_words_service.py (offset support)
- py/services/tag_fts_index.py (offset support)
- py/routes/handlers/model_handlers.py (offset param)
- py/routes/handlers/misc_handlers.py (offset param)
This commit is contained in:
Will Miao
2026-03-07 22:17:26 +08:00
parent 343dd91e4b
commit a802a89ff9
6 changed files with 649 additions and 146 deletions

View File

@@ -240,11 +240,7 @@ class SupportersHandler:
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {
"specialThanks": [],
"allSupporters": [],
"totalCount": 0
}
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
async def get_supporters(self, request: web.Request) -> web.Response:
"""Return supporters data as JSON."""
@@ -253,9 +249,7 @@ class SupportersHandler:
return web.json_response({"success": True, "supporters": supporters})
except Exception as exc:
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class SettingsHandler:
@@ -263,15 +257,17 @@ class SettingsHandler:
# Settings keys that should NOT be synced to frontend.
# All other settings are synced by default.
_NO_SYNC_KEYS = frozenset({
# Internal/performance settings (not used by frontend)
"hash_chunk_size_mb",
"download_stall_timeout_seconds",
# Complex internal structures retrieved via separate endpoints
"folder_paths",
"libraries",
"active_library",
})
_NO_SYNC_KEYS = frozenset(
{
# Internal/performance settings (not used by frontend)
"hash_chunk_size_mb",
"download_stall_timeout_seconds",
# Complex internal structures retrieved via separate endpoints
"folder_paths",
"libraries",
"active_library",
}
)
_PROXY_KEYS = {
"proxy_enabled",
@@ -1226,6 +1222,7 @@ class CustomWordsHandler:
def __init__(self) -> None:
from ...services.custom_words_service import get_custom_words_service
self._service = get_custom_words_service()
async def search_custom_words(self, request: web.Request) -> web.Response:
@@ -1234,6 +1231,7 @@ class CustomWordsHandler:
Query parameters:
search: The search term to match against.
limit: Maximum number of results to return (default: 20).
offset: Number of results to skip (default: 0).
category: Optional category filter. Can be:
- A category name (e.g., "character", "artist", "general")
- Comma-separated category IDs (e.g., "4,11" for character)
@@ -1243,6 +1241,7 @@ class CustomWordsHandler:
try:
search_term = request.query.get("search", "")
limit = int(request.query.get("limit", "20"))
offset = max(0, int(request.query.get("offset", "0")))
category_param = request.query.get("category", "")
enriched_param = request.query.get("enriched", "").lower() == "true"
@@ -1252,13 +1251,14 @@ class CustomWordsHandler:
categories = self._parse_category_param(category_param)
results = self._service.search_words(
search_term, limit, categories=categories, enriched=enriched_param
search_term,
limit,
offset=offset,
categories=categories,
enriched=enriched_param,
)
return web.json_response({
"success": True,
"words": results
})
return web.json_response({"success": True, "words": results})
except Exception as exc:
logger.error("Error searching custom words: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)

View File

@@ -1268,8 +1268,11 @@ class ModelQueryHandler:
async def get_relative_paths(self, request: web.Request) -> web.Response:
try:
search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 50)
matching_paths = await self._service.search_relative_paths(search, limit)
limit = min(int(request.query.get("limit", "15")), 100)
offset = max(0, int(request.query.get("offset", "0")))
matching_paths = await self._service.search_relative_paths(
search, limit, offset
)
return web.json_response(
{"success": True, "relative_paths": matching_paths}
)

View File

@@ -383,7 +383,9 @@ class BaseModelService(ABC):
# Check user setting for hiding early access updates
hide_early_access = False
try:
hide_early_access = bool(self.settings.get("hide_early_access_updates", False))
hide_early_access = bool(
self.settings.get("hide_early_access_updates", False)
)
except Exception:
hide_early_access = False
@@ -413,7 +415,11 @@ class BaseModelService(ABC):
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method):
try:
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access)
resolved = await bulk_method(
self.model_type,
ordered_ids,
hide_early_access=hide_early_access,
)
except Exception as exc:
logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s",
@@ -426,7 +432,9 @@ class BaseModelService(ABC):
if resolved is None:
tasks = [
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access)
self.update_service.has_update(
self.model_type, model_id, hide_early_access=hide_early_access
)
for model_id in ordered_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -588,13 +596,19 @@ class BaseModelService(ABC):
normalized_type = normalize_sub_type(resolve_sub_type(entry))
if not normalized_type:
continue
# Filter by valid sub-types based on scanner type
if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES:
if (
self.model_type == "lora"
and normalized_type not in VALID_LORA_SUB_TYPES
):
continue
if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES:
if (
self.model_type == "checkpoint"
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
):
continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
sorted_types = sorted(
@@ -838,7 +852,7 @@ class BaseModelService(ABC):
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
async def search_relative_paths(
self, search_term: str, limit: int = 15
self, search_term: str, limit: int = 15, offset: int = 0
) -> List[str]:
"""Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data()
@@ -849,6 +863,7 @@ class BaseModelService(ABC):
# Get model roots for path calculation
model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data:
file_path = model.get("file_path", "")
if not file_path:
@@ -877,12 +892,12 @@ class BaseModelService(ABC):
):
matching_paths.append(relative_path)
if len(matching_paths) >= limit * 2: # Get more for better sorting
break
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
)
return matching_paths[:limit]
# Apply offset and limit
start = min(offset, len(matching_paths))
end = min(start + limit, len(matching_paths))
return matching_paths[start:end]

View File

@@ -49,6 +49,7 @@ class CustomWordsService:
if self._tag_index is None:
try:
from .tag_fts_index import get_tag_fts_index
self._tag_index = get_tag_fts_index()
except Exception as e:
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
@@ -59,14 +60,16 @@ class CustomWordsService:
self,
search_term: str,
limit: int = 20,
offset: int = 0,
categories: Optional[List[int]] = None,
enriched: bool = False
enriched: bool = False,
) -> List[Dict[str, Any]]:
"""Search tags using TagFTSIndex with category filtering.
Args:
search_term: The search term to match against.
limit: Maximum number of results to return.
offset: Number of results to skip.
categories: Optional list of category IDs to filter by.
enriched: If True, always return enriched results with category
and post_count (default behavior now).
@@ -76,7 +79,9 @@ class CustomWordsService:
"""
tag_index = self._get_tag_index()
if tag_index is not None:
results = tag_index.search(search_term, categories=categories, limit=limit)
results = tag_index.search(
search_term, categories=categories, limit=limit, offset=offset
)
return results
logger.debug("TagFTSIndex not available, returning empty results")

View File

@@ -69,7 +69,9 @@ class TagFTSIndex:
_DEFAULT_FILENAME = "tag_fts.sqlite"
_CSV_FILENAME = "danbooru_e621_merged.csv"
def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None:
def __init__(
self, db_path: Optional[str] = None, csv_path: Optional[str] = None
) -> None:
"""Initialize the FTS index.
Args:
@@ -92,7 +94,9 @@ class TagFTSIndex:
if directory:
os.makedirs(directory, exist_ok=True)
except Exception as exc:
logger.warning("Could not create FTS index directory %s: %s", directory, exc)
logger.warning(
"Could not create FTS index directory %s: %s", directory, exc
)
def _resolve_default_db_path(self) -> str:
"""Resolve the default database path."""
@@ -173,13 +177,15 @@ class TagFTSIndex:
# Set schema version
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION))
("schema_version", str(SCHEMA_VERSION)),
)
conn.commit()
self._schema_initialized = True
self._needs_rebuild = needs_rebuild
logger.debug("Tag FTS index schema initialized at %s", self._db_path)
logger.debug(
"Tag FTS index schema initialized at %s", self._db_path
)
finally:
conn.close()
except Exception as exc:
@@ -206,13 +212,20 @@ class TagFTSIndex:
row = cursor.fetchone()
if not row:
# Old schema without version, needs rebuild
logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION)
logger.info(
"Migrating tag FTS index to schema version %d (adding alias support)",
SCHEMA_VERSION,
)
self._drop_old_tables(conn)
return True
current_version = int(row[0])
if current_version < SCHEMA_VERSION:
logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION)
logger.info(
"Migrating tag FTS index from version %d to %d",
current_version,
SCHEMA_VERSION,
)
self._drop_old_tables(conn)
return True
@@ -246,7 +259,9 @@ class TagFTSIndex:
return
if not os.path.exists(self._csv_path):
logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path)
logger.warning(
"CSV file not found at %s, cannot build tag index", self._csv_path
)
return
self._indexing_in_progress = True
@@ -314,22 +329,24 @@ class TagFTSIndex:
# Update metadata
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("last_build_time", str(time.time()))
("last_build_time", str(time.time())),
)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("tag_count", str(total_inserted))
("tag_count", str(total_inserted)),
)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION))
("schema_version", str(SCHEMA_VERSION)),
)
conn.commit()
elapsed = time.time() - start_time
logger.info(
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
total_inserted, tags_with_aliases, elapsed
total_inserted,
tags_with_aliases,
elapsed,
)
finally:
conn.close()
@@ -350,7 +367,7 @@ class TagFTSIndex:
# Insert into tags table (with aliases)
conn.executemany(
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
rows
rows,
)
# Build a map of tag_name -> aliases for FTS insertion
@@ -362,7 +379,7 @@ class TagFTSIndex:
placeholders = ",".join("?" * len(tag_names))
cursor = conn.execute(
f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})",
tag_names
tag_names,
)
# Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...")
@@ -379,13 +396,17 @@ class TagFTSIndex:
alias = alias[1:] # Remove leading slash
if alias:
alias_parts.append(alias)
searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
searchable_text = (
f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
)
else:
searchable_text = tag_name
fts_rows.append((rowid, searchable_text))
if fts_rows:
conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows)
conn.executemany(
"INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows
)
def ensure_ready(self) -> bool:
"""Ensure the index is ready, building if necessary.
@@ -420,7 +441,8 @@ class TagFTSIndex:
self,
query: str,
categories: Optional[List[int]] = None,
limit: int = 20
limit: int = 20,
offset: int = 0,
) -> List[Dict]:
"""Search tags using FTS5 with prefix matching.
@@ -431,6 +453,7 @@ class TagFTSIndex:
query: The search query string.
categories: Optional list of category IDs to filter by.
limit: Maximum number of results to return.
offset: Number of results to skip.
Returns:
List of dictionaries with tag_name, category, post_count,
@@ -466,9 +489,9 @@ class TagFTSIndex:
)
AND t.category IN ({placeholders})
ORDER BY t.post_count DESC
LIMIT ?
LIMIT ? OFFSET ?
"""
params = [fts_query] + categories + [limit]
params = [fts_query] + categories + [limit, offset]
else:
sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases
@@ -476,9 +499,9 @@ class TagFTSIndex:
JOIN tags t ON f.rowid = t.rowid
WHERE f.searchable_text MATCH ?
ORDER BY t.post_count DESC
LIMIT ?
LIMIT ? OFFSET ?
"""
params = [fts_query, limit]
params = [fts_query, limit, offset]
cursor = conn.execute(sql, params)
results = []
@@ -502,7 +525,9 @@ class TagFTSIndex:
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
return []
def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]:
def _find_matched_alias(
self, query: str, tag_name: str, aliases_str: str
) -> Optional[str]:
"""Find which alias matched the query, if any.
Args:

View File

@@ -269,13 +269,17 @@ class AutoComplete {
this.modelType = modelType;
this.behavior = getModelBehavior(modelType);
this.options = {
maxItems: 20,
maxItems: 100,
pageSize: 20,
visibleItems: 15, // Fixed at 15 items for balanced UX
itemHeight: 40,
minChars: 1,
debounceDelay: 200,
showPreview: this.behavior.enablePreview ?? false,
enableVirtualScroll: true,
...options
};
this.dropdown = null;
this.selectedIndex = -1;
this.items = [];
@@ -286,6 +290,15 @@ class AutoComplete {
this.previewTooltipPromise = null;
this.searchType = null;
// Virtual scrolling state
this.virtualScrollOffset = 0;
this.hasMoreItems = true;
this.isLoadingMore = false;
this.currentPage = 0;
this.scrollContainer = null;
this.contentContainer = null;
this.totalHeight = 0;
// Command mode state
this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' })
this.showingCommands = false; // Whether showing command list dropdown
@@ -297,6 +310,7 @@ class AutoComplete {
this.onKeyDown = null;
this.onBlur = null;
this.onDocumentClick = null;
this.onScroll = null;
this.init();
}
@@ -309,12 +323,12 @@ class AutoComplete {
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'comfy-autocomplete-dropdown';
// Apply new color scheme
this.dropdown.style.cssText = `
position: absolute;
z-index: 10000;
overflow-y: visible;
overflow: hidden;
background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 8px;
@@ -325,7 +339,29 @@ class AutoComplete {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
`;
if (this.options.enableVirtualScroll) {
// Create scroll container for virtual scrolling
this.scrollContainer = document.createElement('div');
this.scrollContainer.className = 'comfy-autocomplete-scroll-container';
this.scrollContainer.style.cssText = `
overflow-y: auto;
max-height: ${this.options.visibleItems * this.options.itemHeight}px;
position: relative;
`;
// Create content container for virtual items
this.contentContainer = document.createElement('div');
this.contentContainer.className = 'comfy-autocomplete-content';
this.contentContainer.style.cssText = `
position: relative;
width: 100%;
`;
this.scrollContainer.appendChild(this.contentContainer);
this.dropdown.appendChild(this.scrollContainer);
}
// Custom scrollbar styles with new color scheme
const style = document.createElement('style');
style.textContent = `
@@ -343,9 +379,29 @@ class AutoComplete {
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(226, 232, 240, 0.4);
}
.comfy-autocomplete-scroll-container::-webkit-scrollbar {
width: 8px;
}
.comfy-autocomplete-scroll-container::-webkit-scrollbar-track {
background: rgba(40, 44, 52, 0.3);
border-radius: 4px;
}
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb {
background: rgba(226, 232, 240, 0.2);
border-radius: 4px;
}
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(226, 232, 240, 0.4);
}
.comfy-autocomplete-loading {
padding: 12px;
text-align: center;
color: rgba(226, 232, 240, 0.5);
font-size: 12px;
}
`;
document.head.appendChild(style);
// Append to body to avoid overflow issues
document.body.appendChild(this.dropdown);
@@ -410,6 +466,14 @@ class AutoComplete {
// Mark this element as having autocomplete events bound
this.inputElement._autocompleteEventsBound = true;
// Bind scroll event for virtual scrolling
if (this.options.enableVirtualScroll && this.scrollContainer) {
this.onScroll = () => {
this.handleScroll();
};
this.scrollContainer.addEventListener('scroll', this.onScroll);
}
}
/**
@@ -958,80 +1022,102 @@ class AutoComplete {
}
render() {
this.dropdown.innerHTML = '';
this.selectedIndex = -1;
// Reset virtual scroll state
this.virtualScrollOffset = 0;
this.currentPage = 0;
this.hasMoreItems = true;
this.isLoadingMore = false;
// Early return if no items to prevent empty dropdown
if (!this.items || this.items.length === 0) {
if (this.contentContainer) {
this.contentContainer.innerHTML = '';
} else {
this.dropdown.innerHTML = '';
}
return;
}
// Check if items are enriched (have tag_name, category, post_count)
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
if (this.options.enableVirtualScroll && this.contentContainer) {
// Use virtual scrolling - only update visible items if dropdown is already visible
// If not visible, updateVisibleItems() will be called from show() after display:block
this.updateVirtualScrollHeight();
if (this.isVisible && this.dropdown.style.display !== 'none') {
this.updateVisibleItems();
}
} else {
// Traditional rendering (fallback)
this.dropdown.innerHTML = '';
this.items.forEach((itemData, index) => {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
// Check if items are enriched (have tag_name, category, post_count)
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
// Get the display text and path for insertion
const displayText = isEnriched ? itemData.tag_name : itemData;
const insertPath = isEnriched ? itemData.tag_name : itemData;
this.items.forEach((itemData, index) => {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
if (isEnriched) {
// Render enriched item with category badge and post count
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
// Create highlighted content for simple items, wrapped in a span
// to prevent flex layout from breaking up the text
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
nameSpan.style.cssText = `
flex: 1;
min-width: 0;
// Get the display text and path for insertion
const displayText = isEnriched ? itemData.tag_name : itemData;
const insertPath = isEnriched ? itemData.tag_name : itemData;
if (isEnriched) {
// Render enriched item with category badge and post count
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
// Create highlighted content for simple items, wrapped in a span
// to prevent flex layout from breaking up the text
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
nameSpan.style.cssText = `
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`;
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;
`;
item.appendChild(nameSpan);
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
});
item.addEventListener('mouseleave', () => {
this.hidePreview();
});
// Click handler
item.addEventListener('click', () => {
this.insertSelection(insertPath);
});
this.dropdown.appendChild(item);
});
// Remove border from last item
if (this.dropdown.lastChild) {
this.dropdown.lastChild.style.borderBottom = 'none';
}
// 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
item.addEventListener('mouseenter', () => {
this.selectItem(index);
});
item.addEventListener('mouseleave', () => {
this.hidePreview();
});
// Click handler
item.addEventListener('click', () => {
this.insertSelection(insertPath);
});
this.dropdown.appendChild(item);
});
// Remove border from last item
if (this.dropdown.lastChild) {
this.dropdown.lastChild.style.borderBottom = 'none';
}
// Auto-select the first item with a small delay
@@ -1163,16 +1249,323 @@ class AutoComplete {
}
}
/**
* Handle scroll event for virtual scrolling and loading more items
*/
handleScroll() {
if (!this.scrollContainer || this.isLoadingMore) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
const scrollBottom = scrollTop + clientHeight;
const threshold = this.options.itemHeight * 2; // Load more when within 2 items of bottom
// Check if we need to load more items
if (scrollBottom >= scrollHeight - threshold && this.hasMoreItems) {
this.loadMoreItems();
}
// Update visible items for virtual scrolling
if (this.options.enableVirtualScroll) {
this.updateVisibleItems();
}
}
/**
* Load more items (pagination)
*/
async loadMoreItems() {
if (this.isLoadingMore || !this.hasMoreItems || this.showingCommands) {
return;
}
this.isLoadingMore = true;
this.currentPage++;
try {
// Show loading indicator
this.showLoadingIndicator();
// Get the current endpoint
let endpoint = `/lm/${this.modelType}/relative-paths`;
if (this.modelType === 'prompt') {
if (this.searchType === 'embeddings') {
endpoint = '/lm/embeddings/relative-paths';
} else if (this.searchType === 'custom_words') {
if (this.activeCommand?.categories) {
const categories = this.activeCommand.categories.join(',');
endpoint = `/lm/custom-words/search?category=${categories}`;
} else {
endpoint = '/lm/custom-words/search?enriched=true';
}
}
}
const queryVariations = this._generateQueryVariations(this.currentSearchTerm);
const queriesToExecute = queryVariations.slice(0, 4);
const offset = this.items.length;
// 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}`;
try {
const response = await api.fetchApi(url);
const data = await response.json();
return data.success ? (data.relative_paths || data.words || []) : [];
} catch (error) {
console.warn(`Search query failed for "${query}":`, error);
return [];
}
});
const resultsArrays = await Promise.all(searchPromises);
// Merge and deduplicate results with existing items
const seen = new Set(this.items.map(item => {
const itemKey = typeof item === 'object' && item.tag_name
? item.tag_name.toLowerCase()
: String(item).toLowerCase();
return itemKey;
}));
const newItems = [];
for (const resultArray of resultsArrays) {
for (const item of resultArray) {
const itemKey = typeof item === 'object' && item.tag_name
? item.tag_name.toLowerCase()
: String(item).toLowerCase();
if (!seen.has(itemKey)) {
seen.add(itemKey);
newItems.push(item);
}
}
}
// If we got fewer items than requested, we've reached the end
if (newItems.length < this.options.pageSize) {
this.hasMoreItems = false;
}
// If we got new items, add them and re-render
if (newItems.length > 0) {
const currentLength = this.items.length;
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
if (this.options.enableVirtualScroll) {
this.updateVirtualScrollHeight();
this.updateVisibleItems();
} else {
this.render();
}
} else {
this.hasMoreItems = false;
}
} catch (error) {
console.error('Error loading more items:', error);
this.hasMoreItems = false;
} finally {
this.isLoadingMore = false;
this.hideLoadingIndicator();
}
}
/**
* Show loading indicator at the bottom of the list
*/
showLoadingIndicator() {
if (!this.contentContainer) return;
let loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
if (!loadingEl) {
loadingEl = document.createElement('div');
loadingEl.className = 'comfy-autocomplete-loading';
loadingEl.textContent = 'Loading more...';
loadingEl.style.cssText = `
padding: 12px;
text-align: center;
color: rgba(226, 232, 240, 0.5);
font-size: 12px;
`;
this.contentContainer.appendChild(loadingEl);
}
}
/**
* Hide loading indicator
*/
hideLoadingIndicator() {
if (!this.contentContainer) return;
const loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
if (loadingEl) {
loadingEl.remove();
}
}
/**
* Update the total height of the virtual scroll container
*/
updateVirtualScrollHeight() {
if (!this.contentContainer) return;
this.totalHeight = this.items.length * this.options.itemHeight;
this.contentContainer.style.height = `${this.totalHeight}px`;
}
/**
* Update which items are visible based on scroll position
*/
updateVisibleItems() {
if (!this.scrollContainer || !this.contentContainer) return;
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
// Calculate which items should be visible
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2);
const endIndex = Math.min(
this.items.length - 1,
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2
);
// Clear current content
this.contentContainer.innerHTML = '';
// Create spacer for items before visible range
if (startIndex > 0) {
const topSpacer = document.createElement('div');
topSpacer.style.height = `${startIndex * this.options.itemHeight}px`;
this.contentContainer.appendChild(topSpacer);
}
// Render visible items
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
for (let i = startIndex; i <= endIndex; i++) {
const itemData = this.items[i];
const itemEl = this.createItemElement(itemData, i, isEnriched);
this.contentContainer.appendChild(itemEl);
}
// Create spacer for items after visible range
if (endIndex < this.items.length - 1) {
const bottomSpacer = document.createElement('div');
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
this.contentContainer.appendChild(bottomSpacer);
}
}
/**
* Create a single item element
*/
createItemElement(itemData, index, isEnriched) {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
item.dataset.index = index.toString();
item.style.cssText = `
height: ${this.options.itemHeight}px;
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;
box-sizing: border-box;
`;
const displayText = isEnriched ? itemData.tag_name : itemData;
const insertPath = isEnriched ? itemData.tag_name : itemData;
if (isEnriched) {
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
nameSpan.style.cssText = `
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`;
item.appendChild(nameSpan);
}
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
});
item.addEventListener('mouseleave', () => {
this.hidePreview();
});
item.addEventListener('click', () => {
this.insertSelection(insertPath);
});
return item;
}
show() {
if (!this.items || this.items.length === 0) {
this.hide();
return;
}
// Position dropdown at cursor position using TextAreaCaretHelper
this.positionAtCursor();
this.dropdown.style.display = 'block';
this.isVisible = true;
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
if (this.options.enableVirtualScroll && this.contentContainer) {
this.dropdown.style.display = 'block';
this.isVisible = true;
this.updateVisibleItems();
this.positionAtCursor();
} else {
// Position dropdown at cursor position using TextAreaCaretHelper
this.positionAtCursor();
this.dropdown.style.display = 'block';
this.isVisible = true;
}
}
positionAtCursor() {
@@ -1189,7 +1582,11 @@ class AutoComplete {
// Measure the content width
let maxWidth = 200; // minimum width
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
// For virtual scrolling, query items from contentContainer; otherwise from dropdown
const container = this.options.enableVirtualScroll && this.contentContainer
? this.contentContainer
: this.dropdown;
const items = container.querySelectorAll('.comfy-autocomplete-item');
items.forEach(item => {
const itemWidth = item.scrollWidth + 24; // Add padding
maxWidth = Math.max(maxWidth, itemWidth);
@@ -1215,6 +1612,18 @@ class AutoComplete {
this.selectedIndex = -1;
this.showingCommands = false;
// Reset virtual scrolling state
this.virtualScrollOffset = 0;
this.currentPage = 0;
this.hasMoreItems = true;
this.isLoadingMore = false;
this.totalHeight = 0;
// Reset scroll position
if (this.scrollContainer) {
this.scrollContainer.scrollTop = 0;
}
// Hide preview tooltip
this.hidePreview();
@@ -1228,28 +1637,69 @@ class AutoComplete {
selectItem(index) {
// Remove previous selection
const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected');
const container = this.options.enableVirtualScroll && this.contentContainer
? this.contentContainer
: this.dropdown;
const prevSelected = container.querySelector('.comfy-autocomplete-item-selected');
if (prevSelected) {
prevSelected.classList.remove('comfy-autocomplete-item-selected');
prevSelected.style.backgroundColor = '';
}
// Add new selection
if (index >= 0 && index < this.items.length) {
this.selectedIndex = index;
const item = this.dropdown.children[index];
item.classList.add('comfy-autocomplete-item-selected');
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Scroll into view if needed
item.scrollIntoView({ block: 'nearest' });
// Show preview for selected item
if (this.options.showPreview) {
if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], item);
} else if (this.previewTooltip) {
this.showPreviewForItem(this.items[index], item);
// For virtual scrolling, we need to ensure the item is rendered
if (this.options.enableVirtualScroll && this.scrollContainer) {
// Calculate if the item is currently visible
const itemTop = index * this.options.itemHeight;
const itemBottom = itemTop + this.options.itemHeight;
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
const scrollBottom = scrollTop + containerHeight;
// If item is not visible, scroll to make it visible
if (itemTop < scrollTop || itemBottom > scrollBottom) {
this.scrollContainer.scrollTop = itemTop - containerHeight / 2;
// Re-render visible items after scroll
this.updateVisibleItems();
}
// Find the item element using data-index attribute
const selectedEl = container.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);
}
}
}
} else {
// Traditional rendering
const item = container.children[index];
if (item) {
item.classList.add('comfy-autocomplete-item-selected');
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Scroll into view if needed
item.scrollIntoView({ block: 'nearest' });
// Show preview for selected item
if (this.options.showPreview) {
if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], item);
} else if (this.previewTooltip) {
this.showPreviewForItem(this.items[index], item);
}
}
}
}
}
@@ -1512,6 +1962,11 @@ class AutoComplete {
this.onDocumentClick = null;
}
if (this.onScroll && this.scrollContainer) {
this.scrollContainer.removeEventListener('scroll', this.onScroll);
this.onScroll = null;
}
if (typeof this.behavior.destroy === 'function') {
this.behavior.destroy(this);
} else if (this.previewTooltip) {