Files
imi 5b819dad2e refactor: maintenance, quality, and performance improvements
- Backend Optimizations:
  - Optimized workflow graph traversal using deque for O(1) operations.
  - Implemented robust normalization for autocomplete extra_files API.
  - Added bounded instance-level caching to prevent memory growth.
  - Fixed AttributeError in hooks by restoring transformers_dict.
  - Added EXPERIMENTAL.md for tracking legacy code notes.
- Frontend Refinements:
  - Fixed memory leaks via onRemoved cleanup for DOM and observers.
  - Added race-condition protection for autocomplete blur events.
  - Improved UI feedback for API errors in the autocomplete popup.
  - Refined autocomplete aesthetics (removed redundant last-item border).
  - Ensured defensive DOM cleanup in coordinate calculations.
- Documentation & Quality:
  - Overhauled README with auto-detection and hidden inputs docs.
  - Fixed MD040 linting errors across all project READMEs.
  - Updated CHANGELOG.md and TODO.md for the 2026-02-02 release.
2026-02-02 16:57:30 +01:00

420 lines
12 KiB
Python

"""
Tag Autocomplete API
Provides tag autocomplete functionality with support for:
- Danbooru/e621 tag databases
- Alias searching
- Post count sorting with optional frequency boosting
"""
import csv
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Conditional server import
try:
from aiohttp import web
from server import PromptServer
_HAS_SERVER = True
except ImportError:
_HAS_SERVER = False
web = None
PromptServer = None
# Tag type definitions (for color coding in frontend)
TAG_TYPES = {
0: "general", # lightblue/dodgerblue
1: "artist", # indianred/firebrick
3: "copyright", # violet/darkorchid
4: "character", # lightgreen/darkgreen
5: "meta", # orange/darkorange
}
# Get the data directory path
DATA_DIR = Path(__file__).parent.parent / "data" / "tags"
# Default tag file to use if not specified
DEFAULT_TAG_FILE = "danbooru.csv"
class TagEntry:
"""Represents a single tag entry from the database."""
__slots__ = ("name", "type", "count", "aliases", "search_text")
def __init__(self, name: str, tag_type: int, count: int, aliases: List[str]):
self.name = name
self.type = tag_type
self.count = count
self.aliases = aliases
# Pre-compute lowercase search text for faster matching
self.search_text = name.lower()
def to_dict(self) -> Dict:
return {
"name": self.name,
"type": self.type,
"count": self.count,
"aliases": self.aliases,
}
class TagDatabase:
"""
In-memory tag database with fast prefix search.
Tags are loaded lazily on first search to avoid slowing down ComfyUI startup.
Supports loading multiple tag files and merging results.
"""
def __init__(self):
self._tags: List[TagEntry] = []
self._alias_map: Dict[str, TagEntry] = {} # alias -> canonical tag
self._loaded = False
self._current_files: List[str] = []
@property
def is_loaded(self) -> bool:
return self._loaded
@property
def tag_count(self) -> int:
return len(self._tags)
def load_csv(self, filepath: Path, append: bool = False) -> int:
"""
Load tags from a CSV file.
Args:
filepath: Path to the CSV file
append: If True, append to existing tags instead of replacing
Returns:
Number of tags loaded from this file
CSV Format: name,type,postCount,"aliases"
Example: 1girl,0,6008644,"1girls,sole_female"
"""
if not append:
self._tags = []
self._alias_map = {}
self._current_files = []
if not filepath.exists():
print(f"[Autocomplete] Tag file not found: {filepath}")
return 0
try:
loaded_count = 0
existing_tags = {tag.name for tag in self._tags}
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
reader = csv.reader(f)
for row in reader:
if len(row) < 3:
continue
name = row[0].strip()
if not name or name in existing_tags:
continue
try:
tag_type = int(row[1])
except (ValueError, IndexError):
tag_type = 0
try:
count = int(row[2])
except (ValueError, IndexError):
count = 0
# Parse aliases (4th column, comma-separated in quotes)
aliases = []
if len(row) > 3 and row[3]:
aliases = [a.strip() for a in row[3].split(",") if a.strip()]
entry = TagEntry(name, tag_type, count, aliases)
self._tags.append(entry)
existing_tags.add(name)
loaded_count += 1
# Build alias -> canonical tag mapping
for alias in aliases:
self._alias_map[alias.lower()] = entry
self._loaded = True
self._current_files.append(filepath.name)
print(f"[Autocomplete] Loaded {loaded_count} tags from {filepath.name}")
return loaded_count
except Exception as e:
print(f"[Autocomplete] Error loading tag file: {e}")
return 0
def load_multiple(self, filepaths: List[Path]) -> int:
"""
Load tags from multiple CSV files, merging results.
Args:
filepaths: List of paths to CSV files
Returns:
Total number of tags loaded
"""
total = 0
for i, filepath in enumerate(filepaths):
count = self.load_csv(filepath, append=(i > 0))
total += count
return total
def search(
self, query: str, limit: int = 20, search_aliases: bool = True
) -> List[Dict]:
"""
Search for tags matching the query.
Args:
query: Search query (prefix match)
limit: Maximum number of results
search_aliases: Whether to also search aliases
Returns:
List of matching tags as dictionaries
"""
if not self._loaded or not query:
return []
query_lower = query.lower().strip()
if not query_lower:
return []
results: List[Tuple[int, TagEntry, Optional[str]]] = []
seen_tags = set()
# Score function: exact match > prefix match > contains match
def get_score(tag: TagEntry, matched_text: str) -> int:
if matched_text == query_lower:
return 3 # Exact match
elif matched_text.startswith(query_lower):
return 2 # Prefix match
else:
return 1 # Contains match
# Search by tag name
for tag in self._tags:
if query_lower in tag.search_text:
score = get_score(tag, tag.search_text)
results.append((score, tag, None))
seen_tags.add(tag.name)
# Search by aliases
if search_aliases:
for alias_lower, tag in self._alias_map.items():
if tag.name in seen_tags:
continue
if query_lower in alias_lower:
score = get_score(tag, alias_lower)
# Find the original case alias
original_alias = next(
(a for a in tag.aliases if a.lower() == alias_lower),
alias_lower,
)
results.append((score, tag, original_alias))
seen_tags.add(tag.name)
# Sort by: score (desc), then post count (desc)
results.sort(key=lambda x: (-x[0], -x[1].count))
# Format results
output = []
for _, tag, matched_alias in results[:limit]:
entry = tag.to_dict()
if matched_alias:
entry["matched_alias"] = matched_alias
output.append(entry)
return output
# Global database instance (lazy loaded)
_database = TagDatabase()
def get_database() -> TagDatabase:
"""Get the global tag database instance."""
return _database
def ensure_database_loaded(
tag_file: str = DEFAULT_TAG_FILE,
extra_files: Optional[List[str]] = None,
) -> TagDatabase:
"""
Ensure the database is loaded, loading it if necessary.
Args:
tag_file: Name of the main tag file to load
extra_files: Optional list of additional tag files to merge (e.g., ["extra-quality-tags.csv"])
Returns:
The loaded TagDatabase instance
"""
db = get_database()
# Build list of files to load
files_to_load = [tag_file]
if extra_files:
files_to_load.extend(extra_files)
# Check if we need to reload
needs_reload = not db.is_loaded or set(db._current_files) != set(files_to_load)
if needs_reload:
# Find and load all tag files
filepaths = []
for filename in files_to_load:
# Try main data directory first
filepath = DATA_DIR / filename
# Fall back to reference directory
if not filepath.exists():
ref_path = (
Path(__file__).parent.parent
/ "autocomplete_reference"
/ "a1111-sd-webui-tagcomplete"
/ "tags"
/ filename
)
if ref_path.exists():
filepath = ref_path
if filepath.exists():
filepaths.append(filepath)
else:
print(f"[Autocomplete] Warning: Tag file not found: {filename}")
if filepaths:
db.load_multiple(filepaths)
return db
# Register API endpoints (only if server is available)
if _HAS_SERVER and PromptServer:
@PromptServer.instance.routes.post("/a1111_prompt/autocomplete")
async def autocomplete_tags(request):
"""
API endpoint for tag autocomplete.
Request body:
{
"query": "partial_tag_name",
"limit": 20,
"tag_file": "danbooru.csv",
"search_aliases": true
}
Response:
{
"results": [
{
"name": "1girl",
"type": 0,
"count": 6008644,
"aliases": ["1girls", "sole_female"],
"matched_alias": "sole_female" // only if matched via alias
},
...
],
"tag_count": 100000
}
"""
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
query = data.get("query", "")
limit = min(data.get("limit", 20), 100) # Cap at 100
tag_file = data.get(
"tag_file"
) # Will use DEFAULT_TAG_FILE in ensure_database_loaded if None
# Normalize extra_files: must be a list of strings
extra_files = data.get("extra_files")
if extra_files is None:
extra_files = ["extra-quality-tags.csv"]
elif isinstance(extra_files, str):
extra_files = [extra_files]
elif not isinstance(extra_files, list):
extra_files = ["extra-quality-tags.csv"]
search_aliases = data.get("search_aliases", True)
# Ensure database is loaded
db = ensure_database_loaded(
tag_file or DEFAULT_TAG_FILE, extra_files=extra_files
)
# Perform search
results = db.search(query, limit=limit, search_aliases=search_aliases)
return web.json_response({"results": results, "tag_count": db.tag_count})
@PromptServer.instance.routes.get("/a1111_prompt/autocomplete/status")
async def autocomplete_status(request):
"""
Get the status of the autocomplete database.
Response:
{
"loaded": true,
"tag_count": 100000,
"current_file": "danbooru.csv"
}
"""
db = get_database()
return web.json_response(
{
"loaded": db.is_loaded,
"tag_count": db.tag_count,
"current_files": db._current_files,
}
)
@PromptServer.instance.routes.get("/a1111_prompt/autocomplete/files")
async def list_tag_files(request):
"""
List available tag files.
Response:
{
"files": ["danbooru.csv", "e621.csv", ...]
}
"""
files = []
# Check main data directory
if DATA_DIR.exists():
files.extend(f.name for f in DATA_DIR.glob("*.csv"))
# Check reference directory
ref_dir = (
Path(__file__).parent.parent
/ "autocomplete_reference"
/ "a1111-sd-webui-tagcomplete"
/ "tags"
)
if ref_dir.exists():
for f in ref_dir.glob("*.csv"):
if f.name not in files:
files.append(f.name)
files.sort()
return web.json_response({"files": files})