mirror of
https://github.com/vladmandic/automatic
synced 2026-08-26 15:16:01 +02:00
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
This commit is contained in:
+8
-1
@@ -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"},
|
||||
@@ -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.<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 +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.<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 +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.<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 +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.<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"},
|
||||
|
||||
+33
-10
@@ -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());
|
||||
}
|
||||
|
||||
+11
-5
@@ -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;
|
||||
|
||||
@@ -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("<h2>Wildcards</h2>", "", gr.HTML),
|
||||
"wildcards_enabled": OptionInfo(True, "Enable file wildcards support"),
|
||||
|
||||
"extra_networks_autocomplete_sep": OptionInfo("<h2>Tag Autocomplete</h2>", "", 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}),
|
||||
|
||||
@@ -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"<b>{name}</b> - {desc} ({tags:,} tags, {size:.1f} MB) [{dl_status}]")
|
||||
for name in sorted(local - remote_names):
|
||||
lines.append(f"<b>{name}</b> [local]")
|
||||
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():
|
||||
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]
|
||||
Reference in New Issue
Block a user