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:
Will Miao
2026-07-14 18:00:31 +08:00
parent b0c4510fdb
commit 419bbc90b2
14 changed files with 1285 additions and 280 deletions

182
web/comfyui/lora_info.js Normal file
View 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);
}
}
}
};
},
});

View File

@@ -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

View File

@@ -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

View File

@@ -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;