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: () => { app.ui.settings.element.close(); 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 = ``; 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); } } }, });