Files
DemonGatanjieu-Anomalous_Mo…/web/modules/graph_splice.js
T
DemonGatanjieuandClaude Opus 5.5 fa11c55293 feat(lora): one way to insert a LoRA, for Current node and MCP alike
Current node's "Insert LoRA before / after" refused the most common workflows: a
checkpoint whose CLIP feeds both prompts (one link per output only) and UNet workflows
with their own CLIP loader (MODEL and CLIP had to come from one node).

- graph_splice.js: planLoraInsertion decides the links (after a node: every link of its
  MODEL output; before one: only its own MODEL input) and, for LoRAs that train the text
  encoder, the CLIP line (same node, a UNet workflow's one CLIP source, or before a
  sampler the source its prompts share); spliceLora wires it, all or nothing. The old
  analyzeModelChainInsertion / spliceModelChainNode / spliceAfterOutputs are gone.
- api/lora_info.py: GET /anomalous/lora_info reads the LoRA's safetensors header; the
  picker asks it on confirm and inserts a model-only loader when there are no
  text-encoder weights. The MCP add_lora uses the same helper.
- Insertion messages speak of the MODEL line; the fan-out ones are removed.
- tests/graph_splice.mjs (local) covers checkpoint, UNet + CLIP loader, before a
  sampler, and the rollback.

Verified on 8199 through the Current node picker: after a checkpoint feeding two prompts
(full LoRA, both prompts moved) and before a sampler (model-only Flux LoRA).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:00:32 +08:00

246 lines
11 KiB
JavaScript

/**
* Where a LoRA loader goes into a workflow, and putting it there (Current node's "Insert LoRA"
* and the MCP add_lora). A LoRA patches the model line (MODEL) and, when its file also trains
* the text encoder, the CLIP line. The two lines may come from one node (a checkpoint, a full
* LoRA loader) or from two (a UNet loader and its own CLIP loader). Every link moved keeps
* going, now through the LoRA. Slots are found by declared type, never by index or name.
*/
const MAIN_LOADER = /checkpointloader|unetloader|unetloadergguf|diffusionmodelloader/i;
const BYPASSED = 4;
function normalizeSlotType(type) {
return String(type ?? '').trim().toUpperCase();
}
function findSlotIndex(slots, requiredType) {
const wanted = normalizeSlotType(requiredType);
return Array.isArray(slots)
? slots.findIndex(slot => normalizeSlotType(slot?.type) === wanted)
: -1;
}
function getGraphLink(graph, linkId) {
if (linkId === null || linkId === undefined) return null;
return graph?.getLink?.(linkId) || graph?.links?.[linkId] || graph?._links?.[linkId] || null;
}
function getGraphNode(graph, nodeId) {
if (graph?.getNodeById) return graph.getNodeById(nodeId) || null;
return Array.isArray(graph?._nodes)
? graph._nodes.find(node => node?.id === nodeId) || null
: null;
}
/** The nodes an output feeds: [{ targetNode, targetSlot }]. */
function outputLinks(graph, node, slot) {
return (node.outputs?.[slot]?.links || [])
.map(id => getGraphLink(graph, id))
.filter(Boolean)
.map(link => ({ targetNode: getGraphNode(graph, link.target_id), targetSlot: link.target_slot }))
.filter(link => link.targetNode);
}
/** The output an input is connected to: { node, slot }, or null. */
function inputSource(graph, node, slot) {
const link = getGraphLink(graph, node.inputs?.[slot]?.link);
const origin = link && getGraphNode(graph, link.origin_id);
return origin ? { node: origin, slot: link.origin_slot } : null;
}
/** Follows `type` from `node` through the LoRA loaders chained after it: the last of them. */
export function chainEnd(graph, node, type) {
let end = node;
for (let hop = 0; hop < 20; hop++) {
const slot = findSlotIndex(end.outputs, type);
const targets = slot < 0 ? [] : outputLinks(graph, end, slot);
if (targets.length !== 1 || !/lora/i.test(targets[0].targetNode.type || '')) break;
end = targets[0].targetNode;
}
return end;
}
/** The workflow's main model loaders (checkpoint, UNet), bypassed ones left out. */
export function mainModelLoaders(graph) {
return (graph?._nodes || []).filter(node => MAIN_LOADER.test(node.type || '') && node.mode !== BYPASSED);
}
/** The canvas's one CLIP source (a loader taking no MODEL or CLIP), past its LoRAs; null unless exactly one. */
function soleClipEnd(graph) {
const sources = (graph?._nodes || []).filter(node => node.mode !== BYPASSED && findSlotIndex(node.outputs, 'CLIP') >= 0
&& findSlotIndex(node.inputs, 'CLIP') < 0 && findSlotIndex(node.inputs, 'MODEL') < 0);
if (sources.length !== 1) return null;
const end = chainEnd(graph, sources[0], 'CLIP');
return { node: end, slot: findSlotIndex(end.outputs, 'CLIP') };
}
/**
* The CLIP that goes through a LoRA inserted before `anchor`: its own CLIP input, or, for a
* sampler (none), the CLIP inputs of the prompt nodes wired into it when one source feeds them all.
*/
function clipBefore(graph, anchor) {
const own = findSlotIndex(anchor.inputs, 'CLIP');
if (own >= 0) {
const source = inputSource(graph, anchor, own);
return source && { ...source, links: [{ targetNode: anchor, targetSlot: own }] };
}
const prompts = new Set((anchor.inputs || []).map((_input, index) => inputSource(graph, anchor, index)?.node)
.filter(node => node && findSlotIndex(node.inputs, 'CLIP') >= 0));
const links = [];
const sources = new Map();
for (const node of prompts) {
const slot = findSlotIndex(node.inputs, 'CLIP');
const source = inputSource(graph, node, slot);
if (!source) continue;
sources.set(`${source.node.id}:${source.slot}`, source);
links.push({ targetNode: node, targetSlot: slot });
}
return sources.size === 1 ? { ...[...sources.values()][0], links } : null;
}
function unsupported(direction, code) {
return { supported: false, direction, code, outputs: [], clip: false };
}
/**
* Where a LoRA loader goes before or after `anchorNode`: { supported, code, direction,
* outputs, clip }. Each of `outputs` ({ node, slot, type, links }) feeds the LoRA's input of
* `type`, and its `links` ({ targetNode, targetSlot }) then come from the LoRA's output.
* After: every link of the anchor's MODEL output; with `textEncoder`, also every link of its
* CLIP output, or of the canvas's one CLIP source when it has none (a UNet loader).
* Before: only the anchor's own MODEL input; with `textEncoder`, the CLIP from clipBefore.
* `clip` says whether the CLIP line goes through (a full LoRA loader, else a model-only one).
*/
export function planLoraInsertion(graph, anchorNode, direction, { textEncoder = true } = {}) {
if (!graph || !anchorNode) return unsupported(direction, 'missing_graph_or_node');
const outputs = [];
if (direction === 'after') {
const slot = findSlotIndex(anchorNode.outputs, 'MODEL');
if (slot < 0) return unsupported(direction, 'missing_chain_outputs');
outputs.push({ node: anchorNode, slot, type: 'MODEL', links: outputLinks(graph, anchorNode, slot) });
if (textEncoder) {
const own = findSlotIndex(anchorNode.outputs, 'CLIP');
const clip = own >= 0 ? { node: anchorNode, slot: own } : soleClipEnd(graph);
if (clip) outputs.push({ ...clip, type: 'CLIP', links: outputLinks(graph, clip.node, clip.slot) });
}
} else if (direction === 'before') {
const slot = findSlotIndex(anchorNode.inputs, 'MODEL');
if (slot < 0) return unsupported(direction, 'missing_chain_inputs');
const source = inputSource(graph, anchorNode, slot);
if (!source) return unsupported(direction, 'unconnected_chain_inputs');
outputs.push({ ...source, type: 'MODEL', links: [{ targetNode: anchorNode, targetSlot: slot }] });
const clip = textEncoder ? clipBefore(graph, anchorNode) : null;
if (clip) outputs.push({ ...clip, type: 'CLIP' });
} else {
return unsupported(direction, 'invalid_direction');
}
return { supported: true, direction, code: 'ready', outputs, clip: outputs.length > 1 };
}
/** Whether a LoRA can go before / after the node (the model line decides). */
export function loraInsertionCapabilities(graph, anchorNode) {
return {
before: planLoraInsertion(graph, anchorNode, 'before', { textEncoder: false }),
after: planLoraInsertion(graph, anchorNode, 'after', { textEncoder: false }),
};
}
function assertInsertedNodeSlots(insertedNode, types) {
const slots = {};
for (const type of types) {
const input = findSlotIndex(insertedNode?.inputs, type);
const output = findSlotIndex(insertedNode?.outputs, type);
if (input < 0 || output < 0) {
const error = new Error(`Inserted node does not expose ${type} input/output slots.`);
error.code = 'inserted_node_missing_chain_slots';
throw error;
}
slots[type] = { input, output };
}
return slots;
}
function connectOrThrow(originNode, originSlot, targetNode, targetSlot, type) {
const link = originNode?.connect?.(originSlot, targetNode, targetSlot);
if (!link) {
const error = new Error(`Failed to connect ${type} while inserting the model node.`);
error.code = 'connection_failed';
error.channelType = type;
throw error;
}
}
function restoreConnections(originalConnections) {
for (const connection of originalConnections) {
connection.originNode?.connect?.(
connection.originSlot,
connection.targetNode,
connection.targetSlot,
);
}
}
function placeInsertedNode(graph, anchorNode, insertedNode, direction) {
const anchorX = Number(anchorNode?.pos?.[0]) || 0;
const anchorY = Number(anchorNode?.pos?.[1]) || 0;
const anchorWidth = Number(anchorNode?.size?.[0]) || 220;
const insertedWidth = Number(insertedNode?.size?.[0]) || 220;
const horizontalGap = 90;
let x = direction === 'before'
? anchorX - insertedWidth - horizontalGap
: anchorX + anchorWidth + horizontalGap;
let y = anchorY;
const overlaps = (candidateX, candidateY) => (graph?._nodes || []).some(node => {
if (!node || node === anchorNode || node === insertedNode) return false;
const nodeX = Number(node.pos?.[0]) || 0;
const nodeY = Number(node.pos?.[1]) || 0;
const nodeWidth = Number(node.size?.[0]) || 220;
const nodeHeight = Number(node.size?.[1]) || 120;
const insertedHeight = Number(insertedNode?.size?.[1]) || 120;
return candidateX < nodeX + nodeWidth + 20
&& candidateX + insertedWidth + 20 > nodeX
&& candidateY < nodeY + nodeHeight + 20
&& candidateY + insertedHeight + 20 > nodeY;
});
for (const offset of [0, 80, -80, 160, -160, 240, -240]) {
if (!overlaps(x, anchorY + offset)) {
y = anchorY + offset;
break;
}
}
insertedNode.pos = [x, y];
}
/**
* Puts `insertedNode` (a LoRA loader of the plan's kind) where `plan` (planLoraInsertion) says,
* as one graph change. All or nothing: on a failed connection the node goes and the original
* links come back.
*/
export function spliceLora({ graph, plan, anchorNode, insertedNode }) {
const slots = assertInsertedNodeSlots(insertedNode, plan.outputs.map(output => output.type));
const original = plan.outputs.flatMap(output => output.links.map(link => ({ originNode: output.node, originSlot: output.slot, ...link })));
let added = false;
graph.beforeChange?.(anchorNode);
try {
graph.add(insertedNode);
added = true;
placeInsertedNode(graph, anchorNode, insertedNode, plan.direction);
for (const output of plan.outputs) {
const { input, output: out } = slots[output.type];
connectOrThrow(output.node, output.slot, insertedNode, input, output.type);
for (const link of output.links) connectOrThrow(insertedNode, out, link.targetNode, link.targetSlot, output.type);
}
return insertedNode;
} catch (error) {
if (added) graph.remove?.(insertedNode);
restoreConnections(original);
throw error;
} finally {
graph.afterChange?.(anchorNode);
graph.change?.();
graph.setDirtyCanvas?.(true, true);
}
}