mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
feat(lora-info): add Lora Info display node
Add a pure frontend node that shows filename and editable notes for a selected LoRA. Connect any output from a LoRA Loader/Stacker/Randomizer/ WanVideoSelect to the lora_source input — selecting a LoRA in the source widget updates the info display automatically. - Python node (LoraInfoLM): display-only, no workflow execution - Vue widget: filename label, auto-sizing notes textarea, save button with ComfyUI toast feedback on save - Frontend extension: wire-based selection propagation with stale-response race guard; clears display on wire disconnect - Backend: get-notes endpoint now returns file_path alongside notes; matching supports full-path lora syntax; fix NoneType crash in trigger words endpoint; document cache file_name invariant - Wired into all four lora widget nodes (Loader, Stacker, Randomizer, WanVideoSelect)
This commit is contained in:
182
web/comfyui/lora_info.js
Normal file
182
web/comfyui/lora_info.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import {
|
||||
getLinkFromGraph,
|
||||
chainCallback,
|
||||
} from "./utils.js";
|
||||
|
||||
const LORA_INFO_CLASS = "Lora Info (LoraManager)";
|
||||
|
||||
/**
|
||||
* Find Lora Info nodes directly connected to the given node's outputs.
|
||||
* Mirrors the getConnectedTriggerToggleNodes pattern from utils.js.
|
||||
* @param {object} node - The source node to check outputs from
|
||||
* @returns {object[]} Array of connected Lora Info node instances
|
||||
*/
|
||||
export function getConnectedLoraInfoNodes(node) {
|
||||
const connectedNodes = [];
|
||||
|
||||
if (!node?.outputs) {
|
||||
return connectedNodes;
|
||||
}
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (!output?.links?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = getLinkFromGraph(node.graph, linkId);
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetNode = node.graph?.getNodeById?.(link.target_id);
|
||||
if (targetNode && targetNode.comfyClass === LORA_INFO_CLASS) {
|
||||
connectedNodes.push(targetNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connectedNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch notes for the selected lora and push them to all directly connected
|
||||
* Lora Info nodes (no recursive chain traversal — only direct connections).
|
||||
* @param {object} node - The source LoRA Loader/Stacker node
|
||||
* @param {object|null} selection - The current lora selection {name, active, entry}
|
||||
*/
|
||||
export async function updateConnectedLoraInfoNodes(node, selection) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const infoNodes = getConnectedLoraInfoNodes(node);
|
||||
|
||||
if (infoNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No selection or inactive — clear the display on all connected info nodes
|
||||
if (!selection?.name || !selection?.active) {
|
||||
for (const infoNode of infoNodes) {
|
||||
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
|
||||
if (typeof infoNode._setLoraInfo === "function") {
|
||||
infoNode._setLoraInfo(null);
|
||||
} else {
|
||||
infoNode.__pendingLoraInfo = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Bump request token on each info node to guard against stale async responses
|
||||
for (const infoNode of infoNodes) {
|
||||
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
|
||||
}
|
||||
const reqIdSnapshot = new Map();
|
||||
for (const infoNode of infoNodes) {
|
||||
reqIdSnapshot.set(infoNode, infoNode.__loraInfoReqId);
|
||||
}
|
||||
|
||||
// Fetch notes for the selected lora
|
||||
try {
|
||||
const response = await api.fetchApi(
|
||||
`/lm/loras/get-notes?name=${encodeURIComponent(selection.name)}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Failed to fetch notes for ${selection.name}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const infoData = {
|
||||
name: selection.name,
|
||||
notes: data?.notes || "",
|
||||
filePath: data?.file_path || "",
|
||||
};
|
||||
|
||||
for (const infoNode of infoNodes) {
|
||||
// Discard if a newer request has been issued for this node
|
||||
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof infoNode._setLoraInfo === "function") {
|
||||
infoNode._setLoraInfo(infoData);
|
||||
} else {
|
||||
infoNode.__pendingLoraInfo = infoData;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching notes for lora info:", error);
|
||||
|
||||
const errorData = {
|
||||
name: selection.name,
|
||||
notes: "[Error loading notes]",
|
||||
filePath: "",
|
||||
};
|
||||
|
||||
for (const infoNode of infoNodes) {
|
||||
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof infoNode._setLoraInfo === "function") {
|
||||
infoNode._setLoraInfo(errorData);
|
||||
} else {
|
||||
infoNode.__pendingLoraInfo = errorData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraInfo",
|
||||
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== LORA_INFO_CLASS) {
|
||||
return;
|
||||
}
|
||||
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
// Add wire-only input for receiving connections from LoRA nodes
|
||||
this.addInput("lora_source", "*", { shape: 7 });
|
||||
|
||||
// Forward lora info data to the Vue widget when available.
|
||||
this._setLoraInfo = function (data) {
|
||||
const widget = this.widgets?.find(
|
||||
(w) => w.type === "LORA_INFO_DISPLAY"
|
||||
);
|
||||
if (widget) {
|
||||
if (typeof widget._setLoraInfo === "function") {
|
||||
widget._setLoraInfo(data);
|
||||
} else {
|
||||
widget.__pendingLoraInfo = data;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// When the lora_source wire is disconnected, clear the display.
|
||||
const origOnConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
|
||||
if (origOnConnectionsChange) {
|
||||
origOnConnectionsChange.apply(this, arguments);
|
||||
}
|
||||
// type 1 = input connection change; disconnected = !connected
|
||||
if (type === 1 && !connected) {
|
||||
const input = this.inputs?.[index];
|
||||
if (input?.name === "lora_source") {
|
||||
// Check if any lora_source input still has a connection
|
||||
const hasLoraSourceConnection = this.inputs?.some(
|
||||
(inp) => inp.name === "lora_source" && inp.link != null
|
||||
);
|
||||
if (!hasLoraSourceConnection) {
|
||||
this._setLoraInfo?.(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
||||
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraLoader",
|
||||
@@ -185,8 +186,10 @@ app.registerExtension({
|
||||
this,
|
||||
"loras",
|
||||
{
|
||||
onSelectionChange: (selection) =>
|
||||
applySelectionHighlight(this, selection),
|
||||
onSelectionChange: (selection) => {
|
||||
applySelectionHighlight(this, selection);
|
||||
updateConnectedLoraInfoNodes(this, selection);
|
||||
},
|
||||
},
|
||||
(value) => {
|
||||
// Prevent recursive calls
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
||||
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraStacker",
|
||||
@@ -64,8 +65,10 @@ app.registerExtension({
|
||||
this,
|
||||
"loras",
|
||||
{
|
||||
onSelectionChange: (selection) =>
|
||||
applySelectionHighlight(this, selection),
|
||||
onSelectionChange: (selection) => {
|
||||
applySelectionHighlight(this, selection);
|
||||
updateConnectedLoraInfoNodes(this, selection);
|
||||
},
|
||||
},
|
||||
(value) => {
|
||||
// Prevent recursive calls
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ import {
|
||||
} from "./utils.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.WanVideoLoraSelect",
|
||||
@@ -63,7 +64,11 @@ app.registerExtension({
|
||||
}
|
||||
});
|
||||
|
||||
const result = addLorasWidget(this, "loras", {}, (value) => {
|
||||
const result = addLorasWidget(this, "loras", {
|
||||
onSelectionChange: (selection) => {
|
||||
updateConnectedLoraInfoNodes(this, selection);
|
||||
},
|
||||
}, (value) => {
|
||||
// Prevent recursive calls
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
|
||||
Reference in New Issue
Block a user