diff --git a/Prompt DB.png b/Prompt DB.png new file mode 100644 index 0000000..4ec001a Binary files /dev/null and b/Prompt DB.png differ diff --git a/README.md b/README.md index 600ed91..8d2067c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A powerful prompt management system for ComfyUI. Store, categorize, search, and ![Main Pipeline](Main%20Pipeline.png) +![Prompt Database Manager](Prompt%20DB.png) + --- ## ✨ Features diff --git a/web/js/prompt_db.js b/web/js/prompt_db.js new file mode 100644 index 0000000..e2620c8 --- /dev/null +++ b/web/js/prompt_db.js @@ -0,0 +1,1017 @@ +import { app } from "/scripts/app.js"; +import { api } from "/scripts/api.js"; + +/* ============================================================================= + Prompt Database — ComfyUI Web Extension + ========================================================================== */ + +console.log("[PromptDB] Extension loading..."); + +// ─── API helpers ──────────────────────────────────────────────────────────── +const API = { + async getCategories() { + const r = await api.fetchApi("/prompt_db/categories"); + const data = await r.json(); + return data.categories || []; + }, + async getPrompts(category) { + const url = category + ? `/prompt_db/prompts?category=${encodeURIComponent(category)}` + : "/prompt_db/prompts"; + const r = await api.fetchApi(url); + const data = await r.json(); + return data.prompts || []; + }, +}; + +function getNodeClass(node) { + return node.comfyClass || node.type || node.constructor?.type || ""; +} + +// ─── Setup PromptDBManager ────────────────────────────────────────────────── +function setupManagerNode(node) { + if (node._pdb_manager_setup) return; + node._pdb_manager_setup = true; + node.addWidget("button", "📝 Open Prompt Manager", null, () => { + window.open("/prompt_db/manager", "_blank"); + }); + node.setSize?.(node.computeSize?.() || [260, 58]); + app.graph?.setDirtyCanvas?.(true); +} + +// ─── Setup PromptSelector ─────────────────────────────────────────────────── +function setupSelectorNode(node) { + if (node._pdb_selector_setup) return; + node._pdb_selector_setup = true; + + const catWidget = node.widgets?.find((w) => w.name === "category"); + const promptWidget = node.widgets?.find((w) => w.name === "prompt_name"); + if (!catWidget || !promptWidget) return; + + // Strip 📖 marker before sending to Python backend + promptWidget.serializeValue = () => + (promptWidget.value || "").replace(/\s*📖$/, ""); + + // ── Refresh categories + prompts from DB ── + async function refreshFromDB() { + try { + const categories = await API.getCategories(); + if (categories.length > 0) { + catWidget.options.values = categories; + if (!categories.includes(catWidget.value)) catWidget.value = categories[0]; + } + const cat = catWidget.value; + if (cat && cat !== "(no categories)") { + const prompts = await API.getPrompts(cat); + const names = prompts.map((p) => { + const hasLoras = p.loras && p.loras.length > 0; + return hasLoras ? `${p.name} 📖` : p.name; + }); + if (names.length > 0) { + promptWidget.options.values = names; + if (!names.includes(promptWidget.value)) promptWidget.value = names[0]; + } else { + promptWidget.options.values = ["(no prompts)"]; + promptWidget.value = "(no prompts)"; + } + } + await updateLoraDisplay(); + app.graph?.setDirtyCanvas?.(true); + } catch (e) { + console.error("[PromptDB] Refresh failed:", e); + } + } + + // ── LoRA display widgets (read-only info) ── + async function updateLoraDisplay() { + if (node.widgets) { + node.widgets = node.widgets.filter((w) => !w._pdb_lora_display); + } + const rawName = promptWidget.value || ""; + const cleanName = rawName.replace(/\s*📖$/, ""); + const cat = catWidget.value; + if (!cleanName || cleanName === "(no prompts)") return; + + try { + const prompts = await API.getPrompts(cat); + const prompt = prompts.find((p) => p.name === cleanName); + if (!prompt || !prompt.loras || prompt.loras.length === 0) return; + + for (const lora of prompt.loras) { + const displayName = (lora.name || "").replace(/\.safetensors$/, ""); + const strength = lora.strength != null ? lora.strength : 1.0; + const w = node.addWidget( + "text", + `🔗 ${displayName}`, + `${strength.toFixed(2)}`, + () => {}, + { serialize: false } + ); + w._pdb_lora_display = true; + w.disabled = true; + } + } catch (e) { + console.error("[PromptDB] Failed to load LoRA info:", e); + } + const computed = node.computeSize?.(); + if (computed) node.setSize?.(computed); + app.graph?.setDirtyCanvas?.(true); + } + + // ── Add Refresh button ── + node.addWidget("button", "🔄 Refresh", null, () => refreshFromDB()); + + // ── Category change callback ── + const origCatCallback = catWidget.callback; + catWidget.callback = async function (value) { + if (origCatCallback) origCatCallback.call(this, value); + try { + const prompts = await API.getPrompts(value); + const names = prompts.map((p) => { + const hasLoras = p.loras && p.loras.length > 0; + return hasLoras ? `${p.name} 📖` : p.name; + }); + if (names.length === 0) { + promptWidget.options.values = ["(no prompts)"]; + promptWidget.value = "(no prompts)"; + } else { + promptWidget.options.values = names; + if (!names.includes(promptWidget.value)) promptWidget.value = names[0]; + } + await updateLoraDisplay(); + app.graph?.setDirtyCanvas?.(true); + } catch (e) { + console.error("[PromptDB] Failed to fetch prompts:", e); + } + }; + + // ── Prompt change callback ── + const origPromptCallback = promptWidget.callback; + promptWidget.callback = async function (value) { + if (origPromptCallback) origPromptCallback.call(this, value); + await updateLoraDisplay(); + }; + + refreshFromDB(); +} + +// ─── Setup PromptCombiner ─────────────────────────────────────────────────── +const DEFAULT_WIDTH = 500; + +function setupCombinerNode(node) { + if (node._pdb_combiner_setup) return; + node._pdb_combiner_setup = true; + console.log("[PromptDB] Setting up PromptCombiner node", node.id); + + const selectionsWidget = node.widgets?.find( + (w) => w.name === "prompt_selections" + ); + if (selectionsWidget) { + selectionsWidget.type = "converted-widget"; + selectionsWidget.computeSize = () => [0, -4]; + selectionsWidget.serializeValue = () => selectionsWidget.value; + } + + let userWidth = Math.max(node.size?.[0] || DEFAULT_WIDTH, DEFAULT_WIDTH); + + const origOnResize = node.onResize; + node.onResize = function (size) { + if (origOnResize) origOnResize.apply(this, arguments); + if (size && size[0]) userWidth = size[0]; + }; + + let rebuildPending = false; + let rebuildTimer = null; + + function getMaxSelectionIndex() { + let max = 0; + for (const inp of node.inputs || []) { + const m = inp.name.match(/^selection_(\d+)$/); + if (m) max = Math.max(max, parseInt(m[1])); + } + return max; + } + + function syncSelectionsJSON() { + if (!selectionsWidget) return; + const obj = {}; + if (node.widgets) { + for (const w of node.widgets) { + if (w._pdb_dynamic && w._pdb_inputName) { + // Skip muted entries + if (w._pdb_muted) { + obj[w._pdb_inputName] = ""; + } else { + const val = (w.value || "").replace(/\s*📖$/, ""); + obj[w._pdb_inputName] = val; + } + } + } + } + selectionsWidget.value = JSON.stringify(obj); + } + + function getSourceCategory(slotIndex) { + const input = node.inputs?.[slotIndex]; + if (!input || input.link == null) return null; + const linkInfo = app.graph.links[input.link]; + if (!linkInfo) return null; + const sourceNode = app.graph.getNodeById(linkInfo.origin_id); + if (!sourceNode) return null; + const catWidget = sourceNode.widgets?.find((w) => w.name === "category"); + return catWidget?.value || null; + } + + async function fullRebuild() { + // Remove dynamic widgets + if (node.widgets) { + node.widgets = node.widgets.filter( + (w) => !w._pdb_dynamic && !w._pdb_lora_display + ); + } + + // Cleanup disconnected slots + if (node.inputs) { + for (let i = node.inputs.length - 1; i >= 0; i--) { + const inp = node.inputs[i]; + if (inp.name.startsWith("selection_") && inp.link == null) { + node.removeInput(i); + } + } + } + + const nextIdx = getMaxSelectionIndex() + 1; + node.addInput(`selection_${nextIdx}`, "DB_SELECTION"); + + let savedSelections = {}; + try { + savedSelections = JSON.parse(selectionsWidget?.value || "{}"); + } catch (e) {} + + for (let i = 0; i < (node.inputs?.length || 0); i++) { + const inp = node.inputs[i]; + if (!inp.name.startsWith("selection_")) continue; + if (inp.link == null) continue; + + const category = getSourceCategory(i); + if (!category) continue; + + let allPrompts = []; + let promptNames = []; + try { + allPrompts = await API.getPrompts(category); + promptNames = allPrompts.map((p) => { + const hasLoras = p.loras && p.loras.length > 0; + return hasLoras ? `${p.name} 📖` : p.name; + }); + } catch (e) { + console.error("[PromptDB] fetch failed for", category, e); + } + if (promptNames.length === 0) promptNames = ["(no prompts)"]; + + const saved = savedSelections[inp.name]; + let defaultVal = promptNames[0]; + if (saved) { + const match = promptNames.find( + (n) => + n === saved || + n === `${saved} 📖` || + n.replace(/\s*📖$/, "") === saved + ); + if (match) defaultVal = match; + } + + // Check saved mute state + const muteKey = `pdb_mute_${node.id}_${inp.name}`; + const isMuted = localStorage.getItem(muteKey) === "1"; + + // ── Custom single-line widget: ● 📂 Category ◀ PromptName ▶ ── + const closureCat = category; + const closureNames = promptNames; + const closureInpName = inp.name; + const widget = { + type: "pdb_combo", + name: `📂 ${category}`, + value: defaultVal, + _pdb_dynamic: true, + _pdb_inputName: closureInpName, + _pdb_category: closureCat, + _pdb_allPrompts: allPrompts, + _pdb_muted: isMuted, + _pdb_values: closureNames, + options: { serialize: false }, + last_y: 0, + computeSize: function () { + return [node.size[0], 20]; + }, + // ── Custom draw ── + draw: function (ctx, nodeRef, widgetWidth, posY, H) { + this.last_y = posY; + const margin = 15; + const innerW = widgetWidth - margin * 2; + + // Background bar (standard LiteGraph color) + const bgColor = LiteGraph.WIDGET_BGCOLOR || "#29293e"; + ctx.fillStyle = bgColor; + ctx.beginPath(); + ctx.roundRect + ? ctx.roundRect(margin, posY, innerW, H, H * 0.3) + : ctx.rect(margin, posY, innerW, H); + ctx.fill(); + + // Toggle circle (left side) + const toggleX = margin + 14; + const toggleY = posY + H * 0.5; + const r = 5; + ctx.beginPath(); + ctx.arc(toggleX, toggleY, r, 0, Math.PI * 2); + ctx.fillStyle = this._pdb_muted ? "#555" : "#7cacf8"; + ctx.fill(); + if (!this._pdb_muted) { + ctx.beginPath(); + ctx.arc(toggleX - 1, toggleY - 1, r * 0.35, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.25)"; + ctx.fill(); + } + + // Category name (after circle) + ctx.fillStyle = this._pdb_muted ? "#777" : "#ddd"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + const catText = `📂 ${closureCat}`; + ctx.fillText(catText, margin + 28, posY + H * 0.5); + const catTextW = ctx.measureText(catText).width; + const catEnd = margin + 28 + catTextW + 8; // where category text ends + + // Right side layout: ◀ PromptName ▶ (right-aligned) + const rightEdge = margin + innerW - 10; + const displayVal = (this.value || "").replace(/\s*📖$/, ""); + + // Measure prompt name and truncate if overlaps category + ctx.font = "12px sans-serif"; + const maxTextW = rightEdge - catEnd - 36; // space for both arrows + gaps + let valText = displayVal; + if (ctx.measureText(valText).width > maxTextW) { + while (ctx.measureText(valText + "…").width > maxTextW && valText.length > 1) { + valText = valText.slice(0, -1); + } + valText = valText + "…"; + } + + const textW = ctx.measureText(valText).width; + // Positions: ...◀ [text] ▶ + const arrowRight = rightEdge; + const textRight = arrowRight - 14; // text right edge (before ▶) + const textLeft = textRight - textW; // text left edge + const arrowLeftX = textLeft - 14; // ◀ position + + // Cache for mouse handler + this._pdb_arrowLeft = arrowLeftX; + this._pdb_catEnd = catEnd; + + // ◀ button + ctx.fillStyle = "#aaa"; + ctx.textAlign = "center"; + ctx.font = "10px sans-serif"; + ctx.fillText("◀", arrowLeftX, posY + H * 0.5); + + // Prompt name (right-aligned) + ctx.fillStyle = this._pdb_muted ? "#777" : "#ddd"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(valText, textRight, posY + H * 0.5); + + // ▶ button + ctx.fillStyle = "#aaa"; + ctx.textAlign = "center"; + ctx.font = "10px sans-serif"; + ctx.fillText("▶", arrowRight, posY + H * 0.5); + + ctx.textBaseline = "alphabetic"; + }, + // ── Custom mouse ── + mouse: function (event, pos, nodeRef) { + if (event.type !== "pointerdown" && event.type !== "mousedown") + return false; + + const x = pos[0]; + const widgetWidth = nodeRef.size[0]; + const margin = 15; + const innerW = widgetWidth - margin * 2; + const rightEdge = margin + innerW - 10; + const arrowLeftX = this._pdb_arrowLeft || (margin + 120); + + // Toggle area (left 45px — circle click) + if (x < margin + 45) { + this._pdb_muted = !this._pdb_muted; + localStorage.setItem(muteKey, this._pdb_muted ? "1" : "0"); + syncSelectionsJSON(); + updateCombinerLoras(); + app.graph?.setDirtyCanvas?.(true); + return true; + } + + // ◀ button area + if (x > arrowLeftX - 10 && x < arrowLeftX + 10) { + const idx = this._pdb_values.indexOf(this.value); + const newIdx = (idx - 1 + this._pdb_values.length) % this._pdb_values.length; + this.value = this._pdb_values[newIdx]; + syncSelectionsJSON(); + updateCombinerLoras(); + app.graph?.setDirtyCanvas?.(true); + return true; + } + + // ▶ button area + if (x > rightEdge - 10) { + const idx = this._pdb_values.indexOf(this.value); + const newIdx = (idx + 1) % this._pdb_values.length; + this.value = this._pdb_values[newIdx]; + syncSelectionsJSON(); + updateCombinerLoras(); + app.graph?.setDirtyCanvas?.(true); + return true; + } + + // Text area click — open dropdown + if (x > arrowLeftX + 10 && x < rightEdge - 10) { + const that = this; + const canvas = app.canvas; + const ref_window = canvas.getCanvasWindow(); + // Clean display names for the menu + const cleanValues = this._pdb_values.map(v => v.replace(/\s*📖$/, "")); + new LiteGraph.ContextMenu( + this._pdb_values, + { + scale: Math.max(1, canvas.ds?.scale || 1), + event: event, + className: "dark", + callback: function (v) { + if (v) { + that.value = v; + syncSelectionsJSON(); + updateCombinerLoras(); + app.graph?.setDirtyCanvas?.(true); + } + }, + }, + ref_window + ); + return true; + } + + return false; + }, + }; + node.widgets.push(widget); + + // Add LoRA info strips for the currently selected prompt + const cleanVal = defaultVal.replace(/\s*📖$/, ""); + const selectedPrompt = allPrompts.find((p) => p.name === cleanVal); + if (selectedPrompt && selectedPrompt.loras && selectedPrompt.loras.length > 0) { + for (const lora of selectedPrompt.loras) { + const dName = (lora.name || "").replace(/\.safetensors$/, ""); + const str = lora.strength != null ? lora.strength : 1.0; + const loraW = node.addWidget( + "text", + ` 🔗 ${dName}`, + `${str.toFixed(2)}`, + () => {}, + { serialize: false } + ); + loraW._pdb_lora_display = true; + loraW.disabled = true; + } + } + } + + syncSelectionsJSON(); + const computed = node.computeSize?.() || [userWidth, 100]; + node.setSize?.([userWidth, computed[1]]); + app.graph?.setDirtyCanvas?.(true); + } + + // Update LoRA display strips when combo selection changes + async function updateCombinerLoras() { + // Remove old lora display widgets + if (node.widgets) { + node.widgets = node.widgets.filter((w) => !w._pdb_lora_display); + } + + // Re-add LoRA strips after each combo widget + const combos = (node.widgets || []).filter((w) => w._pdb_dynamic); + // We need to insert in order, so collect all widgets to add + const toInsert = []; // { afterWidget, loraWidgets[] } + + for (const combo of combos) { + const cleanVal = (combo.value || "").replace(/\s*📖$/, ""); + const cat = combo._pdb_category; + if (!cleanVal || cleanVal === "(no prompts)" || !cat) continue; + + let prompts = combo._pdb_allPrompts; + if (!prompts) { + try { + prompts = await API.getPrompts(cat); + } catch (e) { + continue; + } + } + + const prompt = prompts.find((p) => p.name === cleanVal); + if (!prompt || !prompt.loras || prompt.loras.length === 0) continue; + + toInsert.push({ afterWidget: combo, loras: prompt.loras }); + } + + // Insert LoRA widgets after their combo (in reverse to keep indices stable) + for (const item of toInsert.reverse()) { + const idx = node.widgets.indexOf(item.afterWidget); + if (idx === -1) continue; + + const newWidgets = []; + for (const lora of item.loras) { + const dName = (lora.name || "").replace(/\.safetensors$/, ""); + const str = lora.strength != null ? lora.strength : 1.0; + const w = { + type: "text", + name: ` 🔗 ${dName}`, + value: `${str.toFixed(2)}`, + options: { serialize: false }, + _pdb_lora_display: true, + disabled: true, + callback: () => {}, + computeSize: function (width) { + return [width, 20]; + }, + }; + newWidgets.push(w); + } + // Insert after the combo widget + node.widgets.splice(idx + 1, 0, ...newWidgets); + } + + const computed = node.computeSize?.() || [userWidth, 100]; + node.setSize?.([userWidth, computed[1]]); + app.graph?.setDirtyCanvas?.(true); + } + + function scheduleRebuild() { + if (rebuildTimer) clearTimeout(rebuildTimer); + rebuildTimer = setTimeout(async () => { + if (rebuildPending) return; + rebuildPending = true; + try { + await fullRebuild(); + } finally { + rebuildPending = false; + } + }, 150); + } + + // Refresh button + node.addWidget("button", "🔄 Refresh", null, () => scheduleRebuild()); + + const origOnConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function ( + type, + slotIndex, + isConnected, + link, + ioSlot + ) { + if (origOnConnectionsChange) origOnConnectionsChange.apply(this, arguments); + if (type === 1) scheduleRebuild(); + }; + + // Periodically check for category changes + node._pdb_refreshInterval = setInterval(() => { + if (!node.graph) { + clearInterval(node._pdb_refreshInterval); + return; + } + let needsRebuild = false; + for (let i = 0; i < (node.inputs?.length || 0); i++) { + const inp = node.inputs[i]; + if (!inp.name.startsWith("selection_") || inp.link == null) continue; + const category = getSourceCategory(i); + const existing = node.widgets?.find( + (w) => w._pdb_dynamic && w._pdb_inputName === inp.name + ); + if (category && (!existing || existing._pdb_category !== category)) { + needsRebuild = true; + break; + } + } + if (needsRebuild) scheduleRebuild(); + }, 1000); + + node.setSize?.([userWidth, node.size?.[1] || 100]); + scheduleRebuild(); +} + +// ─── Setup ApplyLoraStack ─────────────────────────────────────────────────── +const APPLY_LORA_WIDTH = 450; + +function setupApplyLoraNode(node) { + if (node._pdb_apply_setup) return; + node._pdb_apply_setup = true; + console.log("[PromptDB] Setting up ApplyLoraStack node", node.id); + + // Find and hide the lora_config text widget + const configWidget = node.widgets?.find((w) => w.name === "lora_config"); + if (configWidget) { + configWidget.type = "converted-widget"; + configWidget.computeSize = () => [0, -4]; + configWidget.serializeValue = () => configWidget.value; + } + + let rebuildPending = false; + let rebuildTimer = null; + + // Get current config overrides + function getConfig() { + try { + return JSON.parse(configWidget?.value || "{}"); + } catch (e) { + return {}; + } + } + + // Save config back to hidden widget + function saveConfig(config) { + if (configWidget) configWidget.value = JSON.stringify(config); + } + + // Trace back through lora_stack input to discover LoRAs + async function discoverLoras() { + const loraStackInput = node.inputs?.find((inp) => inp.name === "lora_stack"); + if (!loraStackInput || loraStackInput.link == null) return []; + + const linkInfo = app.graph.links[loraStackInput.link]; + if (!linkInfo) return []; + + const sourceNode = app.graph.getNodeById(linkInfo.origin_id); + if (!sourceNode) return []; + + const sourceClass = getNodeClass(sourceNode); + + if (sourceClass === "PromptSelector") { + return await getLorasFromSelector(sourceNode); + } else if (sourceClass === "PromptCombiner") { + return await getLorasFromCombiner(sourceNode); + } + + return []; + } + + // Get LoRAs from a PromptSelector node + async function getLorasFromSelector(selectorNode) { + const catWidget = selectorNode.widgets?.find((w) => w.name === "category"); + const promptWidget = selectorNode.widgets?.find( + (w) => w.name === "prompt_name" + ); + if (!catWidget || !promptWidget) return []; + + const cat = catWidget.value; + const rawName = promptWidget.value || ""; + const cleanName = rawName.replace(/\s*📖$/, ""); + + try { + const prompts = await API.getPrompts(cat); + const prompt = prompts.find((p) => p.name === cleanName); + if (prompt && prompt.loras && prompt.loras.length > 0) { + return prompt.loras.map((l) => ({ + name: l.name || "", + strength: l.strength != null ? l.strength : 1.0, + })); + } + } catch (e) { + console.error("[PromptDB] Failed to get LoRAs from selector:", e); + } + return []; + } + + // Get LoRAs from a PromptCombiner node + async function getLorasFromCombiner(combinerNode) { + const allLoras = []; + const seen = new Set(); + + // Read all dynamic combo widgets to find selected prompts + const combos = (combinerNode.widgets || []).filter((w) => w._pdb_dynamic); + + for (const combo of combos) { + const category = combo._pdb_category; + const rawVal = combo.value || ""; + const cleanName = rawVal.replace(/\s*📖$/, ""); + + if (!category || !cleanName || cleanName === "(no prompts)") continue; + // Skip muted categories + if (combo._pdb_muted) continue; + + try { + const prompts = await API.getPrompts(category); + const prompt = prompts.find((p) => p.name === cleanName); + if (prompt && prompt.loras) { + for (const l of prompt.loras) { + const name = l.name || ""; + if (name && !seen.has(name)) { + seen.add(name); + allLoras.push({ + name, + strength: l.strength != null ? l.strength : 1.0, + }); + } + } + } + } catch (e) { + console.error("[PromptDB] Failed to get LoRAs from combiner:", e); + } + } + + return allLoras; + } + + // Full rebuild of LoRA display widgets + async function fullRebuild() { + console.log("[PromptDB] ApplyLoraStack rebuild", node.id); + + // Remove all dynamic LoRA widgets + if (node.widgets) { + node.widgets = node.widgets.filter((w) => !w._pdb_lora_widget); + } + + const loras = await discoverLoras(); + if (loras.length === 0) { + const emptyW = node.addWidget( + "text", + "Status", + "No LoRAs connected", + () => {}, + { serialize: false } + ); + emptyW._pdb_lora_widget = true; + emptyW.disabled = true; + + const computed = node.computeSize?.() || [APPLY_LORA_WIDTH, 100]; + node.setSize?.([Math.max(computed[0], APPLY_LORA_WIDTH), computed[1]]); + app.graph?.setDirtyCanvas?.(true); + return; + } + + const config = getConfig(); + + for (const lora of loras) { + const displayName = (lora.name || "").replace(/\.safetensors$/, ""); + const override = config[lora.name] || {}; + const isEnabled = override.enabled !== false; + const str = + override.strength != null ? override.strength : lora.strength; + + // ── Custom single-line widget: ● name ◀ 1.00 ▶ ── + const loraFullName = lora.name; // closure capture + const widget = { + type: "pdb_lora", + name: displayName, + value: str, + _pdb_lora_widget: true, + _pdb_lora_name: loraFullName, + _pdb_enabled: isEnabled, + options: { serialize: false }, + last_y: 0, + computeSize: function () { + return [node.size[0], 20]; + }, + // ── Custom draw ── + draw: function (ctx, nodeRef, widgetWidth, posY, H) { + this.last_y = posY; + const margin = 15; + const innerW = widgetWidth - margin * 2; + + // Background bar + ctx.fillStyle = "#232333"; + ctx.beginPath(); + ctx.roundRect + ? ctx.roundRect(margin, posY, innerW, H, H * 0.3) + : ctx.rect(margin, posY, innerW, H); + ctx.fill(); + + // Toggle circle + const toggleX = margin + 14; + const toggleY = posY + H * 0.5; + const r = 5; + ctx.beginPath(); + ctx.arc(toggleX, toggleY, r, 0, Math.PI * 2); + ctx.fillStyle = this._pdb_enabled ? "#7cacf8" : "#555"; + ctx.fill(); + if (this._pdb_enabled) { + ctx.beginPath(); + ctx.arc(toggleX - 1, toggleY - 1, r * 0.35, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.25)"; + ctx.fill(); + } + + // LoRA name + ctx.fillStyle = this._pdb_enabled ? "#ddd" : "#777"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(this.name, margin + 28, posY + H * 0.5); + + // ◀ value ▶ on right side + const rightEdge = margin + innerW - 8; + const valStr = this.value.toFixed(2); + + // ▶ button + ctx.fillStyle = "#aaa"; + ctx.textAlign = "center"; + ctx.font = "10px sans-serif"; + ctx.fillText("▶", rightEdge, posY + H * 0.5); + + // Value + ctx.fillStyle = this._pdb_enabled ? "#ddd" : "#777"; + ctx.font = "12px sans-serif"; + ctx.fillText(valStr, rightEdge - 28, posY + H * 0.5); + + // ◀ button + ctx.fillStyle = "#aaa"; + ctx.font = "10px sans-serif"; + ctx.fillText("◀", rightEdge - 56, posY + H * 0.5); + + ctx.textBaseline = "alphabetic"; + }, + // ── Custom mouse ── + mouse: function (event, pos, nodeRef) { + if ( + event.type !== "pointerdown" && + event.type !== "mousedown" + ) + return false; + + const x = pos[0]; + const widgetWidth = nodeRef.size[0]; + const margin = 15; + const innerW = widgetWidth - margin * 2; + const rightEdge = margin + innerW - 8; + + // Toggle area (left 50px) + if (x < margin + 45) { + this._pdb_enabled = !this._pdb_enabled; + const cfg = getConfig(); + if (!cfg[this._pdb_lora_name]) cfg[this._pdb_lora_name] = {}; + cfg[this._pdb_lora_name].enabled = this._pdb_enabled; + saveConfig(cfg); + app.graph?.setDirtyCanvas?.(true); + return true; + } + + // ◀ button (decrease strength) + if (x > rightEdge - 70 && x < rightEdge - 42) { + this.value = Math.max(-2, +(this.value - 0.05).toFixed(2)); + const cfg = getConfig(); + if (!cfg[this._pdb_lora_name]) cfg[this._pdb_lora_name] = {}; + cfg[this._pdb_lora_name].strength = this.value; + saveConfig(cfg); + app.graph?.setDirtyCanvas?.(true); + return true; + } + + // ▶ button (increase strength) + if (x > rightEdge - 14 && x < rightEdge + 14) { + this.value = Math.min(2, +(this.value + 0.05).toFixed(2)); + const cfg = getConfig(); + if (!cfg[this._pdb_lora_name]) cfg[this._pdb_lora_name] = {}; + cfg[this._pdb_lora_name].strength = this.value; + saveConfig(cfg); + app.graph?.setDirtyCanvas?.(true); + return true; + } + + return false; + }, + }; + + node.widgets = node.widgets || []; + node.widgets.push(widget); + } + + // ── Add Reset Values button ── + const resetBtn = node.addWidget( + "button", + "🔄 Reset Values", + null, + async () => { + // Clear all overrides + if (configWidget) configWidget.value = "{}"; + await fullRebuild(); + }, + { serialize: false } + ); + resetBtn._pdb_lora_widget = true; + + const computed = node.computeSize?.() || [APPLY_LORA_WIDTH, 100]; + node.setSize?.([Math.max(computed[0], APPLY_LORA_WIDTH), computed[1]]); + app.graph?.setDirtyCanvas?.(true); + } + + function scheduleRebuild() { + if (rebuildTimer) clearTimeout(rebuildTimer); + rebuildTimer = setTimeout(async () => { + if (rebuildPending) return; + rebuildPending = true; + try { + await fullRebuild(); + } finally { + rebuildPending = false; + } + }, 200); + } + + // ── Refresh button ── + node.addWidget("button", "🔄 Refresh", null, () => scheduleRebuild()); + + // ── React to connection changes ── + const origOnConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function ( + type, + slotIndex, + isConnected, + link, + ioSlot + ) { + if (origOnConnectionsChange) origOnConnectionsChange.apply(this, arguments); + scheduleRebuild(); + }; + + // Periodically check for changes in connected source nodes + let lastLoraFingerprint = ""; + node._pdb_refreshInterval = setInterval(async () => { + if (!node.graph) { + clearInterval(node._pdb_refreshInterval); + return; + } + + try { + const loras = await discoverLoras(); + const fingerprint = JSON.stringify(loras.map((l) => l.name).sort()); + if (fingerprint !== lastLoraFingerprint) { + lastLoraFingerprint = fingerprint; + scheduleRebuild(); + } + } catch (e) {} + }, 1500); + + // Initial setup + node.setSize?.([APPLY_LORA_WIDTH, node.size?.[1] || 100]); + scheduleRebuild(); +} + +// ─── Register extension ───────────────────────────────────────────────────── +app.registerExtension({ + name: "comfy.promptdb", + + async beforeRegisterNodeDef(nodeType, nodeData, _app) { + if (nodeData.name === "PromptDBManager") { + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + orig?.apply(this, arguments); + setTimeout(() => setupManagerNode(this), 100); + }; + } + if (nodeData.name === "PromptSelector") { + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + orig?.apply(this, arguments); + setTimeout(() => setupSelectorNode(this), 100); + }; + } + if (nodeData.name === "PromptCombiner") { + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + orig?.apply(this, arguments); + setTimeout(() => setupCombinerNode(this), 100); + }; + } + if (nodeData.name === "ApplyLoraStack") { + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + orig?.apply(this, arguments); + setTimeout(() => setupApplyLoraNode(this), 100); + }; + } + }, + + nodeCreated(node) { + const cls = getNodeClass(node); + if (cls === "PromptDBManager") + setTimeout(() => setupManagerNode(node), 150); + if (cls === "PromptSelector") + setTimeout(() => setupSelectorNode(node), 150); + if (cls === "PromptCombiner") + setTimeout(() => setupCombinerNode(node), 150); + if (cls === "ApplyLoraStack") + setTimeout(() => setupApplyLoraNode(node), 150); + }, +}); + +console.log("[PromptDB] Extension registered."); diff --git a/web/prompt_manager.html b/web/prompt_manager.html new file mode 100644 index 0000000..8e8c3d6 --- /dev/null +++ b/web/prompt_manager.html @@ -0,0 +1,1422 @@ + + + + + + Prompt Database Manager + + + + +
+ + + + +
+
+
All Prompts
+
+ + +
+ + + +
+
+
+
+ + +
+
+
+

New Prompt

+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ 📖 Attached LoRAs +
+
+
+ + +
+
+
+ +
+ + +
+ + + + + + + +