mirror of
https://github.com/vladmandic/automatic
synced 2026-09-13 10:08:43 +02:00
feat(autocomplete): aliases, extra-networks, translations, baseline fixes
- tag aliases (optional 4-tuple) and translations (companion file) - lora and wildcard triggers; embeddings fold into tag-mode results - new GET /sdapi/v1/wildcards endpoint - LRU cap, IME safety, multi-textarea safety, modifier-key pass-through - manifest-retry and config-bridge hardening - tags-fetch: alias/translation harvesters with authenticated rate limits
This commit is contained in:
+244
-44
@@ -42,6 +42,14 @@ const CATEGORY_NAMES = {
|
||||
13: 'color',
|
||||
};
|
||||
|
||||
// Glyph + color per result kind. Renders in place of the category dot for non-tag results.
|
||||
const KIND_GLYPHS = {
|
||||
tag: { glyph: '●', color: null }, // color pulled from tag category
|
||||
lora: { glyph: '◆', color: '#8a66ff' },
|
||||
embed: { glyph: '▲', color: '#1abc9c' },
|
||||
wildcard: { glyph: '★', color: '#f1c40f' },
|
||||
};
|
||||
|
||||
let active = false;
|
||||
|
||||
// -- Utilities (ported from Enso) --
|
||||
@@ -103,34 +111,98 @@ function caretViewportY(textarea) {
|
||||
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]) => ({
|
||||
// Tuples are [name, catId, count] or [name, catId, count, aliases]. Default `aliases = []`
|
||||
// keeps legacy 3-tuple dictionaries working unchanged.
|
||||
this.tags = data.tags.map(([name, category, count, aliases = []]) => ({
|
||||
name: name.toLowerCase(),
|
||||
display: name,
|
||||
category,
|
||||
count,
|
||||
aliases,
|
||||
}));
|
||||
this.tags.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Alias index parallel to this.tags. Each entry has .name so lowerBound works on both.
|
||||
this.aliasEntries = [];
|
||||
for (const tag of this.tags) {
|
||||
if (!tag.aliases || tag.aliases.length === 0) continue;
|
||||
for (const alias of tag.aliases) {
|
||||
this.aliasEntries.push({ name: alias.toLowerCase(), display: alias, tag });
|
||||
}
|
||||
}
|
||||
this.aliasEntries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Optional translations companion: foreign_term -> canonical_tag_name.
|
||||
// tagByName is keyed on canonical lowercased name for O(1) resolution from a translation hit.
|
||||
this.translations = new Map();
|
||||
this.tagByName = new Map(this.tags.map((t) => [t.name, t]));
|
||||
if (data.translations && typeof data.translations === 'object') {
|
||||
for (const [foreign, canonical] of Object.entries(data.translations)) {
|
||||
if (typeof foreign !== 'string' || typeof canonical !== 'string') continue;
|
||||
this.translations.set(foreign.toLowerCase(), { canonical: canonical.toLowerCase(), foreign });
|
||||
}
|
||||
}
|
||||
// Sorted translation keys for prefix+substring scan via lowerBound.
|
||||
this.translationEntries = [...this.translations.entries()]
|
||||
.map(([foreignLower, { canonical, foreign }]) => ({ name: foreignLower, foreign, canonical }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Prefix search with binary search. Returns matches sorted by count descending. */
|
||||
/** Prefix search with binary search across canonical names and aliases. 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);
|
||||
// Canonical prefix matches
|
||||
const matches = [];
|
||||
const start = lowerBound(this.tags, query);
|
||||
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
|
||||
// Alias prefix matches. Annotate so render can show "canonical (alias)".
|
||||
const aliasStart = lowerBound(this.aliasEntries, query);
|
||||
for (let i = aliasStart; i < this.aliasEntries.length && matches.length < limit * 10; i++) {
|
||||
const entry = this.aliasEntries[i];
|
||||
if (!entry.name.startsWith(query)) break;
|
||||
matches.push({ ...entry.tag, matchedVia: 'alias', matchedAlias: entry.display });
|
||||
}
|
||||
// Substring fallback (canonical + aliases) for 4+ char queries when prefix matching returned 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]);
|
||||
}
|
||||
for (let i = 0; i < this.aliasEntries.length && matches.length < limit * 10; i++) {
|
||||
const entry = this.aliasEntries[i];
|
||||
if (entry.name.includes(query)) matches.push({ ...entry.tag, matchedVia: 'alias', matchedAlias: entry.display });
|
||||
}
|
||||
}
|
||||
matches.sort((a, b) => b.count - a.count);
|
||||
return matches.slice(0, limit);
|
||||
// Translation lookup. Prefix scan over foreign terms, resolving to canonical tags when present.
|
||||
if (this.translationEntries.length > 0) {
|
||||
const tStart = lowerBound(this.translationEntries, query);
|
||||
for (let i = tStart; i < this.translationEntries.length && matches.length < limit * 10; i++) {
|
||||
const entry = this.translationEntries[i];
|
||||
if (!entry.name.startsWith(query)) break;
|
||||
const canonicalTag = this.tagByName.get(entry.canonical);
|
||||
if (canonicalTag) matches.push({ ...canonicalTag, matchedVia: 'translation', matchedTerm: entry.foreign });
|
||||
}
|
||||
// Substring fallback over translation keys (CJK/short foreign terms benefit from 2-char threshold)
|
||||
if (query.length >= 2) {
|
||||
for (let i = 0; i < this.translationEntries.length && matches.length < limit * 10; i++) {
|
||||
const entry = this.translationEntries[i];
|
||||
if (entry.name.includes(query) && !entry.name.startsWith(query)) {
|
||||
const canonicalTag = this.tagByName.get(entry.canonical);
|
||||
if (canonicalTag) matches.push({ ...canonicalTag, matchedVia: 'translation', matchedTerm: entry.foreign });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dedupe by canonical name; prefer canonical (no matchedVia) over alias/translation matches.
|
||||
const seen = new Map();
|
||||
for (const tag of matches) {
|
||||
const existing = seen.get(tag.name);
|
||||
if (!existing || (existing.matchedVia && !tag.matchedVia)) seen.set(tag.name, tag);
|
||||
}
|
||||
const result = [...seen.values()];
|
||||
result.sort((a, b) => b.count - a.count);
|
||||
return result.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,47 +266,100 @@ const engine = {
|
||||
|
||||
// -- Textarea integration --
|
||||
|
||||
/** Extract the current word being typed at the cursor position. */
|
||||
/**
|
||||
* Extract the current completion context at the cursor position.
|
||||
*
|
||||
* Returns { word, start, end, mode } where:
|
||||
* mode === 'tag': ordinary tag completion
|
||||
* mode === 'lora': inside an unclosed `<lora:...` span; `start` points at the `<`
|
||||
* mode === 'wildcard': inside an unclosed `__...` span; `start` points at the first `_`
|
||||
*
|
||||
* `start..end` is the replacement range the appropriate insert function should overwrite.
|
||||
* Embeddings are served under `mode === 'tag'` and merged into tag-mode results by the engine.
|
||||
*/
|
||||
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];
|
||||
// Scan backward from cursor to the nearest hard separator
|
||||
let wordStart = selectionStart;
|
||||
while (wordStart > 0) {
|
||||
const ch = value[wordStart - 1];
|
||||
if (ch === ',' || ch === '\n') break;
|
||||
start--;
|
||||
wordStart--;
|
||||
}
|
||||
// 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)
|
||||
// Skip leading whitespace between the separator and the typed word
|
||||
while (wordStart < selectionStart && value[wordStart] === ' ') wordStart++;
|
||||
const segment = value.slice(wordStart, selectionStart);
|
||||
// LoRA / extra-network trigger: unclosed `<` with `kind:` prefix
|
||||
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 };
|
||||
if (lastOpen > lastClose && lastOpen >= wordStart) {
|
||||
const inside = before.slice(lastOpen + 1); // e.g. "lora:foo" or "lora:" or "lor"
|
||||
const colon = inside.indexOf(':');
|
||||
// Require `<lora:`; before the colon the kind is ambiguous (could be lora/embed/hypernet).
|
||||
if (colon >= 0 && inside.slice(0, colon).toLowerCase() === 'lora') {
|
||||
return { word: inside.slice(colon + 1), start: lastOpen, end: selectionStart, mode: 'lora' };
|
||||
}
|
||||
// Inside `<...` but not yet a recognized kind, suppress completion.
|
||||
return null;
|
||||
}
|
||||
// Wildcard trigger: unclosed `__` that doesn't close within the current word
|
||||
if (segment.startsWith('__') && !segment.slice(2).includes('__')) {
|
||||
return { word: segment.slice(2), start: wordStart, end: selectionStart, mode: 'wildcard' };
|
||||
}
|
||||
// Ordinary tag
|
||||
if (!segment) return null;
|
||||
return { word: segment, start: wordStart, end: selectionStart, mode: 'tag' };
|
||||
}
|
||||
|
||||
/** Escape bare parens so tag names like `fate_(series)` aren't parsed as attention syntax. */
|
||||
function escapeParensForPrompt(name) {
|
||||
return name.replace(/([()])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an extra-network reference at the current trigger position.
|
||||
* kind === 'lora': inserts `<lora:name:1.0>` over the range including the leading `<`
|
||||
* kind === 'wildcard': inserts `__name__` over the range including the leading `__`
|
||||
* Embeddings use insertTag directly so they go through comma-separator and paren-escape logic.
|
||||
*/
|
||||
function insertExtraNetwork(textarea, item, kind) {
|
||||
const info = getCurrentWord(textarea);
|
||||
if (!info || info.mode !== kind) return;
|
||||
const { value } = textarea;
|
||||
const before = value.slice(0, info.start);
|
||||
const after = value.slice(info.end);
|
||||
let insertion;
|
||||
if (kind === 'lora') {
|
||||
insertion = `<lora:${item.display ?? item.name}:1.0>`;
|
||||
} else if (kind === 'wildcard') {
|
||||
insertion = `__${item.display ?? item.name}__`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
textarea.value = before + insertion + after;
|
||||
const cursorPos = before.length + insertion.length;
|
||||
textarea.selectionStart = cursorPos;
|
||||
textarea.selectionEnd = cursorPos;
|
||||
if (typeof updateInput === 'function') updateInput(textarea);
|
||||
}
|
||||
|
||||
/** Insert a tag at the current word position, replacing the typed prefix. */
|
||||
function insertTag(textarea, tagName) {
|
||||
const info = getCurrentWord(textarea);
|
||||
if (!info) return;
|
||||
if (!info || info.mode !== 'tag') return;
|
||||
const { value } = textarea;
|
||||
const before = value.slice(0, info.start);
|
||||
const after = value.slice(info.end);
|
||||
// Build insertion: tag + separator
|
||||
// Build insertion: tag + separator. Parens in tag names are escaped so the prompt parser doesn't read them as attention syntax.
|
||||
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}`;
|
||||
const insertion = `${prefix}${escapeParensForPrompt(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;
|
||||
@@ -280,10 +405,9 @@ const dropdown = {
|
||||
|
||||
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);
|
||||
}
|
||||
// Switching textareas: clear prior state so a stale render can't leak across.
|
||||
if (this.textarea && this.textarea !== textarea) this.hide();
|
||||
if (this.textarea !== textarea) this.resizeObserver.observe(textarea);
|
||||
this.results = results;
|
||||
this.textarea = textarea;
|
||||
this.query = query || '';
|
||||
@@ -312,24 +436,50 @@ const dropdown = {
|
||||
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 kind = tag.kind || 'tag';
|
||||
const kindStyle = KIND_GLYPHS[kind] || KIND_GLYPHS.tag;
|
||||
dot.style.color = kindStyle.color || engine.categoryColors[tag.category] || '#888';
|
||||
dot.textContent = kindStyle.glyph;
|
||||
dot.title = kind === 'tag' ? (engine.categoryNames[tag.category] || '') : kind;
|
||||
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 canonicalMatch = tag.name.indexOf(queryNorm);
|
||||
if (canonicalMatch >= 0 && queryNorm.length > 0) {
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = tagText.slice(matchPos, matchPos + queryNorm.length);
|
||||
mark.textContent = tagText.slice(canonicalMatch, canonicalMatch + queryNorm.length);
|
||||
name.append(
|
||||
document.createTextNode(tagText.slice(0, matchPos)),
|
||||
document.createTextNode(tagText.slice(0, canonicalMatch)),
|
||||
mark,
|
||||
document.createTextNode(tagText.slice(matchPos + queryNorm.length)),
|
||||
document.createTextNode(tagText.slice(canonicalMatch + queryNorm.length)),
|
||||
);
|
||||
} else {
|
||||
name.textContent = tagText;
|
||||
}
|
||||
// Alias/translation-matched rows append " (foreign)" with the query fragment highlighted.
|
||||
let annotationTerm = null;
|
||||
if (tag.matchedVia === 'alias') annotationTerm = tag.matchedAlias;
|
||||
else if (tag.matchedVia === 'translation') annotationTerm = tag.matchedTerm;
|
||||
if (annotationTerm) {
|
||||
const annotationDisplay = replaceUnderscores ? annotationTerm.replace(/_/g, ' ') : annotationTerm;
|
||||
const annotationLower = annotationTerm.toLowerCase();
|
||||
const annotationMatch = annotationLower.indexOf(queryNorm);
|
||||
const prefix = tag.matchedVia === 'translation' ? ' \u{1F310} ' : ' (';
|
||||
const suffix = tag.matchedVia === 'translation' ? '' : ')';
|
||||
name.appendChild(document.createTextNode(prefix));
|
||||
if (annotationMatch >= 0 && queryNorm.length > 0) {
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = annotationDisplay.slice(annotationMatch, annotationMatch + queryNorm.length);
|
||||
name.append(
|
||||
document.createTextNode(annotationDisplay.slice(0, annotationMatch)),
|
||||
mark,
|
||||
document.createTextNode(annotationDisplay.slice(annotationMatch + queryNorm.length)),
|
||||
);
|
||||
} else {
|
||||
name.appendChild(document.createTextNode(annotationDisplay));
|
||||
}
|
||||
if (suffix) name.appendChild(document.createTextNode(suffix));
|
||||
}
|
||||
const count = document.createElement('span');
|
||||
count.className = 'autocomplete-count';
|
||||
count.textContent = tag.count > 0 ? formatCount(tag.count) : '';
|
||||
@@ -386,8 +536,15 @@ const dropdown = {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tag = this.results[this.selectedIndex];
|
||||
if (this.textarea) insertTag(this.textarea, tag.display);
|
||||
const result = this.results[this.selectedIndex];
|
||||
if (this.textarea) {
|
||||
if (result.kind === 'lora' || result.kind === 'wildcard') {
|
||||
insertExtraNetwork(this.textarea, result, result.kind);
|
||||
} else {
|
||||
// 'embed' kind and untagged tag results both go through insertTag (comma-aware, paren-escaped).
|
||||
insertTag(this.textarea, result.display ?? result.name);
|
||||
}
|
||||
}
|
||||
this.hide();
|
||||
},
|
||||
};
|
||||
@@ -398,33 +555,58 @@ let debounceTimer = null;
|
||||
|
||||
function onInput(textarea) {
|
||||
if (!active) return;
|
||||
// IME candidate window open: value isn't committed, and Enter would race with tag accept.
|
||||
if (textarea.dataset.imeActive === '1') return;
|
||||
const minChars = window.opts?.autocomplete_min_chars ?? 3;
|
||||
const info = getCurrentWord(textarea);
|
||||
if (!info || info.word.length < minChars) {
|
||||
if (!info) {
|
||||
dropdown.hide();
|
||||
return;
|
||||
}
|
||||
// Extra-network triggers have a zero threshold so `<lora:` alone surfaces results.
|
||||
const threshold = info.mode === 'tag' ? minChars : 0;
|
||||
if (info.word.length < threshold) {
|
||||
dropdown.hide();
|
||||
return;
|
||||
}
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
const results = engine.searchAll(info.word);
|
||||
let results;
|
||||
if (info.mode === 'lora') {
|
||||
results = window.autocompleteXn ? window.autocompleteXn.searchLoras(info.word) : [];
|
||||
} else if (info.mode === 'wildcard') {
|
||||
results = window.autocompleteXn ? window.autocompleteXn.searchWildcards(info.word) : [];
|
||||
} else {
|
||||
const tagResults = engine.searchAll(info.word);
|
||||
const embedResults = window.autocompleteXn ? window.autocompleteXn.searchEmbeddings(info.word) : [];
|
||||
// Embeddings fold into tag-mode results (a1111 tagcomplete parity).
|
||||
results = [...embedResults, ...tagResults];
|
||||
}
|
||||
dropdown.show(results, textarea, info.word);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (!dropdown.visible) return;
|
||||
if (e.isComposing) return; // IME candidate selection, let the browser commit the candidate
|
||||
// Modifier + nav/accept keys belong to other handlers (editAttention.js on Ctrl+Arrow,
|
||||
// generate hotkey on Ctrl+Enter). Let them through even with the dropdown open.
|
||||
const hasModifier = e.ctrlKey || e.metaKey || e.altKey;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
if (hasModifier) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropdown.navigate(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (hasModifier) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropdown.navigate(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (hasModifier) return;
|
||||
if (dropdown.selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -432,6 +614,7 @@ function onKeyDown(e) {
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
if (hasModifier) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropdown.accept();
|
||||
@@ -450,7 +633,14 @@ function onKeyDown(e) {
|
||||
function attachAutocomplete(textarea) {
|
||||
textarea.addEventListener('input', () => onInput(textarea));
|
||||
textarea.addEventListener('keydown', onKeyDown);
|
||||
textarea.addEventListener('compositionstart', () => { textarea.dataset.imeActive = '1'; });
|
||||
textarea.addEventListener('compositionend', () => { delete textarea.dataset.imeActive; });
|
||||
textarea.addEventListener('focusin', () => {
|
||||
if (dropdown.visible && dropdown.textarea && dropdown.textarea !== textarea) dropdown.hide();
|
||||
});
|
||||
textarea.addEventListener('focusout', () => {
|
||||
// Cancel any in-flight debounced dropdown.show; otherwise it fires against a stale textarea.
|
||||
clearTimeout(debounceTimer);
|
||||
setTimeout(() => dropdown.hide(), 200);
|
||||
});
|
||||
}
|
||||
@@ -484,13 +674,21 @@ function patchActiveButton() {
|
||||
// -- Config bridge --
|
||||
|
||||
/** Monkey-patch script config bridge textboxes to push autocomplete config changes to window.opts immediately. */
|
||||
let bridgeWarnedMissingDescriptor = false;
|
||||
function patchConfigBridge() {
|
||||
const proto = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
||||
if (!proto?.get || !proto?.set) {
|
||||
if (!bridgeWarnedMissingDescriptor) {
|
||||
log('autoComplete', { bridge: 'skipped', reason: 'HTMLTextAreaElement.prototype.value descriptor missing' });
|
||||
bridgeWarnedMissingDescriptor = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -500,7 +698,7 @@ function patchConfigBridge() {
|
||||
const cfg = JSON.parse(newValue);
|
||||
for (const [key, val] of Object.entries(cfg)) window.opts[key] = val;
|
||||
executeCallbacks(optionsChangedCallbacks);
|
||||
} catch { /* ignore parse errors */ }
|
||||
} catch { /* ignore parse errors; the bridge is best-effort */ }
|
||||
}
|
||||
},
|
||||
get() { return proto.get.call(textarea); },
|
||||
@@ -538,6 +736,7 @@ async function initAutocomplete() {
|
||||
document.head.appendChild(style);
|
||||
dropdown.init();
|
||||
await engine.loadEnabled();
|
||||
if (window.autocompleteXn) window.autocompleteXn.loadAll();
|
||||
// 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) => {
|
||||
@@ -560,6 +759,7 @@ async function initAutocomplete() {
|
||||
active = newActive;
|
||||
patchActiveButton();
|
||||
}
|
||||
if (window.autocompleteXn) window.autocompleteXn.loadAll();
|
||||
}
|
||||
onOptionsChanged(optionsChangedCallback);
|
||||
// Watch for config updates from the script UI bridge
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Extra-networks completion for SD.Next prompt textareas.
|
||||
*
|
||||
* Companion to autocomplete.js: exposes sorted indices for LoRAs, embeddings, and wildcards,
|
||||
* each backed by an existing enumeration endpoint. Dispatch and insertion are driven from
|
||||
* autocomplete.js via the mode returned by getCurrentWord().
|
||||
*
|
||||
* This file relies on globals declared in autocomplete.js (lowerBound, log, engine).
|
||||
*/
|
||||
|
||||
/* global lowerBound */
|
||||
|
||||
// -- Indices --
|
||||
|
||||
class XnIndex {
|
||||
constructor(items) {
|
||||
// items: [{ name, display }]. Sorted in-place by lowercase name.
|
||||
this.items = items.map(({ name, display }) => ({
|
||||
name: String(name).toLowerCase(),
|
||||
display: display ?? name,
|
||||
}));
|
||||
this.items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
search(prefix, limit = 20) {
|
||||
const query = String(prefix).toLowerCase();
|
||||
if (!query) return [];
|
||||
const start = lowerBound(this.items, query);
|
||||
const matches = [];
|
||||
for (let i = start; i < this.items.length && matches.length < limit; i++) {
|
||||
if (!this.items[i].name.startsWith(query)) break;
|
||||
matches.push(this.items[i]);
|
||||
}
|
||||
// Substring fallback for 3+ char queries (extra-network names are usually short)
|
||||
if (matches.length === 0 && query.length >= 3) {
|
||||
for (let i = 0; i < this.items.length && matches.length < limit; i++) {
|
||||
if (this.items[i].name.includes(query)) matches.push(this.items[i]);
|
||||
}
|
||||
}
|
||||
return matches.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Engine --
|
||||
|
||||
const xnEngine = {
|
||||
lora: new XnIndex([]),
|
||||
embed: new XnIndex([]),
|
||||
wildcard: new XnIndex([]),
|
||||
|
||||
async fetchJson(path) {
|
||||
try {
|
||||
const resp = await fetch(`${window.api}${path}`, { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error(`${resp.status}`);
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
log('autoComplete', { xnFetchFailed: path, error: String(e) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
// LoRAs: [{name, alias, path, metadata}, ...]
|
||||
const loraData = await this.fetchJson('/loras');
|
||||
if (Array.isArray(loraData)) {
|
||||
const items = [];
|
||||
for (const lo of loraData) {
|
||||
if (lo?.name) items.push({ name: lo.name });
|
||||
if (lo?.alias && lo.alias !== lo.name) items.push({ name: lo.alias });
|
||||
}
|
||||
this.lora = new XnIndex(items);
|
||||
}
|
||||
// Embeddings: {loaded: [...], skipped: [...]}
|
||||
const embData = await this.fetchJson('/embeddings');
|
||||
if (embData && typeof embData === 'object') {
|
||||
const loaded = Array.isArray(embData.loaded) ? embData.loaded : [];
|
||||
this.embed = new XnIndex(loaded.map((name) => ({ name })));
|
||||
}
|
||||
// Wildcards: [{name}, ...]
|
||||
const wcData = await this.fetchJson('/wildcards');
|
||||
if (Array.isArray(wcData)) {
|
||||
this.wildcard = new XnIndex(wcData.filter((w) => w?.name).map((w) => ({ name: w.name })));
|
||||
}
|
||||
log('autoComplete', {
|
||||
xnLoaded: true,
|
||||
lora: this.lora.items.length,
|
||||
embed: this.embed.items.length,
|
||||
wildcard: this.wildcard.items.length,
|
||||
});
|
||||
},
|
||||
|
||||
searchLoras(prefix, limit = 20) {
|
||||
return this.lora.search(prefix, limit).map((item) => ({ ...item, kind: 'lora' }));
|
||||
},
|
||||
|
||||
searchEmbeddings(prefix, limit = 20) {
|
||||
return this.embed.search(prefix, limit).map((item) => ({ ...item, kind: 'embed' }));
|
||||
},
|
||||
|
||||
searchWildcards(prefix, limit = 20) {
|
||||
return this.wildcard.search(prefix, limit).map((item) => ({ ...item, kind: 'wildcard' }));
|
||||
},
|
||||
};
|
||||
|
||||
// Expose globally so autocomplete.js can dispatch to it.
|
||||
window.autocompleteXn = xnEngine;
|
||||
Reference in New Issue
Block a user