Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Includes 30 custom nodes committed directly, 7 Civitai-exclusive loras stored via Git LFS, and a setup script that installs all dependencies and downloads HuggingFace-hosted models on vast.ai. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
602
custom_nodes/comfyui-custom-scripts/web/js/autocompleter.js
Normal file
602
custom_nodes/comfyui-custom-scripts/web/js/autocompleter.js
Normal file
@@ -0,0 +1,602 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { $el, ComfyDialog } from "../../../scripts/ui.js";
|
||||
import { TextAreaAutoComplete } from "./common/autocomplete.js";
|
||||
import { ModelInfoDialog } from "./common/modelInfoDialog.js";
|
||||
import { LoraInfoDialog } from "./modelInfo.js";
|
||||
|
||||
function parseCSV(csvText) {
|
||||
const rows = [];
|
||||
const delimiter = ",";
|
||||
const quote = '"';
|
||||
let currentField = "";
|
||||
let inQuotedField = false;
|
||||
|
||||
function pushField() {
|
||||
rows[rows.length - 1].push(currentField);
|
||||
currentField = "";
|
||||
inQuotedField = false;
|
||||
}
|
||||
|
||||
rows.push([]); // Initialize the first row
|
||||
|
||||
for (let i = 0; i < csvText.length; i++) {
|
||||
const char = csvText[i];
|
||||
const nextChar = csvText[i + 1];
|
||||
|
||||
// Special handling for backslash escaped quotes
|
||||
if (char === "\\" && nextChar === quote) {
|
||||
currentField += quote;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!inQuotedField) {
|
||||
if (char === quote) {
|
||||
inQuotedField = true;
|
||||
} else if (char === delimiter) {
|
||||
pushField();
|
||||
} else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
|
||||
pushField();
|
||||
if (nextChar === "\n") {
|
||||
i++; // Handle Windows line endings (\r\n)
|
||||
}
|
||||
rows.push([]); // Start a new row
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
} else {
|
||||
if (char === quote && nextChar === quote) {
|
||||
currentField += quote;
|
||||
i++; // Skip the next quote
|
||||
} else if (char === quote) {
|
||||
inQuotedField = false;
|
||||
} else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
|
||||
// Dont allow new lines in quoted text, assume its wrong
|
||||
const parsed = parseCSV(currentField);
|
||||
rows.pop();
|
||||
rows.push(...parsed);
|
||||
inQuotedField = false;
|
||||
currentField = "";
|
||||
rows.push([]);
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField || csvText[csvText.length - 1] === ",") {
|
||||
pushField();
|
||||
}
|
||||
|
||||
// Remove the last row if it's empty
|
||||
if (rows[rows.length - 1].length === 0) {
|
||||
rows.pop();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getCustomWords() {
|
||||
const resp = await api.fetchApi("/pysssss/autocomplete", { cache: "no-store" });
|
||||
if (resp.status === 200) {
|
||||
return await resp.text();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function addCustomWords(text) {
|
||||
if (!text) {
|
||||
text = await getCustomWords();
|
||||
}
|
||||
if (text) {
|
||||
TextAreaAutoComplete.updateWords(
|
||||
"pysssss.customwords",
|
||||
parseCSV(text).reduce((p, n) => {
|
||||
let text;
|
||||
let priority;
|
||||
let value;
|
||||
let num;
|
||||
switch (n.length) {
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
// Single word
|
||||
text = n[0];
|
||||
break;
|
||||
case 2:
|
||||
// Word,[priority|alias]
|
||||
num = +n[1];
|
||||
if (isNaN(num)) {
|
||||
text = n[0] + "🔄️" + n[1];
|
||||
value = n[0];
|
||||
} else {
|
||||
text = n[0];
|
||||
priority = num;
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
// a1111 csv format?
|
||||
value = n[0];
|
||||
priority = +n[2];
|
||||
const aliases = n[3]?.trim();
|
||||
if (aliases && aliases !== "null") { // Weird null in an example csv, maybe they are JSON.parsing the last column?
|
||||
const split = aliases.split(",");
|
||||
for (const text of split) {
|
||||
p[text] = { text, priority, value };
|
||||
}
|
||||
}
|
||||
text = value;
|
||||
break;
|
||||
default:
|
||||
// Word,alias,priority
|
||||
text = n[1];
|
||||
value = n[0];
|
||||
priority = +n[2];
|
||||
break;
|
||||
}
|
||||
p[text] = { text, priority, value };
|
||||
return p;
|
||||
}, {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLoras() {
|
||||
[TextAreaAutoComplete.globalWords, TextAreaAutoComplete.globalWordsExclLoras] = [
|
||||
TextAreaAutoComplete.globalWordsExclLoras,
|
||||
TextAreaAutoComplete.globalWords,
|
||||
];
|
||||
}
|
||||
|
||||
class EmbeddingInfoDialog extends ModelInfoDialog {
|
||||
async addInfo() {
|
||||
super.addInfo();
|
||||
const info = await this.addCivitaiInfo();
|
||||
if (info) {
|
||||
$el("div", {
|
||||
parent: this.content,
|
||||
innerHTML: info.description,
|
||||
style: {
|
||||
maxHeight: "250px",
|
||||
overflow: "auto",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomWordsDialog extends ComfyDialog {
|
||||
async show() {
|
||||
const text = await getCustomWords();
|
||||
this.words = $el("textarea", {
|
||||
textContent: text,
|
||||
style: {
|
||||
width: "70vw",
|
||||
height: "70vh",
|
||||
},
|
||||
});
|
||||
|
||||
const input = $el("input", {
|
||||
style: {
|
||||
flex: "auto",
|
||||
},
|
||||
value:
|
||||
"https://gist.githubusercontent.com/pythongosssss/1d3efa6050356a08cea975183088159a/raw/a18fb2f94f9156cf4476b0c24a09544d6c0baec6/danbooru-tags.txt",
|
||||
});
|
||||
|
||||
super.show(
|
||||
$el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
maxHeight: "100%",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("h2", {
|
||||
textContent: "Custom Autocomplete Words",
|
||||
style: {
|
||||
color: "#fff",
|
||||
marginTop: 0,
|
||||
textAlign: "center",
|
||||
fontFamily: "sans-serif",
|
||||
},
|
||||
}),
|
||||
$el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
color: "#fff",
|
||||
fontFamily: "sans-serif",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("label", { textContent: "Load Custom List: " }),
|
||||
input,
|
||||
$el("button", {
|
||||
textContent: "Load",
|
||||
onclick: async () => {
|
||||
try {
|
||||
const res = await fetch(input.value);
|
||||
if (res.status !== 200) {
|
||||
throw new Error("Error loading: " + res.status + " " + res.statusText);
|
||||
}
|
||||
this.words.value = await res.text();
|
||||
} catch (error) {
|
||||
alert("Error loading custom list, try manually copy + pasting the list");
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
this.words,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
createButtons() {
|
||||
const btns = super.createButtons();
|
||||
const save = $el("button", {
|
||||
type: "button",
|
||||
textContent: "Save",
|
||||
onclick: async (e) => {
|
||||
try {
|
||||
const res = await api.fetchApi("/pysssss/autocomplete", { method: "POST", body: this.words.value });
|
||||
if (res.status !== 200) {
|
||||
throw new Error("Error saving: " + res.status + " " + res.statusText);
|
||||
}
|
||||
save.textContent = "Saved!";
|
||||
addCustomWords(this.words.value);
|
||||
setTimeout(() => {
|
||||
save.textContent = "Save";
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
alert("Error saving word list!");
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
btns.unshift(save);
|
||||
return btns;
|
||||
}
|
||||
}
|
||||
|
||||
const id = "pysssss.AutoCompleter";
|
||||
|
||||
app.registerExtension({
|
||||
name: id,
|
||||
init() {
|
||||
const STRING = ComfyWidgets.STRING;
|
||||
const SKIP_WIDGETS = new Set(["ttN xyPlot.x_values", "ttN xyPlot.y_values"]);
|
||||
ComfyWidgets.STRING = function (node, inputName, inputData) {
|
||||
const r = STRING.apply(this, arguments);
|
||||
|
||||
if (inputData[1]?.multiline) {
|
||||
// Disabled on this input
|
||||
const config = inputData[1]?.["pysssss.autocomplete"];
|
||||
if (config === false) return r;
|
||||
|
||||
// In list of widgets to skip
|
||||
const id = `${node.comfyClass}.${inputName}`;
|
||||
if (SKIP_WIDGETS.has(id)) return r;
|
||||
|
||||
let words;
|
||||
let separator;
|
||||
if (typeof config === "object") {
|
||||
separator = config.separator;
|
||||
words = {};
|
||||
if (config.words) {
|
||||
// Custom wordlist, this will have been registered on setup
|
||||
Object.assign(words, TextAreaAutoComplete.groups[node.comfyClass + "." + inputName] ?? {});
|
||||
}
|
||||
|
||||
for (const item of config.groups ?? []) {
|
||||
if (item === "*") {
|
||||
// This widget wants all global words included
|
||||
Object.assign(words, TextAreaAutoComplete.globalWords);
|
||||
} else {
|
||||
// This widget wants a specific group included
|
||||
Object.assign(words, TextAreaAutoComplete.groups[item] ?? {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new TextAreaAutoComplete(r.widget.inputEl, words, separator);
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", ";
|
||||
const enabledSetting = app.ui.settings.addSetting({
|
||||
id,
|
||||
name: "🐍 Text Autocomplete",
|
||||
defaultValue: true,
|
||||
type: (name, setter, value) => {
|
||||
return $el("tr", [
|
||||
$el("td", [
|
||||
$el("label", {
|
||||
for: id.replaceAll(".", "-"),
|
||||
textContent: name,
|
||||
}),
|
||||
]),
|
||||
$el("td", [
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Enabled ",
|
||||
style: {
|
||||
display: "block",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
id: id.replaceAll(".", "-"),
|
||||
type: "checkbox",
|
||||
checked: value,
|
||||
onchange: (event) => {
|
||||
const checked = !!event.target.checked;
|
||||
TextAreaAutoComplete.enabled = checked;
|
||||
setter(checked);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label.comfy-tooltip-indicator",
|
||||
{
|
||||
title: "This requires other ComfyUI nodes/extensions that support using LoRAs in the prompt.",
|
||||
textContent: "Loras enabled ",
|
||||
style: {
|
||||
display: "block",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: !!TextAreaAutoComplete.lorasEnabled,
|
||||
onchange: (event) => {
|
||||
const checked = !!event.target.checked;
|
||||
TextAreaAutoComplete.lorasEnabled = checked;
|
||||
toggleLoras();
|
||||
localStorage.setItem(id + ".ShowLoras", TextAreaAutoComplete.lorasEnabled);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Auto-insert comma ",
|
||||
style: {
|
||||
display: "block",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: !!TextAreaAutoComplete.globalSeparator,
|
||||
onchange: (event) => {
|
||||
const checked = !!event.target.checked;
|
||||
TextAreaAutoComplete.globalSeparator = checked ? ", " : "";
|
||||
localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.globalSeparator);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Replace _ with space ",
|
||||
style: {
|
||||
display: "block",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: !!TextAreaAutoComplete.replacer,
|
||||
onchange: (event) => {
|
||||
const checked = !!event.target.checked;
|
||||
TextAreaAutoComplete.replacer = checked ? (v) => v.replaceAll("_", " ") : undefined;
|
||||
localStorage.setItem(id + ".ReplaceUnderscore", checked);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Insert suggestion on: ",
|
||||
style: {
|
||||
display: "block",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Tab",
|
||||
style: {
|
||||
display: "block",
|
||||
marginLeft: "20px",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: !!TextAreaAutoComplete.insertOnTab,
|
||||
onchange: (event) => {
|
||||
const checked = !!event.target.checked;
|
||||
TextAreaAutoComplete.insertOnTab = checked;
|
||||
localStorage.setItem(id + ".InsertOnTab", checked);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Enter",
|
||||
style: {
|
||||
display: "block",
|
||||
marginLeft: "20px",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: !!TextAreaAutoComplete.insertOnEnter,
|
||||
onchange: (event) => {
|
||||
const checked = !!event.target.checked;
|
||||
TextAreaAutoComplete.insertOnEnter = checked;
|
||||
localStorage.setItem(id + ".InsertOnEnter", checked);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Max suggestions: ",
|
||||
style: {
|
||||
display: "block",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("input", {
|
||||
type: "number",
|
||||
value: +TextAreaAutoComplete.suggestionCount,
|
||||
style: {
|
||||
width: "80px"
|
||||
},
|
||||
onchange: (event) => {
|
||||
const value = +event.target.value;
|
||||
TextAreaAutoComplete.suggestionCount = value;;
|
||||
localStorage.setItem(id + ".SuggestionCount", TextAreaAutoComplete.suggestionCount);
|
||||
},
|
||||
}),
|
||||
]
|
||||
),
|
||||
$el("button", {
|
||||
textContent: "Manage Custom Words",
|
||||
onclick: () => {
|
||||
try {
|
||||
// Try closing old settings window
|
||||
if (typeof app.ui.settings.element?.close === "function") {
|
||||
app.ui.settings.element.close();
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
try {
|
||||
// Try closing new vue dialog
|
||||
document.querySelector(".p-dialog-close-button").click();
|
||||
} catch (error) {
|
||||
// Fallback to just hiding the element
|
||||
app.ui.settings.element.style.display = "none";
|
||||
}
|
||||
|
||||
new CustomWordsDialog().show();
|
||||
},
|
||||
style: {
|
||||
fontSize: "14px",
|
||||
display: "block",
|
||||
marginTop: "5px",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
TextAreaAutoComplete.enabled = enabledSetting.value;
|
||||
TextAreaAutoComplete.replacer = localStorage.getItem(id + ".ReplaceUnderscore") === "true" ? (v) => v.replaceAll("_", " ") : undefined;
|
||||
TextAreaAutoComplete.insertOnTab = localStorage.getItem(id + ".InsertOnTab") !== "false";
|
||||
TextAreaAutoComplete.insertOnEnter = localStorage.getItem(id + ".InsertOnEnter") !== "false";
|
||||
TextAreaAutoComplete.lorasEnabled = localStorage.getItem(id + ".ShowLoras") === "true";
|
||||
TextAreaAutoComplete.suggestionCount = +localStorage.getItem(id + ".SuggestionCount") || 20;
|
||||
},
|
||||
setup() {
|
||||
async function addEmbeddings() {
|
||||
const embeddings = await api.getEmbeddings();
|
||||
const words = {};
|
||||
words["embedding:"] = { text: "embedding:" };
|
||||
|
||||
for (const emb of embeddings) {
|
||||
const v = `embedding:${emb}`;
|
||||
words[v] = {
|
||||
text: v,
|
||||
info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb),
|
||||
use_replacer: false,
|
||||
};
|
||||
}
|
||||
|
||||
TextAreaAutoComplete.updateWords("pysssss.embeddings", words);
|
||||
}
|
||||
|
||||
async function addLoras() {
|
||||
let loras;
|
||||
try {
|
||||
loras = LiteGraph.registered_node_types["LoraLoader"]?.nodeData.input.required.lora_name[0];
|
||||
} catch (error) {}
|
||||
|
||||
if (!loras?.length) {
|
||||
loras = await api.fetchApi("/pysssss/loras", { cache: "no-store" }).then((res) => res.json());
|
||||
}
|
||||
|
||||
const words = {};
|
||||
words["lora:"] = { text: "lora:" };
|
||||
|
||||
for (const lora of loras) {
|
||||
const v = `<lora:${lora}:1.0>`;
|
||||
words[v] = {
|
||||
text: v,
|
||||
info: () => new LoraInfoDialog(lora).show("loras", lora),
|
||||
use_replacer: false,
|
||||
};
|
||||
}
|
||||
|
||||
TextAreaAutoComplete.updateWords("pysssss.loras", words);
|
||||
}
|
||||
|
||||
// store global words with/without loras
|
||||
Promise.all([addEmbeddings(), addCustomWords()])
|
||||
.then(() => {
|
||||
TextAreaAutoComplete.globalWordsExclLoras = Object.assign({}, TextAreaAutoComplete.globalWords);
|
||||
})
|
||||
.then(addLoras)
|
||||
.then(() => {
|
||||
if (!TextAreaAutoComplete.lorasEnabled) {
|
||||
toggleLoras(); // off by default
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeRegisterNodeDef(_, def) {
|
||||
// Process each input to see if there is a custom word list for
|
||||
// { input: { required: { something: ["STRING", { "pysssss.autocomplete": ["groupid", ["custom", "words"] ] }] } } }
|
||||
const inputs = { ...def.input?.required, ...def.input?.optional };
|
||||
for (const input in inputs) {
|
||||
const config = inputs[input][1]?.["pysssss.autocomplete"];
|
||||
if (!config) continue;
|
||||
if (typeof config === "object" && config.words) {
|
||||
const words = {};
|
||||
for (const text of config.words || []) {
|
||||
const obj = typeof text === "string" ? { text } : text;
|
||||
words[obj.text] = obj;
|
||||
}
|
||||
TextAreaAutoComplete.updateWords(def.name + "." + input, words, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user