Merge pull request #4707 from vladmandic/feat/tag-dictionaries

Feat/tag dictionaries
This commit is contained in:
Vladimir Mandic
2026-04-13 11:57:48 +02:00
committed by GitHub
13 changed files with 1756 additions and 9 deletions
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""Fetch and convert booru tag databases to SD.Next autocomplete format.
Usage:
python cli/tags-fetch.py danbooru [--output PATH] [--min-count N]
python cli/tags-fetch.py e621 [--output PATH] [--min-count N]
python cli/tags-fetch.py rule34 --key USER_ID:API_KEY [--output PATH] [--min-count N]
python cli/tags-fetch.py sankaku [--output PATH] [--min-count N]
python cli/tags-fetch.py idol [--output PATH] [--min-count N]
python cli/tags-fetch.py all --key USER_ID:API_KEY [--output-dir DIR] [--min-count N]
Progress is saved every 50 pages to a .partial file so interrupted
runs can be resumed. Transient HTTP errors are retried with backoff.
Output format:
JSON with { name, version, categories, tags: [[name, category_id, post_count], ...] }
Tags sorted by post_count descending.
"""
import argparse
import json
import os
import sys
import time
from datetime import date
import requests
# Unified category scheme - all sources map their native type IDs to these.
# Every dict file uses these same IDs and colors.
UNIFIED_CATEGORIES = {
"0": {"name": "general", "color": "#0075f8"},
"1": {"name": "artist", "color": "#cc0000"},
"2": {"name": "studio", "color": "#ff4500"},
"3": {"name": "copyright", "color": "#9900ff"},
"4": {"name": "character", "color": "#00ab2c"},
"5": {"name": "species", "color": "#ed5d1f"},
"6": {"name": "genre", "color": "#8a66ff"},
"7": {"name": "medium", "color": "#00cccc"},
"8": {"name": "meta", "color": "#6b7280"},
"9": {"name": "lore", "color": "#228b22"},
"10": {"name": "lens", "color": "#e67e22"},
"11": {"name": "lighting", "color": "#f1c40f"},
"12": {"name": "composition", "color": "#1abc9c"},
"13": {"name": "color", "color": "#e84393"},
}
# Source → unified type maps. Each maps the source's native category IDs
# to the unified IDs above. Unmapped IDs default to 0 (general).
DANBOORU_TYPE_MAP = {0: 0, 1: 1, 3: 3, 4: 4, 5: 8}
E621_TYPE_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 0, 7: 8, 8: 9}
RULE34_TYPE_MAP = {0: 0, 1: 1, 3: 3, 4: 4, 5: 8}
SANKAKU_TYPE_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 6, 8: 7, 9: 8}
# Idol Complex uses Sankaku's granular tag type system (21 subcategories).
# API type → unified category:
# 0=studio, 1=artist, 2=studio, 3=franchise, 4=character,
# 5=photoset, 6=genre, 8=medium, 9=meta, 10=fashion,
# 11=anatomy, 12=pose, 13=activity, 14=role, 15=flora,
# 17=fauna/entity, 18=object/setting, 19=substance, 20=general,
# 21=language, 22=automatic
IDOL_TYPE_MAP = {
0: 2, 1: 1, 2: 2, 3: 3, 4: 4, 5: 8, 6: 6, 8: 7, 9: 8,
10: 0, 11: 0, 12: 0, 13: 0, 14: 0, 15: 0, 17: 0, 18: 0,
19: 0, 20: 0, 21: 8, 22: 8,
}
USER_AGENT = "SDNext-DictFetcher/1.0 (tag autocomplete)"
SAVE_INTERVAL = 50 # save progress every N pages
MAX_RETRIES = 3
RETRY_BACKOFF = 5 # seconds, multiplied by attempt number
# -- Partial save/resume --
def save_partial(path: str, page: int, tags: list):
"""Save fetch progress to a .partial file."""
if not path:
return
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"page": page, "tags": tags}, f, separators=(",", ":"))
os.replace(tmp, path)
def load_partial(path: str) -> tuple[int, list] | tuple[None, list]:
"""Load saved progress from a .partial file. Returns (page, tags) or (None, [])."""
if not path or not os.path.isfile(path):
return None, []
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
page = data["page"]
tags = data["tags"]
print(f" Resuming from partial: page {page}, {len(tags)} tags", file=sys.stderr)
return page, tags
except (json.JSONDecodeError, KeyError) as e:
print(f" Warning: corrupt .partial file, starting fresh ({e})", file=sys.stderr)
return None, []
def clear_partial(path: str):
"""Remove .partial file after successful completion."""
if path and os.path.isfile(path):
os.remove(path)
# -- HTTP retry helper --
def fetch_with_retry(session: requests.Session, url: str, params: dict | None = None, timeout: int = 30) -> requests.Response:
"""GET with retry and exponential backoff for transient errors."""
for attempt in range(MAX_RETRIES):
try:
resp = session.get(url, params=params, timeout=timeout)
resp.raise_for_status()
return resp
except requests.RequestException as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF * (attempt + 1)
print(f" Retry {attempt + 1}/{MAX_RETRIES} in {wait}s: {e}", file=sys.stderr)
time.sleep(wait)
else:
raise
# -- Fetchers --
def fetch_danbooru(min_count: int = 10, partial_path: str = "", **_kwargs) -> list:
"""Fetch tags from Danbooru API, paginated."""
start_page, tags = load_partial(partial_path)
page = (start_page + 1) if start_page is not None else 1
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
while True:
url = f"https://danbooru.donmai.us/tags.json?limit=1000&page={page}&search[order]=count"
try:
resp = fetch_with_retry(session, url)
except requests.RequestException as e:
print(f" Error on page {page}: {e}", file=sys.stderr)
break
data = resp.json()
if not data:
break
for tag in data:
count = tag.get("post_count", 0)
if count < min_count:
continue
tags.append([tag["name"], tag["category"], count])
below_threshold = all(t.get("post_count", 0) < min_count for t in data)
print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr)
if below_threshold:
break
if page % SAVE_INTERVAL == 0:
save_partial(partial_path, page, tags)
page += 1
time.sleep(0.5)
tags.sort(key=lambda t: t[2], reverse=True)
return tags
def fetch_e621(min_count: int = 10, partial_path: str = "", **_kwargs) -> list:
"""Fetch tags from e621 API, paginated."""
start_page, tags = load_partial(partial_path)
page = (start_page + 1) if start_page is not None else 1
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
while True:
url = f"https://e621.net/tags.json?limit=320&page={page}&search[order]=count"
try:
resp = fetch_with_retry(session, url)
except requests.RequestException as e:
print(f" Error on page {page}: {e}", file=sys.stderr)
break
data = resp.json()
if not data:
break
for tag in data:
count = tag.get("post_count", 0)
if count < min_count:
continue
tags.append([tag["name"], tag["category"], count])
below_threshold = all(t.get("post_count", 0) < min_count for t in data)
print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr)
if below_threshold:
break
if page % SAVE_INTERVAL == 0:
save_partial(partial_path, page, tags)
page += 1
time.sleep(1.0)
tags.sort(key=lambda t: t[2], reverse=True)
return tags
def fetch_gelbooru(base_url: str, min_count: int = 10, api_key: str | None = None, rate_limit: float = 0.5, partial_path: str = "") -> list:
"""Fetch tags from a Gelbooru-compatible API (rule34, gelbooru, etc.).
Unlike Danbooru/e621, the Gelbooru tag endpoint doesn't support ordering
by count, so we paginate through all tags and filter client-side.
The tag endpoint returns XML (json=1 is not supported for tags).
"""
import xml.etree.ElementTree as ET
start_page, tags = load_partial(partial_path)
page = (start_page + 1) if start_page is not None else 0
page_size = 1000
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
params: dict[str, str] = {
"page": "dapi", "s": "tag", "q": "index",
"limit": str(page_size),
}
if api_key:
if ":" not in api_key:
print(" Error: --key must be USER_ID:API_KEY format", file=sys.stderr)
return []
uid, key = api_key.split(":", 1)
params["user_id"] = uid
params["api_key"] = key
while True:
params["pid"] = str(page)
try:
resp = fetch_with_retry(session, base_url, params=params)
except requests.RequestException as e:
print(f" Error on page {page} after {MAX_RETRIES} retries: {e}", file=sys.stderr)
break
text = resp.text.strip()
if not text or text.startswith('"') or not text.startswith('<?xml'):
if page == 0:
print(f" Error: {text[:200]}", file=sys.stderr)
break
try:
root = ET.fromstring(text)
except ET.ParseError as e:
print(f" Skipping page {page}: malformed XML ({e})", file=sys.stderr)
page += 1
time.sleep(rate_limit)
continue
elements = root.findall('tag')
for el in elements:
count = int(el.get("count", "0"))
if count >= min_count:
tags.append([el.get("name", ""), int(el.get("type", "0")), count])
print(f" Page {page}: {len(elements)} tags (total: {len(tags)})", file=sys.stderr)
if len(elements) < page_size:
break
if page % SAVE_INTERVAL == 0:
save_partial(partial_path, page, tags)
page += 1
time.sleep(rate_limit)
tags.sort(key=lambda t: t[2], reverse=True)
return tags
def fetch_rule34(min_count: int = 10, api_key: str | None = None, partial_path: str = "", **_kwargs) -> list:
"""Fetch tags from rule34.xxx."""
if not api_key:
print(" Warning: no --key provided, rule34 may rate-limit aggressively", file=sys.stderr)
return fetch_gelbooru("https://api.rule34.xxx/index.php", min_count=min_count, api_key=api_key, partial_path=partial_path)
def fetch_sankaku(min_count: int = 10, partial_path: str = "", **_kwargs) -> list:
"""Fetch tags from Sankaku Complex (chan.sankakucomplex.com).
Uses the public JSON API at sankakuapi.com which supports order=count,
so we can stop early when counts drop below min_count.
Tag names come as English with spaces - converted to lowercase.
"""
start_page, tags = load_partial(partial_path)
page = (start_page + 1) if start_page is not None else 1
page_size = 200
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
while True:
try:
resp = fetch_with_retry(session, "https://sankakuapi.com/tags",
params={"limit": page_size, "page": page, "order": "count"})
except requests.RequestException as e:
print(f" Error on page {page} after {MAX_RETRIES} retries: {e}", file=sys.stderr)
break
data = resp.json()
if not data:
break
for tag in data:
count = tag.get("post_count", 0)
if count < min_count:
continue
name = tag.get("name_en") or tag.get("name_ja", "")
if not name:
continue
name = name.strip().lower()
tags.append([name, tag.get("type", 0), count])
below_threshold = all(tag.get("post_count", 0) < min_count for tag in data)
print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr)
if below_threshold:
break
if page % SAVE_INTERVAL == 0:
save_partial(partial_path, page, tags)
page += 1
time.sleep(0.5)
tags.sort(key=lambda t: t[2], reverse=True)
return tags
def fetch_idol(min_count: int = 10, partial_path: str = "", **_kwargs) -> list:
"""Fetch tags from Idol Complex (idol.sankakucomplex.com).
Uses the legacy JSON API at iapi.sankakucomplex.com. Supports order=count.
Max 50 tags per page. Type IDs are non-standard - remapped in write_dict.
"""
start_page, tags = load_partial(partial_path)
page = (start_page + 1) if start_page is not None else 1
page_size = 50
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/138.0.0.0 Safari/537.36"
while True:
try:
resp = fetch_with_retry(session, "https://iapi.sankakucomplex.com/tags.json",
params={"limit": page_size, "page": page, "order": "count"})
except requests.RequestException as e:
print(f" Error on page {page} after {MAX_RETRIES} retries: {e}", file=sys.stderr)
break
data = resp.json()
if not data:
break
for tag in data:
count = tag.get("count", 0)
if count < min_count:
continue
name = tag.get("name", "")
if not name:
continue
tags.append([name, tag.get("type", 0), count])
below_threshold = all(tag.get("count", 0) < min_count for tag in data)
print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr)
if below_threshold:
break
if page % SAVE_INTERVAL == 0:
save_partial(partial_path, page, tags)
page += 1
time.sleep(1.0)
tags.sort(key=lambda t: t[2], reverse=True)
return tags
SOURCES = {
"danbooru": {"fetch": fetch_danbooru, "type_map": DANBOORU_TYPE_MAP},
"e621": {"fetch": fetch_e621, "type_map": E621_TYPE_MAP},
"rule34": {"fetch": fetch_rule34, "type_map": RULE34_TYPE_MAP},
"sankaku": {"fetch": fetch_sankaku, "type_map": SANKAKU_TYPE_MAP},
"idol": {"fetch": fetch_idol, "type_map": IDOL_TYPE_MAP},
}
def write_dict(name: str, tags: list, type_map: dict, output_path: str, separator: str = "_"):
"""Write dict JSON file atomically.
type_map remaps source-native category IDs to unified IDs.
separator controls the word separator in tag names:
"_" (default) → "high_resolution" (booru convention, anime/illustration models)
" ""high resolution" (natural language, SDXL/Flux-style models)
"""
normalized = []
for t in tags:
tag_name = t[0].replace(" ", "_") if separator == "_" else t[0].replace("_", " ")
category = type_map.get(t[1], 0)
normalized.append([tag_name, category, t[2]])
data = {
"name": name,
"version": date.today().isoformat(),
"categories": UNIFIED_CATEGORIES,
"tags": normalized,
}
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
tmp_path = output_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp_path, output_path)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
print(f" Written: {output_path} ({len(tags)} tags, {size_mb:.1f} MB)", file=sys.stderr)
def fetch_source(name: str, output: str, min_count: int, api_key: str | None = None, separator: str = "_"):
"""Fetch and write a single source."""
if name not in SOURCES:
print(f"Unknown source: {name}. Available: {', '.join(SOURCES.keys())}", file=sys.stderr)
sys.exit(1)
source = SOURCES[name]
partial_path = output + ".partial"
print(f"Fetching {name} (min_count={min_count})...", file=sys.stderr)
tags = source["fetch"](min_count=min_count, api_key=api_key, partial_path=partial_path)
if not tags:
print(f" No tags fetched for {name}", file=sys.stderr)
return
write_dict(name, tags, source["type_map"], output, separator=separator)
clear_partial(partial_path)
from importlib.util import spec_from_file_location, module_from_spec
spec = spec_from_file_location("tags_manifest", os.path.join(os.path.dirname(__file__), "tags-manifest.py"))
mod = module_from_spec(spec)
spec.loader.exec_module(mod)
update_manifest = mod.update_manifest
update_manifest(os.path.dirname(output) or ".")
def main():
parser = argparse.ArgumentParser(description="Fetch booru tag databases for SD.Next autocomplete")
parser.add_argument("source", choices=list(SOURCES.keys()) + ["all"], help="Tag source to fetch")
parser.add_argument("--output", "-o", help="Output file path (for single source)")
parser.add_argument("--output-dir", "-d", help="Output directory (for 'all')")
parser.add_argument("--min-count", "-m", type=int, default=10, help="Minimum post count to include (default: 10)")
parser.add_argument("--key", "-k", help="API key as USER_ID:API_KEY (required for rule34)")
parser.add_argument("--spaces", action="store_true", help="Use spaces instead of underscores in tag names (for natural language models like SDXL/Flux)")
args = parser.parse_args()
separator = " " if args.spaces else "_"
if args.source == "all":
output_dir = args.output_dir or "."
for name in SOURCES:
output = os.path.join(output_dir, f"{name}.json")
fetch_source(name, output, args.min_count, api_key=args.key, separator=separator)
else:
output = args.output or f"{args.source}.json"
fetch_source(args.source, output, args.min_count, api_key=args.key, separator=separator)
print("Done.", file=sys.stderr)
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Generate manifest.json for the HuggingFace autocomplete repository.
Reads tag JSON files and writes a manifest with accurate tag counts,
file sizes, and versions.
Usage:
python cli/tags-manifest.py models/autocomplete/danbooru.json models/autocomplete/e621.json ...
python cli/tags-manifest.py models/autocomplete/*.json -o models/autocomplete/manifest.json
"""
import argparse
import json
import os
import sys
# Human-readable descriptions keyed by file name.
# Add entries here when new sources are added to tags-fetch.py.
DESCRIPTIONS = {
"art": "Art movements, styles, and techniques",
"danbooru": "Danbooru image board tags - anime/illustration focused",
"e621": "e621 tags - furry/animal art focused",
"idol": "Idol Complex tags - Japanese idol photography",
"negative": "Common negative prompt terms",
"photography": "Photography terms - lens, lighting, composition, color",
"quality": "Quality and aesthetic meta tags",
"rule34": "Rule34.xxx tags - multi-fandom",
"sankaku": "Sankaku Complex tags - anime/illustration with granular categories",
}
def build_entry(filepath: str) -> dict:
"""Build a manifest entry from a tag JSON file."""
with open(filepath, encoding="utf-8") as f:
data = json.load(f)
name = data.get("name") or os.path.splitext(os.path.basename(filepath))[0]
size_mb = round(os.path.getsize(filepath) / (1024 * 1024), 1)
return {
"name": name,
"description": DESCRIPTIONS.get(name, ""),
"version": data.get("version", ""),
"tag_count": len(data.get("tags", [])),
"size_mb": size_mb,
}
def update_manifest(directory: str) -> bool:
"""Regenerate manifest.json in directory if one already exists.
Only updates entries already listed in the manifest - does not
add new entries. Returns True if the manifest was updated, False
if no manifest exists to update.
"""
manifest_path = os.path.join(directory, "manifest.json")
if not os.path.isfile(manifest_path):
return False
with open(manifest_path, encoding="utf-8") as f:
manifest = json.load(f)
existing_names = {e["name"] for e in manifest.get("entries", [])}
entries = []
for name in sorted(existing_names):
filepath = os.path.join(directory, f"{name}.json")
if not os.path.isfile(filepath):
continue
try:
entries.append(build_entry(filepath))
except (json.JSONDecodeError, KeyError):
continue
manifest["entries"] = entries
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, separators=(",", ":"))
f.write("\n")
print(f" Manifest updated: {manifest_path} ({len(entries)} entries)", file=sys.stderr)
return True
def main():
parser = argparse.ArgumentParser(description="Generate manifest.json for HF autocomplete repo")
parser.add_argument("files", nargs="+", help="Tag JSON files to include")
parser.add_argument("-o", "--output", default="manifest.json", help="Output path (default: manifest.json)")
parser.add_argument("--exclude", nargs="*", default=[], help="Names to exclude (e.g. art quality)")
args = parser.parse_args()
exclude = set(args.exclude)
entries = []
for filepath in args.files:
basename = os.path.splitext(os.path.basename(filepath))[0]
if basename in exclude or basename == "manifest":
continue
if not os.path.isfile(filepath):
print(f" Skipping {filepath}: not found", file=sys.stderr)
continue
try:
entry = build_entry(filepath)
entries.append(entry)
print(f" {entry['name']:>12}: {entry['tag_count']:>10,} tags, {entry['size_mb']:>6.1f} MB, v={entry['version']}", file=sys.stderr)
except (json.JSONDecodeError, KeyError) as e:
print(f" Skipping {filepath}: {e}", file=sys.stderr)
entries.sort(key=lambda e: e["name"])
manifest = {"entries": entries}
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, separators=(",", ":"))
f.write("\n")
print(f"\nManifest written: {args.output} ({len(entries)} entries)", file=sys.stderr)
if __name__ == "__main__":
main()
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Prune tag autocomplete files with per-category minimum post counts.
Usage:
python cli/tags-prune.py models/autocomplete/danbooru.json
python cli/tags-prune.py models/autocomplete/*.json --general 500 --artist 20
python cli/tags-prune.py models/autocomplete/sankaku.json -o sankaku-pruned.json
python cli/tags-prune.py models/autocomplete/*.json --dry-run
Category-aware pruning: artists and characters are kept at lower thresholds
(proper nouns that need autocomplete), while general tags are pruned more
aggressively (common vocabulary you'd type naturally).
"""
import argparse
import json
import os
import sys
# Category ID -> name (must match UNIFIED_CATEGORIES in tags-fetch.py)
CATEGORY_NAMES = {
0: "general",
1: "artist",
2: "studio",
3: "copyright",
4: "character",
5: "species",
6: "genre",
7: "medium",
8: "meta",
9: "lore",
}
# Default minimum post counts per category.
# Low for proper nouns (hard to guess), high for common vocabulary (easy to type).
DEFAULTS = {
"general": 200,
"artist": 10,
"studio": 10,
"copyright": 20,
"character": 10,
"species": 50,
"genre": 100,
"medium": 100,
"meta": 1000,
"lore": 50,
}
def prune(tags: list, thresholds: dict[str, int]) -> tuple[list, dict[str, tuple[int, int]]]:
"""Prune tags by per-category thresholds.
Returns (pruned_tags, stats) where stats maps category name
to (before_count, after_count).
"""
stats: dict[str, tuple[int, int]] = {}
kept = []
for tag in tags:
cat_id = tag[1]
cat_name = CATEGORY_NAMES.get(cat_id, "general")
threshold = thresholds.get(cat_name, thresholds.get("general", 10))
before, after = stats.get(cat_name, (0, 0))
before += 1
if tag[2] >= threshold:
kept.append(tag)
after += 1
stats[cat_name] = (before, after)
return kept, stats
def main():
parser = argparse.ArgumentParser(
description="Prune tag dictionaries with per-category minimum post counts",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Defaults: " + ", ".join(f"{k}={v}" for k, v in DEFAULTS.items()),
)
parser.add_argument("files", nargs="+", help="Dict JSON files to prune")
parser.add_argument("-o", "--output", help="Output file (single file mode only)")
parser.add_argument("--dry-run", action="store_true", help="Show what would be pruned without writing")
parser.add_argument("--in-place", "-i", action="store_true", help="Overwrite input files")
for name, default in DEFAULTS.items():
parser.add_argument(f"--{name}", type=int, default=default, help=f"Min posts for {name} (default: {default})")
args = parser.parse_args()
if args.output and len(args.files) > 1:
print("Error: --output can only be used with a single input file", file=sys.stderr)
sys.exit(1)
if not args.output and not args.in_place and not args.dry_run:
print("Error: specify --in-place, --output, or --dry-run", file=sys.stderr)
sys.exit(1)
thresholds = {name: getattr(args, name) for name in DEFAULTS}
from importlib.util import spec_from_file_location, module_from_spec
spec = spec_from_file_location("tags_manifest", os.path.join(os.path.dirname(__file__), "tags-manifest.py"))
mod = module_from_spec(spec)
spec.loader.exec_module(mod)
update_manifest = mod.update_manifest
for filepath in args.files:
if not os.path.isfile(filepath):
print(f" Skipping {filepath}: not found", file=sys.stderr)
continue
with open(filepath, encoding="utf-8") as f:
data = json.load(f)
tags = data.get("tags", [])
pruned, stats = prune(tags, thresholds)
total_before = sum(s[0] for s in stats.values())
total_after = sum(s[1] for s in stats.values())
name = data.get("name", os.path.basename(filepath))
print(f"\n{name}: {total_before:,}{total_after:,} tags ({total_before - total_after:,} removed)", file=sys.stderr)
for cat_name in sorted(stats, key=lambda c: list(CATEGORY_NAMES.values()).index(c) if c in CATEGORY_NAMES.values() else 99):
before, after = stats[cat_name]
threshold = thresholds.get(cat_name, 10)
removed = before - after
if removed > 0:
print(f" {cat_name:>12}: {before:>8,}{after:>8,} (min {threshold:>5}, -{removed:,})", file=sys.stderr)
else:
print(f" {cat_name:>12}: {before:>8,} (min {threshold:>5}, all kept)", file=sys.stderr)
if args.dry_run:
continue
data["tags"] = pruned
output_path = args.output or filepath
tmp_path = output_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp_path, output_path)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
print(f" Written: {output_path} ({size_mb:.1f} MB)", file=sys.stderr)
update_manifest(os.path.dirname(output_path) or ".")
if __name__ == "__main__":
main()
+9 -1
View File
@@ -54,9 +54,12 @@
{"id":"","label":"_Guidance scale","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"_Guidance rescale","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"_Guidance start","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"_Guidance stop","localized":"","hint":"","ui":"txt2img"}
{"id":"","label":"_Guidance stop","localized":"","hint":"","ui":"txt2img"},
{"id":"tag_autocomplete_refresh","label":"⟲","localized":"","hint":"Fetch list of available tag dictionaries from the remote repository","ui":"script_autocomplete"},
{"id":"tag_autocomplete_update","label":"⇩","localized":"","hint":"Re-download enabled dictionaries if a newer version is available","ui":"script_autocomplete"}
],
"a": [
{"id":"","label":"Active dictionaries","localized":"","hint":"Select which tag dictionaries are used for prompt autocompletion.<br>Dictionaries not yet downloaded locally will be fetched automatically when the autocomplete engine loads them.","ui":"script_autocomplete"},
{"id":"txt2img_advanced","label":"Advanced","localized":"","hint":"Advanced settings used to run image generation","ui":"txt2img"},
{"id":"txt2img_adapters","label":"Adapters","localized":"","hint":"Settings related to IP Adapters","ui":"txt2img"},
{"id":"component-981","label":"Apply to model","localized":"","hint":"","ui":"script_layerdiffuse"},
@@ -71,6 +74,7 @@
{"id":"","label":"Answer","localized":"","hint":"","ui":"caption"},
{"id":"","label":"Adjust start","localized":"","hint":"Starting step when sigma adjust occurs","ui":"txt2img"},
{"id":"","label":"Adjust end","localized":"","hint":"Ending step when sigma adjust occurs","ui":"txt2img"},
{"id":"","label":"Autocomplete","localized":"","hint":"Enable or disable Tag Autocomplete. Choose which dictionaries are used for prompt autocompletion in Extras","ui":"control"},
{"id":"","label":"AutoGuidance dropout","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"AutoGuidance layers","localized":"","hint":"","ui":"txt2img"},
{"id":"","label":"AutoGuidance config","localized":"","hint":"","ui":"txt2img"},
@@ -315,6 +319,7 @@
{"id":"","label":"CivitAI discard downloads with hash mismatch","localized":"","hint":"","ui":"settings_huggingface"},
{"id":"","label":"Cache text encoder results","localized":"","hint":"","ui":"settings_legacy_options"},
{"id":"","label":"contain","localized":"","hint":"","ui":"settings_legacy_options"},
{"id":"","label":"Comma separator","localized":"","hint":"Automatically insert a comma between tags when accepting an autocomplete suggestion.<br>Disable for natural-language prompts where commas are not used as delimiters.","ui":"script_autocomplete"},
{"id":"","label":"Ctrl+up/down word delimiters","localized":"","hint":"","ui":"settings_legacy_options"},
{"id":"","label":"Ctrl+up/down precision when editing (attention:1.1)","localized":"","hint":"","ui":"settings_legacy_options"},
{"id":"","label":"Ctrl+up/down precision when editing <extra networks:0.9>","localized":"","hint":"","ui":"settings_legacy_options"},
@@ -941,6 +946,7 @@
{"id":"","label":"max-autotune-no-cudagraphs","localized":"","hint":"","ui":"settings_compile"},
{"id":"","label":"Maximum image size (MP)","localized":"","hint":"","ui":"settings_saving-images"},
{"id":"","label":"Max words","localized":"","hint":"","ui":"settings_saving-paths"},
{"id":"","label":"Min characters","localized":"","hint":"Number of characters that must be typed before autocomplete suggestions appear.<br>Lower values show suggestions sooner but may feel noisy; higher values wait for a more specific prefix.","ui":"script_autocomplete"},
{"id":"","label":"Modern","localized":"","hint":"","ui":"settings_ui"},
{"id":"","label":"Mount URL subpath","localized":"","hint":"","ui":"settings_ui"},
{"id":"","label":"Mobile scale","localized":"","hint":"","ui":"settings_ui"},
@@ -1229,6 +1235,7 @@
{"id":"","label":"RAS enabled","localized":"","hint":"","ui":"settings_advanced"},
{"id":"","label":"reduce-overhead","localized":"","hint":"","ui":"settings_compile"},
{"id":"","label":"repeated","localized":"","hint":"","ui":"settings_compile"},
{"id":"","label":"Replace underscores","localized":"","hint":"Display underscores in tag names as spaces in the autocomplete suggestion list.<br>For example, <i>long_hair</i> appears as <i>long hair</i>.","ui":"script_autocomplete"},
{"id":"","label":"Root model folder","localized":"","hint":"","ui":"settings_system-paths"},
{"id":"","label":"Resize background color","localized":"","hint":"","ui":"settings_saving-images"},
{"id":"","label":"Restore from metadata: skip params","localized":"","hint":"","ui":"settings_image-metadata"},
@@ -1436,6 +1443,7 @@
{"id":"","label":"T2I Adapter","localized":"","hint":"","ui":"control"},
{"id":"","label":"Tagger","localized":"","hint":"Tag images using anime-focused classification models like WaifuDiffusion or DeepBooru.","ui":"caption"},
{"id":"btn_wd_tag","label":"Tag","localized":"","hint":"","ui":"caption"},
{"id":"","label":"Tag Autocomplete","localized":"","hint":"Suggests matching tags from booru and other dictionaries as you type in prompt fields.<br>Use the refresh button to fetch the list of available dictionaries, then select which ones to enable.","ui":"script_autocomplete"},
{"id":"","label":"Text Encoder","localized":"","hint":"Settings related to text encoder and prompt encoding processing during generate"},
{"id":"","label":"Text","localized":"","hint":"Create image from text"},
{"id":"","label":"TorchAO","localized":"","hint":"","ui":"settings_quantization"},
+564
View File
@@ -0,0 +1,564 @@
/*
* Tag autocomplete for SD.Next prompt textareas.
*
* Ported from Enso's CodeMirror-based autocomplete (autocomplete.ts).
* Uses binary search on sorted tag arrays for O(log n) prefix lookup,
* with substring fallback for 4+ char queries.
*/
// -- Category colors (unified 14-category scheme) --
const CATEGORY_COLORS = {
0: '#0075f8', // general
1: '#cc0000', // artist
2: '#ff4500', // studio
3: '#9900ff', // copyright
4: '#00ab2c', // character
5: '#ed5d1f', // species
6: '#8a66ff', // genre
7: '#00cccc', // medium
8: '#6b7280', // meta
9: '#228b22', // lore
10: '#e67e22', // lens
11: '#f1c40f', // lighting
12: '#1abc9c', // composition
13: '#e84393', // color
};
const CATEGORY_NAMES = {
0: 'general',
1: 'artist',
2: 'studio',
3: 'copyright',
4: 'character',
5: 'species',
6: 'genre',
7: 'medium',
8: 'meta',
9: 'lore',
10: 'lens',
11: 'lighting',
12: 'composition',
13: 'color',
};
let active = false;
// -- Utilities (ported from Enso) --
/** Binary search for the first tag where tag.name >= prefix. */
function lowerBound(tags, prefix) {
let lo = 0;
let hi = tags.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (tags[mid].name < prefix) lo = mid + 1;
else hi = mid;
}
return lo;
}
/** Format post count as abbreviated string. */
function formatCount(count) {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${Math.round(count / 1_000)}k`;
return String(count);
}
/**
* Estimate viewport Y of the bottom of the caret line using a persistent
* offscreen mirror div. Styles and width are re-read from the textarea on
* every call so resized textareas are handled correctly.
*/
let caretMirror = null;
let caretMarker = null;
const MIRROR_PROPS = ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
'lineHeight', 'letterSpacing', 'wordSpacing', 'textTransform',
'padding', 'border', 'boxSizing'];
function caretViewportY(textarea) {
if (!caretMirror) {
caretMirror = document.createElement('div');
caretMirror.className = 'autocomplete-mirror';
caretMirror.style.whiteSpace = 'pre-wrap';
caretMirror.style.wordWrap = 'break-word';
caretMirror.style.position = 'absolute';
caretMirror.style.left = '-9999px';
caretMirror.style.overflow = 'hidden';
caretMarker = document.createElement('span');
caretMarker.textContent = '\u200b';
document.body.appendChild(caretMirror);
}
const cs = getComputedStyle(textarea);
for (const p of MIRROR_PROPS) caretMirror.style[p] = cs[p];
caretMirror.style.width = `${textarea.offsetWidth}px`;
caretMirror.textContent = textarea.value.substring(0, textarea.selectionStart);
caretMirror.appendChild(caretMarker);
const offset = caretMarker.offsetTop + caretMarker.offsetHeight;
return textarea.getBoundingClientRect().top + offset - textarea.scrollTop;
}
// -- TagIndex --
class TagIndex {
constructor(data) {
this.categories = data.categories || {};
// Build sorted array of {name, category, count} from raw [name, catId, count] tuples
this.tags = data.tags.map(([name, category, count]) => ({
name: name.toLowerCase(),
display: name,
category,
count,
}));
this.tags.sort((a, b) => a.name.localeCompare(b.name));
}
/** Prefix search with binary search. Returns matches sorted by count descending. */
search(prefix, limit = 20) {
const query = prefix.toLowerCase().replace(/ /g, '_');
if (!query) return [];
const start = lowerBound(this.tags, query);
const matches = [];
for (let i = start; i < this.tags.length && matches.length < limit * 5; i++) {
if (!this.tags[i].name.startsWith(query)) break;
matches.push(this.tags[i]);
}
// Substring fallback for 4+ chars if prefix found nothing
if (matches.length === 0 && query.length >= 4) {
for (let i = 0; i < this.tags.length && matches.length < limit * 5; i++) {
if (this.tags[i].name.includes(query)) matches.push(this.tags[i]);
}
}
matches.sort((a, b) => b.count - a.count);
return matches.slice(0, limit);
}
}
// -- Engine --
const engine = {
indices: new Map(), // name -> TagIndex
categoryColors: { ...CATEGORY_COLORS },
categoryNames: { ...CATEGORY_NAMES },
async loadEnabled() {
const enabled = window.opts?.autocomplete_enabled || [];
active = window.opts?.autocomplete_active || false;
if (!active) {
this.indices.clear();
return;
}
const toLoad = enabled.filter((n) => !this.indices.has(n));
const toRemove = [...this.indices.keys()].filter((n) => !enabled.includes(n));
toRemove.forEach((n) => this.indices.delete(n));
await Promise.all(toLoad.map(async (name) => {
try {
const resp = await fetch(`${window.api}/autocomplete/${name}`, { credentials: 'include' });
if (!resp.ok) throw new Error(`${resp.status}`);
const data = await resp.json();
this.indices.set(name, new TagIndex(data));
// Extract category colors from first loaded file
if (data.categories) {
Object.entries(data.categories).forEach(([id, cat]) => {
if (cat.color) this.categoryColors[id] = cat.color;
if (cat.name) this.categoryNames[id] = cat.name;
});
}
log('autoComplete', { loaded: name, tags: data.tags?.length || 0 });
} catch (e) {
log('autoComplete', { failed: name, error: e });
}
}));
},
searchAll(prefix, limit = 20) {
if (this.indices.size === 0) return [];
const all = [];
this.indices.forEach((index) => {
all.push(...index.search(prefix, limit));
});
// Deduplicate by name, keeping highest count
const seen = new Map();
all.forEach((tag) => {
const existing = seen.get(tag.name);
if (!existing || tag.count > existing.count) seen.set(tag.name, tag);
});
const results = [...seen.values()];
results.sort((a, b) => b.count - a.count);
return results.slice(0, limit);
},
};
// -- Textarea integration --
/** Extract the current word being typed at the cursor position. */
function getCurrentWord(textarea) {
const { value, selectionStart } = textarea;
if (selectionStart !== textarea.selectionEnd) return null; // has selection
// Scan backward from cursor to find word start
let start = selectionStart;
while (start > 0) {
const ch = value[start - 1];
if (ch === ',' || ch === '\n') break;
start--;
}
// Skip leading whitespace
while (start < selectionStart && value[start] === ' ') start++;
const word = value.slice(start, selectionStart);
if (!word) return null;
// Skip if inside angle brackets (LoRA/embedding syntax)
const before = value.slice(0, selectionStart);
const lastOpen = before.lastIndexOf('<');
const lastClose = before.lastIndexOf('>');
if (lastOpen > lastClose) return null;
// Skip if inside wildcard syntax
const wcBefore = before.slice(start);
if (wcBefore.startsWith('__') && !wcBefore.endsWith('__')) return null;
return { word, start, end: selectionStart };
}
/** Insert a tag at the current word position, replacing the typed prefix. */
function insertTag(textarea, tagName) {
const info = getCurrentWord(textarea);
if (!info) return;
const { value } = textarea;
const before = value.slice(0, info.start);
const after = value.slice(info.end);
// Build insertion: tag + separator
const useComma = window.opts?.autocomplete_append_comma ?? true;
const sep = useComma ? ',' : '';
const needsSepBefore = before.length > 0 && before.trimEnd().length > 0 && !before.trimEnd().endsWith(',');
const prefix = needsSepBefore ? `${sep} ` : '';
let suffix = `${sep} `;
if (after.length > 0 && after.trimStart().startsWith(',')) suffix = ' ';
const insertion = `${prefix}${tagName}${suffix}`;
textarea.value = before.trimEnd() + (before.trimEnd().length > 0 ? ' ' : '') + insertion + after.trimStart();
// Position cursor after the inserted tag + separator
const cursorPos = before.trimEnd().length + (before.trimEnd().length > 0 ? 1 : 0) + insertion.length;
textarea.selectionStart = cursorPos;
textarea.selectionEnd = cursorPos;
// Sync with Gradio
if (typeof updateInput === 'function') updateInput(textarea);
}
// -- Dropdown --
const dropdown = {
el: null,
listEl: null,
selectedIndex: -1,
results: [],
textarea: null,
query: '',
visible: false,
init() {
this.el = document.createElement('div');
this.el.className = 'autocompleteResults';
this.el.style.display = 'none';
this.listEl = document.createElement('ul');
this.listEl.className = 'autocompleteResultsList';
this.el.appendChild(this.listEl);
document.body.appendChild(this.el);
this.el.addEventListener('mousedown', (e) => e.preventDefault()); // prevent blur on click
this.el.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
const idx = [...this.listEl.children].indexOf(li);
if (idx >= 0 && idx < this.results.length) {
this.selectedIndex = idx;
this.accept();
}
});
this.resizeObserver = new ResizeObserver(() => {
if (this.visible) this.position();
});
},
show(results, textarea, query) {
if (results.length === 0) { this.hide(); return; }
if (this.textarea !== textarea) {
if (this.textarea) this.resizeObserver.unobserve(this.textarea);
this.resizeObserver.observe(textarea);
}
this.results = results;
this.textarea = textarea;
this.query = query || '';
this.selectedIndex = -1;
this.render();
this.position();
this.el.style.display = '';
this.visible = true;
},
hide() {
if (this.textarea) this.resizeObserver.unobserve(this.textarea);
this.textarea = null;
this.el.style.display = 'none';
this.visible = false;
this.results = [];
this.selectedIndex = -1;
},
render() {
const replaceUnderscores = window.opts?.autocomplete_replace_underscores ?? true;
const queryNorm = this.query.toLowerCase().replace(/ /g, '_');
this.listEl.replaceChildren();
this.results.forEach((tag, i) => {
const li = document.createElement('li');
if (i === this.selectedIndex) li.classList.add('selected');
const dot = document.createElement('span');
dot.className = 'autocomplete-category';
dot.style.color = engine.categoryColors[tag.category] || '#888';
dot.textContent = '\u25CF';
dot.title = engine.categoryNames[tag.category] || '';
const name = document.createElement('span');
name.className = 'autocomplete-tag';
const tagText = replaceUnderscores ? tag.display.replace(/_/g, ' ') : tag.display;
const matchPos = tag.name.indexOf(queryNorm);
if (matchPos >= 0 && queryNorm.length > 0) {
const mark = document.createElement('mark');
mark.textContent = tagText.slice(matchPos, matchPos + queryNorm.length);
name.append(
document.createTextNode(tagText.slice(0, matchPos)),
mark,
document.createTextNode(tagText.slice(matchPos + queryNorm.length)),
);
} else {
name.textContent = tagText;
}
const count = document.createElement('span');
count.className = 'autocomplete-count';
count.textContent = tag.count > 0 ? formatCount(tag.count) : '';
li.append(dot, name, count);
li.addEventListener('mouseenter', () => {
this.selectedIndex = i;
this.updateSelection();
});
this.listEl.appendChild(li);
});
},
position() {
if (!this.textarea) return;
const rect = this.textarea.getBoundingClientRect();
// Position near the caret line instead of the textarea bottom
const cursorBottom = caretViewportY(this.textarea);
const anchorY = Math.max(rect.top, Math.min(cursorBottom, rect.bottom));
const spaceBelow = window.innerHeight - anchorY;
const dropHeight = Math.min(this.el.scrollHeight, 300);
if (spaceBelow >= dropHeight || spaceBelow >= anchorY - rect.top) {
this.el.style.top = `${anchorY + 2}px`;
} else {
this.el.style.top = `${anchorY - dropHeight - 2}px`;
}
this.el.style.left = `${rect.left}px`;
this.el.style.width = `${rect.width}px`;
},
updateSelection() {
[...this.listEl.children].forEach((li, i) => {
li.classList.toggle('selected', i === this.selectedIndex);
});
const selected = this.listEl.children[this.selectedIndex];
if (selected) selected.scrollIntoView({ block: 'nearest' });
},
navigate(dir) {
if (this.results.length === 0) return;
if (this.selectedIndex === -1) {
this.selectedIndex = dir > 0 ? 0 : this.results.length - 1;
} else {
this.selectedIndex = (this.selectedIndex + dir + this.results.length) % this.results.length;
}
this.updateSelection();
},
accept() {
if (this.selectedIndex < 0 || this.selectedIndex >= this.results.length) {
// Tab with no selection: select first
if (this.results.length > 0) {
this.selectedIndex = 0;
this.updateSelection();
}
return;
}
const tag = this.results[this.selectedIndex];
if (this.textarea) insertTag(this.textarea, tag.display);
this.hide();
},
};
// -- Event handlers --
let debounceTimer = null;
function onInput(textarea) {
if (!active) return;
const minChars = window.opts?.autocomplete_min_chars ?? 3;
const info = getCurrentWord(textarea);
if (!info || info.word.length < minChars) {
dropdown.hide();
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const results = engine.searchAll(info.word);
dropdown.show(results, textarea, info.word);
}, 150);
}
function onKeyDown(e) {
if (!dropdown.visible) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
dropdown.navigate(1);
break;
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
dropdown.navigate(-1);
break;
case 'Enter':
if (dropdown.selectedIndex >= 0) {
e.preventDefault();
e.stopPropagation();
dropdown.accept();
}
break;
case 'Tab':
e.preventDefault();
e.stopPropagation();
dropdown.accept();
break;
case 'Escape':
e.preventDefault();
e.stopPropagation();
dropdown.hide();
break;
default:
break;
}
}
/** Attach autocomplete to a single textarea. */
function attachAutocomplete(textarea) {
textarea.addEventListener('input', () => onInput(textarea));
textarea.addEventListener('keydown', onKeyDown);
textarea.addEventListener('focusout', () => {
setTimeout(() => dropdown.hide(), 200);
});
}
// -- Prompt textarea IDs --
const PROMPT_IDS = [
'txt2img_prompt', 'txt2img_neg_prompt',
'img2img_prompt', 'img2img_neg_prompt',
'control_prompt', 'control_neg_prompt',
'video_prompt', 'video_neg_prompt',
];
// -- Active button --
function patchActiveButton() {
const buttons = [...gradioApp().querySelectorAll('.autocomplete-active')];
active = window.opts?.autocomplete_active || false;
buttons.forEach((btn) => {
btn.classList.toggle('autocomplete-active', active);
btn.classList.toggle('autocomplete-inactive', !active);
btn.parentElement.onclick = () => {
active = !active;
window.opts.autocomplete_active = !active;
btn.classList.toggle('autocomplete-active', active);
btn.classList.toggle('autocomplete-inactive', !active);
};
});
}
// -- Config bridge --
/** Monkey-patch script config bridge textboxes to push autocomplete config changes to window.opts immediately. */
function patchConfigBridge() {
const elements = gradioApp().querySelectorAll('[id$="_tag_autocomplete_config_json"]');
for (const el of elements) {
const textarea = el.querySelector('textarea');
if (!textarea || textarea.acBridgePatched) continue;
textarea.acBridgePatched = true;
const proto = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
Object.defineProperty(textarea, 'value', {
set(newValue) {
const oldValue = proto.get.call(textarea);
proto.set.call(textarea, newValue);
if (oldValue !== newValue && newValue) {
try {
const cfg = JSON.parse(newValue);
for (const [key, val] of Object.entries(cfg)) window.opts[key] = val;
executeCallbacks(optionsChangedCallbacks);
} catch { /* ignore parse errors */ }
}
},
get() { return proto.get.call(textarea); },
});
}
}
// -- Initialization --
async function initAutocomplete() {
const enabled = window.opts?.autocomplete_enabled || [];
active = window.opts?.autocomplete_active || false;
log('autoComplete', { active, enabled });
// Inject styles (CSS files in javascript/ are not auto-loaded)
const style = document.createElement('style');
style.textContent = [
'.autocompleteResults { position: fixed; z-index: 9999; max-height: 300px; overflow-y: auto;',
' background: var(--sd-main-background-color, var(--background-fill-primary, #1f2937));',
' border: 1px solid var(--sd-input-border-color, var(--border-color-primary, #374151));',
' border-radius: var(--sd-border-radius, 6px); box-shadow: 0 4px 16px rgba(0,0,0,0.4);',
' font-size: 13px; scrollbar-width: thin; }',
'.autocompleteResultsList { list-style: none; margin: 0; padding: 4px 0; }',
'.autocompleteResultsList > li { display: flex; align-items: center; padding: 6px 12px; cursor: pointer;',
' gap: 8px; line-height: 1.4; transition: background 0.1s ease; border-bottom: 1px solid rgba(255,255,255,0.03); }',
'.autocompleteResultsList > li:last-child { border-bottom: none; }',
'.autocompleteResultsList > li:hover { background: var(--sd-panel-background-color, var(--input-background-fill-focus, #374151)); }',
'.autocompleteResultsList > li.selected { background: var(--sd-main-accent-color, var(--button-primary-background-fill, #4b5563)); }',
'.autocomplete-category { font-size: 10px; flex-shrink: 0; width: 10px; text-align: center; cursor: help; }',
'.autocomplete-tag { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'.autocomplete-tag mark { background: transparent; color: inherit; font-weight: 700; }',
'.autocomplete-count { font-size: 0.75em; opacity: 0.45; flex-shrink: 0; font-variant-numeric: tabular-nums;',
' background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 8px; min-width: 28px; text-align: right; }',
].join('\n');
document.head.appendChild(style);
dropdown.init();
await engine.loadEnabled();
// Attach to all prompt textareas; even if no dictionaries loaded yet, they may be enabled later via script UI
let attached = 0;
PROMPT_IDS.forEach((id) => {
const textarea = gradioApp().querySelector(`#${id} > label > textarea`);
if (textarea) {
attachAutocomplete(textarea);
attached++;
}
});
log('autoComplete', { attached, dicts: engine.indices.size });
// Reload when settings change
onOptionsChanged(async () => {
const newActive = window.opts?.autocomplete_active || false;
const newEnabled = window.opts?.autocomplete_enabled || [];
const currentKeys = [...engine.indices.keys()].sort().join(',');
const newKeys = [...newEnabled].sort().join(',');
if ((currentKeys !== newKeys) || (active !== newActive)) {
log('autoComplete', { reload: newEnabled });
await engine.loadEnabled();
active = newActive;
patchActiveButton();
}
});
// Watch for config updates from the script UI bridge
patchConfigBridge();
patchActiveButton();
onAfterUiUpdate(() => patchConfigBridge());
}
+11 -5
View File
@@ -324,8 +324,11 @@ async function setHints() {
for (const el of elements) {
// localize elements text
let found;
if (el.dataset.original) found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.dataset.original.toLowerCase().trim());
else found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.textContent.toLowerCase().trim());
if (el.id) found = localeData.data.find((l) => l.id && (l.id === el.id || el.id.endsWith(l.id))); // prefer id match for disambiguation
if (!found) {
if (el.dataset.original) found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.dataset.original.toLowerCase().trim());
else found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.textContent.toLowerCase().trim());
}
if (found?.localized?.length > 0) {
if (!el.dataset.original) el.dataset.original = el.textContent;
replaceTextContent(el, found.localized);
@@ -359,9 +362,12 @@ async function applyHintToElement(el) {
|| (el.tagName === 'SPAN' && (el.parentElement?.tagName === 'LABEL' || el.parentElement?.classList.contains('label-wrap')));
if (!isValidElement) return;
let found; // find matching hint data
if (el.dataset.original) found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.dataset.original.toLowerCase().trim());
else found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.textContent.toLowerCase().trim());
let found; // find matching hint data - prefer id match for disambiguation
if (el.id) found = localeData.data.find((l) => l.id && (l.id === el.id || el.id.endsWith(l.id)));
if (!found) {
if (el.dataset.original) found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.dataset.original.toLowerCase().trim());
else found = localeData.data.find((l) => l.label.toLowerCase().trim() === el.textContent.toLowerCase().trim());
}
if (found?.localized?.length > 0) { // apply localization if found
if (!el.dataset.original) el.dataset.original = el.textContent;
+1
View File
@@ -59,6 +59,7 @@ async function initStartup() {
// optinally wait for modern ui
if (window.waitForUiReady) await waitForUiReady();
initAutocomplete();
monitorConnection();
removeSplash();
+9 -3
View File
@@ -1,9 +1,10 @@
import os
from threading import Lock
from secrets import compare_digest
from fastapi import FastAPI, APIRouter, Depends, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.exceptions import HTTPException
from modules import errors, shared
from modules import errors, shared, paths
from modules.logger import log
from modules.api import models, endpoints, script, helpers, server, generate, process, control, docs, gpu
@@ -117,6 +118,11 @@ class Api:
from modules.api import loras
loras.register_api(self)
# autocomplete api
from modules.api import autocomplete as autocomplete_api
autocomplete_api.init(getattr(shared.opts, 'autocomplete_dir', '') or os.path.join(paths.models_path, 'autocomplete'))
autocomplete_api.register_api(self)
# gallery api
from modules.api import gallery
gallery.register_api(self.app)
@@ -139,9 +145,9 @@ class Api:
# hide trailing-slash duplicates from OpenAPI schema
from fastapi.routing import APIRoute
paths = {r.path for r in self.app.routes if hasattr(r, 'path')}
route_paths = {r.path for r in self.app.routes if hasattr(r, 'path')}
for route in self.app.routes:
if isinstance(route, APIRoute) and len(route.path) > 1 and route.path.endswith('/') and route.path[:-1] in paths:
if isinstance(route, APIRoute) and len(route.path) > 1 and route.path.endswith('/') and route.path[:-1] in route_paths:
route.include_in_schema = False
# upload api
+254
View File
@@ -0,0 +1,254 @@
"""V1 tag autocomplete endpoints.
Serves pre-built tag files (Danbooru, e621, natural language, artists)
from JSON files in the configured autocomplete directory. Remote files
are hosted on HuggingFace and downloaded on demand.
"""
import asyncio
import json
import os
from fastapi.exceptions import HTTPException
from modules.api.models import ItemAutocomplete, ItemAutocompleteContent, ItemAutocompleteRemote
from modules.logger import log
autocomplete_dir: str = ""
cache: dict[str, dict] = {}
HF_REPO = "CalamitousFelicitousness/prompt-vocab"
HF_BASE = f"https://huggingface.co/datasets/{HF_REPO}/resolve/main"
MANIFEST_CACHE_SEC = 300 # re-fetch manifest every 5 minutes
manifest_cache: dict = {} # {"data": [...], "fetched_at": float}
def init(path: str) -> None:
"""Set the autocomplete directory path. Called once during API registration."""
global autocomplete_dir # pylint: disable=global-statement
autocomplete_dir = path
def get_cached(name: str) -> dict:
"""Load a tag file, returning cached version if file hasn't changed."""
if '/' in name or '\\' in name or '..' in name:
raise HTTPException(status_code=400, detail="Invalid name")
path = os.path.join(autocomplete_dir, f"{name}.json")
if not os.path.isfile(path):
cache.pop(name, None)
# Auto-download from HF if available in manifest
try:
manifest = fetch_manifest_sync()
if any(e.get('name') == name for e in manifest):
log.info(f'Autocomplete: name="{name}" auto-download')
download_sync(name)
else:
raise HTTPException(status_code=404, detail=f"Not found: {name}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=404, detail=f"Not found: {name} ({e})") from e
stat = os.stat(path)
entry = cache.get(name)
if entry and entry['mtime'] == stat.st_mtime:
return entry
with open(path, encoding='utf-8') as f:
data = json.load(f)
entry = {
'mtime': stat.st_mtime,
'size': stat.st_size,
'meta': {
'name': data.get('name', name),
'version': data.get('version', ''),
'tag_count': len(data.get('tags', [])),
'categories': {
str(k): v.get('name', str(k)) if isinstance(v, dict) else str(v)
for k, v in data.get('categories', {}).items()
},
},
'content': data,
}
cache[name] = entry
return entry
def list_all_sync() -> list[ItemAutocomplete]:
"""Scan autocomplete directory and return metadata for each tag file."""
if not autocomplete_dir or not os.path.isdir(autocomplete_dir):
return []
items = []
for filename in sorted(os.listdir(autocomplete_dir)):
if not filename.endswith('.json') or filename.startswith('.') or filename == 'manifest.json':
continue
name = filename.rsplit('.', 1)[0]
try:
entry = get_cached(name)
meta = entry['meta']
items.append(ItemAutocomplete(
name=meta['name'],
version=meta['version'],
tag_count=meta['tag_count'],
categories=meta['categories'],
size=entry['size'],
))
except Exception:
pass
return items
async def list_all() -> list[ItemAutocomplete]:
"""List available tag autocomplete files."""
return await asyncio.to_thread(list_all_sync)
async def get_content(name: str) -> ItemAutocompleteContent:
"""Get full tag file content by name."""
def _load():
return get_cached(name)
try:
entry = await asyncio.to_thread(_load)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
content = entry['content']
return ItemAutocompleteContent(
name=content.get('name', name),
version=content.get('version', ''),
categories=content.get('categories', {}),
tags=content.get('tags', []),
)
# -- Remote management --
def fetch_manifest_sync() -> list[dict]:
"""Fetch manifest.json from HuggingFace, with caching."""
import time
import requests
now = time.time()
if manifest_cache.get('data') and now - manifest_cache.get('fetched_at', 0) < MANIFEST_CACHE_SEC:
return manifest_cache['data']
url = f"{HF_BASE}/manifest.json"
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
data = resp.json()
entries = data.get('entries', data) if isinstance(data, dict) else data
manifest_cache['data'] = entries
manifest_cache['fetched_at'] = now
return entries
except Exception as e:
log.warning(f"Autocomplete: Failed to fetch manifest: {e}")
return manifest_cache.get('data', [])
def local_names() -> set[str]:
"""Return set of locally available autocomplete file names."""
if not autocomplete_dir or not os.path.isdir(autocomplete_dir):
return set()
return {
f.rsplit('.', 1)[0]
for f in os.listdir(autocomplete_dir)
if f.endswith('.json') and not f.startswith('.') and f != 'manifest.json'
}
def local_version(name: str) -> str:
"""Return the version string of a local tag file, or empty string."""
path = os.path.join(autocomplete_dir, f"{name}.json")
if not os.path.isfile(path):
return ""
try:
entry = cache.get(name)
if entry:
return entry['meta'].get('version', '')
with open(path, encoding='utf-8') as f:
data = json.load(f)
return data.get('version', '')
except Exception:
return ""
async def list_remote() -> list[ItemAutocompleteRemote]:
"""List tag files available for download from HuggingFace."""
entries = await asyncio.to_thread(fetch_manifest_sync)
local = await asyncio.to_thread(local_names)
results = []
for e in entries:
name = e['name']
is_local = name in local
remote_version = e.get('version', '')
update = False
if is_local and remote_version:
lv = await asyncio.to_thread(local_version, name)
update = bool(lv and lv != remote_version)
results.append(ItemAutocompleteRemote(
name=name,
description=e.get('description', ''),
version=remote_version,
tag_count=e.get('tag_count', 0),
size_mb=e.get('size_mb', 0),
downloaded=is_local,
update_available=update,
))
return results
def download_sync(name: str) -> str:
"""Download a tag file from HuggingFace to the local autocomplete directory."""
import requests
if '/' in name or '\\' in name or '..' in name:
raise HTTPException(status_code=400, detail="Invalid name")
os.makedirs(autocomplete_dir, exist_ok=True)
url = f"{HF_BASE}/{name}.json"
try:
resp = requests.get(url, timeout=120, stream=True)
resp.raise_for_status()
except requests.RequestException as e:
raise HTTPException(status_code=502, detail=f"Failed to download {name}: {e}") from e
target = os.path.join(autocomplete_dir, f"{name}.json")
tmp = target + ".tmp"
size = 0
with open(tmp, 'wb') as f:
for chunk in resp.iter_content(chunk_size=1024 * 256):
f.write(chunk)
size += len(chunk)
os.replace(tmp, target)
cache.pop(name, None)
log.info(f'Autocomplete: name="{name}" url={url} ({size / 1024 / 1024:.2f}MB) downloaded')
return target
async def download(name: str):
"""Download a tag file from HuggingFace."""
await asyncio.to_thread(download_sync, name)
entry = await asyncio.to_thread(get_cached, name)
meta = entry['meta']
return ItemAutocomplete(
name=meta['name'],
version=meta['version'],
tag_count=meta['tag_count'],
categories=meta['categories'],
size=entry['size'],
)
async def delete(name: str):
"""Delete a locally downloaded tag file."""
if '/' in name or '\\' in name or '..' in name:
raise HTTPException(status_code=400, detail="Invalid name")
path = os.path.join(autocomplete_dir, f"{name}.json")
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail=f"Not found: {name}")
await asyncio.to_thread(os.remove, path)
cache.pop(name, None)
return {"status": "deleted", "name": name}
def register_api(api):
api.add_api_route("/sdapi/v1/autocomplete", list_all, methods=["GET"], response_model=list[ItemAutocomplete], tags=["Enumerators"])
api.add_api_route("/sdapi/v1/autocomplete/remote", list_remote, methods=["GET"], response_model=list[ItemAutocompleteRemote], tags=["Enumerators"])
api.add_api_route("/sdapi/v1/autocomplete/{name}", get_content, methods=["GET"], response_model=ItemAutocompleteContent, tags=["Enumerators"])
api.add_api_route("/sdapi/v1/autocomplete/{name}/download", download, methods=["POST"], response_model=ItemAutocomplete, tags=["Enumerators"])
api.add_api_route("/sdapi/v1/autocomplete/{name}", delete, methods=["DELETE"], tags=["Enumerators"])
+22
View File
@@ -509,6 +509,28 @@ class ItemLoadedModel(BaseModel):
dtype: Optional[str] = Field(default=None, title="Dtype", description="Effective data type (e.g., float16, nf4)")
extra: Optional[dict] = Field(default=None, title="Extra metadata", description="Additional metadata (role, class, quantization method, etc.)")
class ItemAutocomplete(BaseModel):
name: str = Field(title="Name", description="Autocomplete file identifier (filename without extension)")
version: str = Field(default="", title="Version", description="Version string")
tag_count: int = Field(default=0, title="Tag count", description="Number of tags")
categories: dict = Field(default_factory=dict, title="Categories", description="Category ID to display name mapping")
size: int = Field(default=0, title="Size", description="File size in bytes")
class ItemAutocompleteContent(BaseModel):
name: str = Field(title="Name", description="Autocomplete file identifier")
version: str = Field(default="", title="Version", description="Version string")
categories: dict = Field(default_factory=dict, title="Categories", description="Category definitions with name and color")
tags: list = Field(default_factory=list, title="Tags", description="Tag entries as [name, category_id, post_count] tuples")
class ItemAutocompleteRemote(BaseModel):
name: str = Field(title="Name", description="Autocomplete file identifier")
description: str = Field(default="", title="Description", description="Human-readable description")
version: str = Field(default="", title="Version", description="Version string")
tag_count: int = Field(default=0, title="Tag count", description="Number of tags")
size_mb: float = Field(default=0, title="Size (MB)", description="Approximate file size in megabytes")
downloaded: bool = Field(default=False, title="Downloaded", description="Whether available locally")
update_available: bool = Field(default=False, title="Update available", description="Whether a newer version exists remotely")
# helper function
def create_model_from_signature(func: Callable, model_name: str, base_model: type[BaseModel] = BaseModel, additional_fields: list | None = None, exclude_fields: list[str] | None = None) -> type[BaseModel]:
+1
View File
@@ -129,6 +129,7 @@ def create_paths(opts):
create_path(fix_path('styles_dir'))
create_path(fix_path('yolo_dir'))
create_path(fix_path('wildcards_dir'))
create_path(fix_path('autocomplete_dir'))
# Create resolved output paths (base + specific)
base_samples = opts.data.get('outdir_samples', '')
+9
View File
@@ -389,6 +389,7 @@ def create_settings(cmd_opts):
"lora_dir": OptionInfo(os.path.join(paths.models_path, 'Lora'), "Folder with LoRA network(s)", folder=True),
"styles_dir": OptionInfo(os.path.join(paths.models_path, 'styles'), "File or Folder with user-defined styles", folder=True),
"wildcards_dir": OptionInfo(os.path.join(paths.models_path, 'wildcards'), "Folder with user-defined wildcards", folder=True),
"autocomplete_dir": OptionInfo(os.path.join(paths.models_path, 'autocomplete'), "Folder with tag autocomplete files", folder=True),
"embeddings_dir": OptionInfo(os.path.join(paths.models_path, 'embeddings'), "Folder with textual inversion embeddings", folder=True),
"control_dir": OptionInfo(os.path.join(paths.models_path, 'control'), "Folder with Control models", folder=True),
"yolo_dir": OptionInfo(os.path.join(paths.models_path, 'yolo'), "Folder with Yolo models", folder=True),
@@ -657,6 +658,14 @@ def create_settings(cmd_opts):
"disabled_extensions": OptionInfo([], "Disable these extensions", gr.Textbox, {"visible": False}),
"sd_checkpoint_hash": OptionInfo("", "SHA256 hash of the current checkpoint", gr.Textbox, {"visible": False}),
"tooltips": OptionInfo("UI Tooltips", "UI tooltips", gr.Radio, {"choices": ["None", "Browser default", "UI tooltips"], "visible": False}),
# Autocomplete settings (controlled via Tag Autocomplete script UI)
"autocomplete_active": OptionInfo(False, "Enable Autocomplete", gr.Checkbox, {"visible": False}),
"autocomplete_enabled": OptionInfo([], "Enabled tag autocomplete files", gr.Dropdown, {"multiselect": True, "choices": [], "visible": False}),
"autocomplete_min_chars": OptionInfo(3, "Min autocomplete chars", gr.Slider, {"minimum": 2, "maximum": 6, "step": 1, "visible": False}),
"autocomplete_replace_underscores": OptionInfo(True, "Replace underscores in autocomplete", gr.Checkbox, {"visible": False}),
"autocomplete_append_comma": OptionInfo(True, "Append comma after autocomplete", gr.Checkbox, {"visible": False}),
# Caption settings (controlled via Caption Tab UI)
"caption_default_type": OptionInfo("VLM", "Default caption type", gr.Radio, {"choices": ["VLM", "OpenCLiP", "Tagger"], "visible": False}),
"tagger_show_scores": OptionInfo(False, "Tagger: show confidence scores in results", gr.Checkbox, {"visible": False}),
+193
View File
@@ -0,0 +1,193 @@
"""Always-on script providing tag autocomplete dictionary management UI."""
import json
import gradio as gr
from modules import shared, scripts_manager
from modules.api import autocomplete as ac_api
from modules.ui_components import ToolButton
import modules.ui_symbols as symbols
from modules.logger import log
def get_all_names():
"""Merge local file names with cached remote manifest names."""
local = ac_api.local_names()
remote = set()
cached = ac_api.manifest_cache.get('data')
if cached:
remote = {e['name'] for e in cached if 'name' in e}
return sorted(local | remote)
def get_config_json():
"""Serialize autocomplete opts for the JS config bridge."""
return json.dumps({
"autocomplete_active": bool(shared.opts.data.get('autocomplete_active', False)),
"autocomplete_enabled": list(shared.opts.data.get('autocomplete_enabled', [])),
"autocomplete_min_chars": shared.opts.data.get('autocomplete_min_chars', 3),
"autocomplete_replace_underscores": shared.opts.data.get('autocomplete_replace_underscores', True),
"autocomplete_append_comma": shared.opts.data.get('autocomplete_append_comma', True),
})
def on_active_change(value):
shared.opts.data['autocomplete_active'] = bool(value)
shared.opts.save(silent=True)
return get_config_json(), ""
def on_enabled_change(selected):
shared.opts.data['autocomplete_enabled'] = list(selected)
shared.opts.save(silent=True)
return get_config_json(), ""
def on_min_chars_change(value):
shared.opts.data['autocomplete_min_chars'] = int(value)
shared.opts.save(silent=True)
return get_config_json()
def on_replace_underscores_change(value):
shared.opts.data['autocomplete_replace_underscores'] = bool(value)
shared.opts.save(silent=True)
return get_config_json()
def on_append_comma_change(value):
shared.opts.data['autocomplete_append_comma'] = bool(value)
shared.opts.save(silent=True)
return get_config_json()
def format_status(local, remote_entries, fetch_ok):
"""Build status HTML showing available dictionaries."""
lines = []
remote_names = set()
for e in remote_entries:
name = e.get('name', '')
remote_names.add(name)
dl_status = '' if name in local else symbols.save
desc = e.get('description', '')
# size = e.get('size_mb', 0)
tags = e.get('tag_count', 0)
lines.append(f"<b>{name}</b> | {desc} | {tags:,} tags {dl_status}")
for name in sorted(local - remote_names):
lines.append(f"<b>{name}</b>")
if not fetch_ok:
lines.insert(0, "<i>Remote fetch failed; showing local files only</i>")
elif not lines:
lines.append("No dictionaries found")
return "<br>".join(lines)
def on_refresh():
"""Fetch remote manifest and update dropdown choices."""
try:
ac_api.manifest_cache.pop('fetched_at', None) # force re-fetch by expiring cache
ac_api.fetch_manifest_sync()
fetch_ok = bool(ac_api.manifest_cache.get('fetched_at'))
names = get_all_names()
current = list(shared.opts.data.get('autocomplete_enabled', []))
local = ac_api.local_names()
remote_entries = ac_api.manifest_cache.get('data', [])
msg = format_status(local, remote_entries, fetch_ok)
return gr.update(choices=names, value=current), msg
except Exception as e:
log.warning(f"Autocomplete refresh: {e}")
return gr.update(), f"Refresh failed: {e}"
def on_update(selected):
"""Re-download enabled dictionaries if remote version is newer."""
if not selected:
return "No dictionaries enabled"
try:
entries = ac_api.fetch_manifest_sync()
except Exception as e:
return f"Failed to fetch manifest: {e}"
updated = []
for name in selected:
remote_entry = next((e for e in entries if e.get('name') == name), None)
if not remote_entry:
continue
remote_ver = remote_entry.get('version', '')
local_ver = ac_api.local_version(name)
if not local_ver or (remote_ver and local_ver != remote_ver):
try:
ac_api.download_sync(name)
updated.append(name)
except Exception as e:
log.warning(f"Autocomplete update {name}: {e}")
if updated:
return f"Updated: {', '.join(updated)}"
return "All dictionaries are up to date"
class AutocompleteScript(scripts_manager.Script):
def show(self, is_img2img):
return scripts_manager.AlwaysVisible
def title(self):
return "Tag Autocomplete"
def ui(self, is_img2img):
initial_names = get_all_names()
initial_enabled = list(shared.opts.data.get('autocomplete_enabled', []))
with gr.Accordion('Tag Autocomplete', open=False, elem_id='autocomplete_settings'):
with gr.Row():
active_cb = gr.Checkbox(
label="Enable Autocomplete",
value=bool(shared.opts.data.get('autocomplete_active', False)),
elem_id=self.elem_id("active"),
)
with gr.Row():
enabled_dd = gr.Dropdown(
label="Active dictionaries",
multiselect=True,
choices=initial_names,
value=initial_enabled,
interactive=True,
elem_id=self.elem_id("enabled"),
)
refresh_btn = ToolButton(value=symbols.refresh, elem_id=self.elem_id("refresh"))
update_btn = ToolButton(value=symbols.save, elem_id=self.elem_id("update"))
with gr.Row():
replace_underscores = gr.Checkbox(
label="Replace underscores",
value=shared.opts.data.get('autocomplete_replace_underscores', True),
elem_id=self.elem_id("replace_underscores"),
)
append_comma = gr.Checkbox(
label="Comma separator",
value=shared.opts.data.get('autocomplete_append_comma', True),
elem_id=self.elem_id("append_comma"),
)
min_chars = gr.Slider(
label="Min characters",
minimum=2, maximum=6, step=1,
value=shared.opts.data.get('autocomplete_min_chars', 3),
elem_id=self.elem_id("min_chars"),
)
with gr.Row():
status = gr.HTML(value="", elem_id=self.elem_id("status"))
config_json = gr.Textbox(
value=get_config_json,
visible=False,
elem_id=self.elem_id("config_json"),
)
active_cb.change(fn=on_active_change, inputs=[active_cb], outputs=[config_json, status])
enabled_dd.change(fn=on_enabled_change, inputs=[enabled_dd], outputs=[config_json, status])
min_chars.change(fn=on_min_chars_change, inputs=[min_chars], outputs=[config_json])
replace_underscores.change(fn=on_replace_underscores_change, inputs=[replace_underscores], outputs=[config_json])
append_comma.change(fn=on_append_comma_change, inputs=[append_comma], outputs=[config_json])
refresh_btn.click(fn=on_refresh, inputs=[], outputs=[enabled_dd, status])
update_btn.click(fn=on_update, inputs=[enabled_dd], outputs=[status])
for comp in [enabled_dd, min_chars, replace_underscores, append_comma, config_json, status]:
comp.do_not_save_to_config = True
return [active_cb, enabled_dd, min_chars, replace_underscores, append_comma, config_json]