feat: add custom words autocomplete support for Prompt node

Adds custom words autocomplete functionality similar to comfyui-custom-scripts,
with the following features:

Backend (Python):
- Create CustomWordsService for CSV parsing and priority-based search
- Add API endpoints: GET/POST /api/lm/custom-words and
  GET /api/lm/custom-words/search
- Share storage with pysssss plugin (checks for their user/autocomplete.txt first)
- Fallback to Lora Manager's user directory for storage

Frontend (JavaScript/Vue):
- Add 'custom_words' and 'prompt' model types to autocomplete system
- Prompt node now supports dual-mode autocomplete:
  * Type 'emb:' prefix → search embeddings
  * Type normally → search custom words (no prefix required)
- Add AUTOCOMPLETE_TEXT_PROMPT widget type
- Update Vue component and composable types

Key Features:
- CSV format: word[,priority] compatible with danbooru-tags.txt
- Priority-based sorting: 20% top priority + prefix + include matches
- Preview tooltip for embeddings (not for custom words)
- Dynamic endpoint switching based on prefix detection

Breaking Changes:
- Prompt (LoraManager) node widget type changed from
  AUTOCOMPLETE_TEXT_EMBEDDINGS to AUTOCOMPLETE_TEXT_PROMPT
- Removed standalone web/comfyui/prompt.js (integrated into main widgets)

Fixes comfy_dir path calculation by prioritizing folder_paths.base_path
from ComfyUI when available, with fallback to computed path.
This commit is contained in:
Will Miao
2026-01-25 12:24:32 +08:00
parent 1f6fc59aa2
commit d5a2bd1e24
13 changed files with 638 additions and 43 deletions

View File

@@ -148,6 +148,52 @@ const MODEL_BEHAVIORS = {
return `embedding:${folder}${trimmedName}, `;
},
},
custom_words: {
enablePreview: false,
async getInsertText(_instance, relativePath) {
return `${relativePath}, `;
},
},
prompt: {
enablePreview: true,
init(instance) {
if (!instance.options.showPreview) {
return;
}
instance.initPreviewTooltip({ modelType: 'embeddings' });
},
showPreview(instance, relativePath, itemElement) {
if (!instance.previewTooltip || instance.searchType !== 'embeddings') {
return;
}
instance.showPreviewForItem(relativePath, itemElement);
},
hidePreview(instance) {
if (!instance.previewTooltip || instance.searchType !== 'embeddings') {
return;
}
instance.previewTooltip.hide();
},
destroy(instance) {
if (instance.previewTooltip) {
instance.previewTooltip.cleanup();
instance.previewTooltip = null;
}
},
async getInsertText(instance, relativePath) {
const rawSearchTerm = instance.getSearchTerm(instance.inputElement.value);
const match = rawSearchTerm.match(/^emb:(.*)$/i);
if (match) {
const { directories, fileName } = splitRelativePath(relativePath);
const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('\\')}\\` : '';
return `embedding:${folder}${trimmedName}, `;
} else {
return `${relativePath}, `;
}
},
},
};
function getModelBehavior(modelType) {
@@ -175,6 +221,7 @@ class AutoComplete {
this.currentSearchTerm = '';
this.previewTooltip = null;
this.previewTooltipPromise = null;
this.searchType = null;
// Initialize TextAreaCaretHelper
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
@@ -355,6 +402,7 @@ class AutoComplete {
// Get the search term (text after last comma / '>')
const rawSearchTerm = this.getSearchTerm(value);
let searchTerm = rawSearchTerm;
let endpoint = `/lm/${this.modelType}/relative-paths`;
// For embeddings, only trigger autocomplete when the current token
// starts with the explicit "emb:" prefix. This avoids interrupting
@@ -368,14 +416,30 @@ class AutoComplete {
searchTerm = (match[1] || '').trim();
}
// For prompt model type, check if we're searching embeddings or custom words
if (this.modelType === 'prompt') {
const match = rawSearchTerm.match(/^emb:(.*)$/i);
if (match) {
// User typed "emb:" prefix - search embeddings
endpoint = '/lm/embeddings/relative-paths';
searchTerm = (match[1] || '').trim();
this.searchType = 'embeddings';
} else {
// No prefix - search custom words
endpoint = '/lm/custom-words/search';
searchTerm = rawSearchTerm;
this.searchType = 'custom_words';
}
}
if (searchTerm.length < this.options.minChars) {
this.hide();
return;
}
// Debounce the search
this.debounceTimer = setTimeout(() => {
this.search(searchTerm);
this.search(searchTerm, endpoint);
}, this.options.debounceDelay);
}
@@ -385,25 +449,43 @@ class AutoComplete {
if (!beforeCursor) {
return '';
}
// Split on comma and '>' delimiters only (do not split on spaces)
const segments = beforeCursor.split(/[,\>]+/);
// Return the last non-empty segment as search term
const lastSegment = segments[segments.length - 1] || '';
return lastSegment.trim();
}
async search(term = '') {
async search(term = '', endpoint = null) {
try {
this.currentSearchTerm = term;
const response = await api.fetchApi(`/lm/${this.modelType}/relative-paths?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`);
if (!endpoint) {
endpoint = `/lm/${this.modelType}/relative-paths`;
}
const url = endpoint.includes('?')
? `${endpoint}&search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`
: `${endpoint}?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`;
const response = await api.fetchApi(url);
const data = await response.json();
if (data.success && data.relative_paths && data.relative_paths.length > 0) {
this.items = data.relative_paths;
this.render();
this.show();
// Support both response formats:
// 1. Model endpoint format: { success: true, relative_paths: [...] }
// 2. Custom words format: { success: true, words: [...] }
if (data.success) {
const items = data.relative_paths || data.words || [];
if (items.length > 0) {
this.items = items;
this.render();
this.show();
} else {
this.items = [];
this.hide();
}
} else {
this.items = [];
this.hide();

View File

@@ -1,20 +0,0 @@
import { app } from "../../scripts/app.js";
import { chainCallback } from "./utils.js";
app.registerExtension({
name: "LoraManager.Prompt",
async beforeRegisterNodeDef(nodeType) {
if (nodeType.comfyClass === "Prompt (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
this.serialize_widgets = true;
// Get the text input widget (AUTOCOMPLETE_TEXT_EMBEDDINGS type, created by Vue widgets)
const inputWidget = this.widgets?.[0];
if (inputWidget) {
this.inputWidget = inputWidget;
}
});
}
},
});

View File

@@ -1725,7 +1725,7 @@ to {
padding: 20px 0;
}
.autocomplete-text-widget[data-v-46db5331] {
.autocomplete-text-widget[data-v-d5278afc] {
background: transparent;
height: 100%;
display: flex;
@@ -1734,7 +1734,7 @@ to {
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-46db5331] {
.text-input[data-v-d5278afc] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -1751,7 +1751,7 @@ to {
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-46db5331] {
.text-input.vue-dom-mode[data-v-d5278afc] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px;
@@ -1760,7 +1760,7 @@ to {
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-46db5331]:focus {
.text-input[data-v-d5278afc]:focus {
outline: none;
}`));
document.head.appendChild(elementStyle);
@@ -13456,7 +13456,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-46db5331"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d5278afc"]]);
const LORA_PROVIDER_NODE_TYPES$1 = [
"Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)",
@@ -14141,6 +14141,12 @@ app$1.registerExtension({
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
return createAutocompleteTextWidgetFactory(node, "text", "embeddings", options);
},
// Autocomplete text widget for prompt (supports both embeddings and custom words)
// @ts-ignore
AUTOCOMPLETE_TEXT_PROMPT(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
return createAutocompleteTextWidgetFactory(node, "text", "prompt", options);
}
};
},

File diff suppressed because one or more lines are too long