From 3195bd6287554cdb8671765e3d3657bf7e65fc31 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Thu, 9 Apr 2026 02:00:45 +0100 Subject: [PATCH] feat(autocomplete): move settings to always-on script UI - create scripts/autocomplete.py as always-on script with dictionary management UI (dropdown, refresh, update, settings controls) - move autocomplete settings from Settings > Extra Networks to hidden options, controlled via script UI with JS config bridge - remove early exit in autocomplete.js when no dictionaries enabled, allowing dictionaries enabled via script UI to work without reload - add id-based suffix matching to setHints.js for unique button hints - add locale_en.json entries with tooltips for all autocomplete controls --- html/locale_en.json | 9 +- javascript/autocomplete.js | 43 ++++++--- javascript/setHints.js | 16 ++-- modules/ui_definitions.py | 24 ++--- scripts/autocomplete.py | 179 +++++++++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 scripts/autocomplete.py diff --git a/html/locale_en.json b/html/locale_en.json index 88a3d6203..5658fb4b6 100644 --- a/html/locale_en.json +++ b/html/locale_en.json @@ -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.
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"}, @@ -315,6 +318,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.
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 ","localized":"","hint":"","ui":"settings_legacy_options"}, @@ -941,6 +945,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.
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 +1234,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.
For example, long_hair appears as long hair.","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 +1442,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.
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"}, diff --git a/javascript/autocomplete.js b/javascript/autocomplete.js index d475df130..6466d129b 100644 --- a/javascript/autocomplete.js +++ b/javascript/autocomplete.js @@ -457,15 +457,39 @@ const PROMPT_IDS = [ 'video_prompt', 'video_neg_prompt', ]; +// -- 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; + log('autocomplete', 'config updated via bridge'); + executeCallbacks(optionsChangedCallbacks); + } catch { /* ignore parse errors */ } + } + }, + get() { return proto.get.call(textarea); }, + }); + } +} + // -- Initialization -- async function initAutocomplete() { const enabled = window.opts?.autocomplete_enabled || []; - if (!enabled.length) { - log('autocomplete', 'no dictionaries enabled'); - return; - } - log('autocomplete', `init: ${enabled.join(', ')}`); + log('autocomplete', enabled.length ? `init: ${enabled.join(', ')}` : 'init: no dictionaries enabled yet'); // Inject styles (CSS files in javascript/ are not auto-loaded) const style = document.createElement('style'); style.textContent = [ @@ -489,11 +513,7 @@ async function initAutocomplete() { document.head.appendChild(style); dropdown.init(); await engine.loadEnabled(); - if (engine.indices.size === 0) { - log('autocomplete', 'no dictionaries loaded'); - return; - } - // Attach to all prompt textareas + // 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`); @@ -513,4 +533,7 @@ async function initAutocomplete() { await engine.loadEnabled(); } }); + // Watch for config updates from the script UI bridge + patchConfigBridge(); + onAfterUiUpdate(() => patchConfigBridge()); } diff --git a/javascript/setHints.js b/javascript/setHints.js index 8ae428ae8..b35fd0eb3 100644 --- a/javascript/setHints.js +++ b/javascript/setHints.js @@ -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; diff --git a/modules/ui_definitions.py b/modules/ui_definitions.py index b8a979106..f229889cf 100644 --- a/modules/ui_definitions.py +++ b/modules/ui_definitions.py @@ -51,19 +51,6 @@ def get_openvino_device_list(): return [] -def list_autocomplete_names(): - """Return list of available tag autocomplete file names from local files.""" - from modules import shared - from modules.files_cache import list_files - autocomplete_dir = getattr(shared.opts, 'autocomplete_dir', None) or os.path.join(paths.models_path, 'autocomplete') - names = set() - for fp in list_files(autocomplete_dir, ext_filter=['.json'], recursive=False): - name = os.path.splitext(os.path.basename(fp))[0] - if name and name != 'manifest' and not name.startswith('.'): - names.add(name) - return sorted(names) - - def create_settings(cmd_opts): # Calculate default modes @@ -651,12 +638,6 @@ def create_settings(cmd_opts): "extra_networks_wildcard_sep": OptionInfo("

Wildcards

", "", gr.HTML), "wildcards_enabled": OptionInfo(True, "Enable file wildcards support"), - - "extra_networks_autocomplete_sep": OptionInfo("

Tag Autocomplete

", "", gr.HTML), - "autocomplete_enabled": OptionInfo([], "Enabled tag autocomplete files", gr.Dropdown, lambda: {"multiselect": True, "choices": list_autocomplete_names()}), - "autocomplete_min_chars": OptionInfo(3, "Minimum characters before autocomplete triggers", gr.Slider, {"minimum": 2, "maximum": 6, "step": 1}), - "autocomplete_replace_underscores": OptionInfo(True, "Replace underscores with spaces in autocomplete results"), - "autocomplete_append_comma": OptionInfo(True, "Automatically add comma separator between tags"), })) # --- Extensions --- @@ -675,6 +656,11 @@ 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_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}), diff --git a/scripts/autocomplete.py b/scripts/autocomplete.py new file mode 100644 index 000000000..f1e50e865 --- /dev/null +++ b/scripts/autocomplete.py @@ -0,0 +1,179 @@ +"""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_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_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 = 'local' if name in local else 'available' + desc = e.get('description', '') + size = e.get('size_mb', 0) + tags = e.get('tag_count', 0) + lines.append(f"{name} - {desc} ({tags:,} tags, {size:.1f} MB) [{dl_status}]") + for name in sorted(local - remote_names): + lines.append(f"{name} [local]") + if not fetch_ok: + lines.insert(0, "Remote fetch failed; showing local files only") + elif not lines: + lines.append("No dictionaries found") + return "
".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(): + 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(): + 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"), + ) + 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"), + ) + 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"), + ) + + 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 [enabled_dd, min_chars, replace_underscores, append_comma, config_json]