A picture kept as a combo keeps its nodes, every value as it was

Keep -> Its combo now saves the nodes upstream of the picture's samplers' model and prompt
inputs (loaders, LoRA chains, CLIP skip, both prompts) as a node-structure combo, values
untouched; models are named by where they are here, missing ones kept by the picture's name.
A workflow without a sampler still gives the simple combo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-19 20:37:46 +08:00
co-authored by Claude Opus 5.5
parent b5f66fae91
commit 7a59068ebb
11 changed files with 210 additions and 25 deletions
+5
View File
@@ -527,6 +527,11 @@ covered by the `styles.css` manifest.
named by `comboTitle` — first LoRA or main model · two telling tags — and renamed by `renameCombo`;
its prompts as a saved prompt, reading what was kept) without DOM. A new combo's toast is
`ui_kept_toast.js` (the image's thumbnail, the name in a box to rename it on the spot, Open).
An image becomes a node-structure combo: `sampler_inputs.js` (no DOM) picks the nodes upstream
of its samplers' model / positive / negative / conditioning inputs (not through images,
latents or masks), and `image_keep_nodes.js` saves them with `captureStructure`, every value
as the image had it, models renamed to where they are on this computer; a workflow with no
sampler gives the older main model + LoRAs + prompt combo.
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
? (what a combo is), New, and a card per combo with its model's cover (one kept from an output image shows that image, the model's cover as a badge) and Put on canvas; a card opens the combo's editor, and dragged onto the
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
+5
View File
@@ -22,6 +22,11 @@
### ★ See only the kept pictures (图库只看收藏)
- **★ Kept** beside the gallery's search lists only the pictures you kept as a workflow, combo or prompt (the ★ on their cards). Works with the search and with Pictures / Videos. Pointing at a card's ★ says what it was kept as.
### 📌 A picture kept as a combo keeps its nodes as they were (图片存搭配,节点原样记住)
- **Keep → Its combo** now keeps the nodes that made the picture's model and prompts: the model and CLIP loaders, the whole LoRA chain (Power Lora Loader and other packs' nodes too), CLIP skip and model patches, the positive **and** negative prompt nodes, with every value as the picture had it. The sampler, size and decoding are left out, so Put on canvas gives a group you wire to your own sampler, like a combo saved from the canvas.
- It used to keep only the main model, the LoRAs' names and the positive prompt: LoRA strengths came back as 1.0, and the negative prompt and CLIP skip were lost. **A combo only remembers; it never changes a value** (now one of the plugin's rules). Combos kept before stay as they are.
- Models are named by where they are on this computer; one this computer lacks is kept by the picture's name and said after keeping. A workflow without a sampler to read from still gives the simple kind.
### ⭐ Combos kept from a picture are easy to tell apart (图片存的搭配一眼认得出)
- **The card shows the picture you kept it from**, with the main model's cover as a small round badge in its corner, instead of the model's cover alone (several combos of one model used to look the same).
- **A name that says which one it is**: the first LoRA (or the main model) and the prompt's first two telling tags, like *Ahri · fox ears, kimono*, skipping words nearly every picture has (*1girl*, *solo*, *looking at viewer*…).
+5 -2
View File
@@ -116,8 +116,11 @@ override, which travels into the saved record but never writes back to a Recipe.
The gallery's star and the image workbench's Keep (`ui_keep_menu.js`) keep an output PNG
as one of three things, each recording the image as its source: the whole workflow as a
Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive
prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
Workflow Recipe (`recipe_save.js`, `source_image`), the nodes that make its sampler's model
and prompts as a node-structure combo (`image_keep_nodes.js`, `sampler_inputs.js`: loaders,
LoRA chains, CLIP skip, model patches and both prompt nodes, every value as it was; a combo
never changes a value) — or, for a workflow with no sampler, its main model, LoRAs and
positive prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
`/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps
`source_image`, which the Combos card and the editor's title show; the name is the first
LoRA's (else the main model's) · the prompt's first two tags that are neither quality words
+43
View File
@@ -0,0 +1,43 @@
// What a combo kept from an output image holds (web/modules/sampler_inputs.js).
import assert from 'node:assert/strict';
import { isSampler, samplerSideNodes } from '../web/modules/sampler_inputs.js';
// A small graph: links [id, from, to, type]; inputs name the link feeding them.
function graph(nodes, links) {
const byId = new Map(links.map(([id, from, , type]) => [id, { id, origin_id: from, type }]));
return {
_nodes: nodes.map(([id, type, inputs = {}]) => ({ id, type, inputs: Object.entries(inputs).map(([name, link]) => ({ name, link })) })),
getLink: id => byId.get(id) ?? null,
};
}
const g = graph([
[1, 'CheckpointLoaderSimple'],
[2, 'LoraLoader', { model: 1, clip: 2 }],
[3, 'CLIPSetLastLayer', { clip: 3 }],
[4, 'CLIPTextEncode', { clip: 4 }],
[5, 'CLIPTextEncode', { clip: 5 }],
[6, 'EmptyLatentImage'],
[7, 'KSampler', { model: 6, positive: 7, negative: 8, latent_image: 9 }],
[8, 'VAEDecode', { samples: 10, vae: 11 }],
[9, 'LoadImage'],
[10, 'ControlNetApply', { conditioning: 12, image: 13 }],
[11, 'Note'],
], [
[1, 1, 2, 'MODEL'], [2, 1, 2, 'CLIP'], [3, 2, 3, 'CLIP'], [4, 3, 4, 'CLIP'], [5, 3, 5, 'CLIP'],
[6, 2, 7, 'MODEL'], [7, 10, 7, 'CONDITIONING'], [8, 5, 7, 'CONDITIONING'], [9, 6, 7, 'LATENT'],
[10, 7, 8, 'LATENT'], [11, 1, 8, 'VAE'], [12, 4, 10, 'CONDITIONING'], [13, 9, 10, 'IMAGE'],
]);
assert.equal(isSampler(g._nodes[6]), true);
assert.equal(isSampler(g._nodes[1]), false);
// Loaders, the LoRA, CLIP skip, both prompts and what sits between them and the sampler;
// not the sampler, the latent, the decode, the image loader or a note.
assert.deepEqual(samplerSideNodes(g).map(node => node.id), [1, 2, 3, 4, 5, 10]);
// A workflow without a sampler: nothing to keep.
assert.deepEqual(samplerSideNodes(graph([[1, 'CheckpointLoaderSimple']], [])), []);
// A guider (Flux): model and conditioning.
const flux = graph([[1, 'UNETLoader'], [2, 'CLIPTextEncode'], [3, 'BasicGuider', { model: 1, conditioning: 2 }]],
[[1, 1, 3, 'MODEL'], [2, 2, 3, 'CONDITIONING']]);
assert.deepEqual(samplerSideNodes(flux).map(node => node.id), [1, 2]);
console.log('sampler inputs: ok');
+39 -17
View File
@@ -1,8 +1,10 @@
/**
* Keeping an output image (the gallery's star, the image workbench) as one of three things:
* its whole workflow as a Workflow Recipe (recipe_save.js), its main model, LoRAs and
* positive prompt as a combo (搭配, a file of GET /anomalous/notebooks), or its prompts as a
* saved prompt (POST /anomalous/save_prompt_plan). The image's workflow is laid on a
* its whole workflow as a Workflow Recipe (recipe_save.js), the nodes that make its sampler's
* model and prompts as a combo (搭配, a file of GET /anomalous/notebooks; image_keep_nodes.js,
* every value as the image had it; a workflow without a sampler to read from gives its main
* model, LoRAs and positive prompt), or its prompts as a saved prompt
* (POST /anomalous/save_prompt_plan). The image's workflow is laid on a
* canvas of its own and read like a recipe saved from it (recipe_parser.js); its models are
* looked up among this computer's files. Each thing kept remembers its image, so
* GET /anomalous/kept_images can say what an image was kept as. No DOM.
@@ -40,9 +42,10 @@ const fileStem = name => String(name || '').split(/[\\/]/).pop().replace(/\.[^.]
/** What the image's workflow uses (extractRecipeMetadata) and a name: its prompt's first tags, else the file's. */
async function readImage(sourceImage) {
const info = await jsonResponse(await post('/anomalous/inspect_image_material', { source_image: sourceImage }), 'read image workflow');
const metadata = extractRecipeMetadata(detachedGraph(info.workflow));
const graph = detachedGraph(info.workflow);
const metadata = extractRecipeMetadata(graph);
const name = promptTitle(metadata.promptPositive[0]) || fileStem(sourceImage?.filename);
return { name, metadata };
return { name, metadata, graph };
}
/** A model's name as its card shows it: the name the user gave it, else the file's. */
@@ -85,16 +88,41 @@ export function freeComboName(name, combos) {
}
}
/** Saves combo `data` under a free name from `wanted`. Returns { filename, name }. */
async function saveCombo(data, wanted, combos) {
const comboName = freeComboName(wanted, combos);
const filename = `${comboName}.json`;
const response = await post('/anomalous/save_notebook', { filename, name: comboName, data: { ...data, name: comboName } });
if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed');
return { filename, name: comboName };
}
/**
* Keeps the image's main model, LoRAs and positive prompt as a combo; an image kept before
* gives that combo back (`existed`). Models this computer does not have are left out and
* named in `missing`. Returns { filename, name, existed, missing }.
* Keeps the image as a combo of the nodes that make its sampler's model and prompts, every
* value as it was; an image kept before gives that combo back (`existed`). Models this
* computer does not have are named in `missing` (kept by the workflow's name, `missingKept`). A workflow
* without a sampler to read from gives a combo of its main model, LoRAs and positive prompt.
* Returns { filename, name, existed, missing }.
*/
export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
const combos = await listCombos();
const kept = combos.find(note => sameImage(note.data?.source_image, sourceImage));
if (kept) return { filename: kept.filename, name: kept.name, existed: true, missing: [] };
const image = await readImage(sourceImage);
const prompt = image.metadata.promptPositive.join('\n');
// Loaded only here: it reaches the canvas modules (no DOM in this file, and tests import it).
const { keepGraphNodes } = await import('./image_keep_nodes.js');
const nodes = await keepGraphNodes(image.graph, localModels);
if (nodes) {
const title = comboTitle({ prompt, mainModel: nodes.mainModel, loras: nodes.loras, fallback: image.name });
const saved = await saveCombo({ ...nodes.data, source_image: sourceImage }, name || title, combos);
return { ...saved, existed: false, missing: nodes.missing, missingKept: true };
}
return keepSimpleCombo(sourceImage, image, prompt, name, combos);
}
/** The older kind of combo: the image's main model, LoRAs and positive prompt. */
async function keepSimpleCombo(sourceImage, image, prompt, name, combos) {
const main = image.metadata.baseModel || '';
const loraPaths = [...new Set((image.metadata.loras || []).map(lora => lora.name).filter(Boolean))];
const [mains, loras] = await Promise.all([
@@ -103,22 +131,16 @@ export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
]);
const mainModel = mains[main] || null;
const localLoras = loraPaths.filter(path => loras[path]).map(path => loras[path]);
const prompt = image.metadata.promptPositive.join('\n');
const comboName = freeComboName(name || comboTitle({ prompt, mainModel, loras: localLoras, fallback: image.name }), combos);
const data = {
name: comboName,
const saved = await saveCombo({
baseModel: mainModel ? String(mainModel.metadata?.baseModel || '').trim() || UNLABELED_BASE_MODEL : '',
mainModel,
loras: localLoras,
promptEn: prompt,
promptZh: '',
source_image: sourceImage,
};
const filename = `${comboName}.json`;
const response = await post('/anomalous/save_notebook', { filename, name: comboName, data });
if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed');
}, name || comboTitle({ prompt, mainModel, loras: localLoras, fallback: image.name }), combos);
const missing = [...(main && !mainModel ? [main] : []), ...loraPaths.filter(path => !loras[path])];
return { filename, name: comboName, existed: false, missing };
return { ...saved, existed: false, missing };
}
/** Gives the combo kept in `filename` the name `name` (its file keeps its name). Returns the name. */
+55
View File
@@ -0,0 +1,55 @@
/**
* An output image kept as a combo of its own nodes (image_keep.js keepImageAsCombo): the part
* of its workflow that makes the sampler's model and prompts (sampler_inputs.js), laid on a
* graph of its own, saved like nodes picked on the canvas (combo_structure.js) with every value
* as the image had it: LoRA strengths, CLIP skip, the negative prompt. A combo never changes a
* value (AGENTS.md, 功能原则). Models are looked up among this computer's files: one found
* there is named by where it is here; one not found keeps the workflow's name and is reported.
*/
import { captureStructure } from './combo_structure.js';
import { NONE, STRUCTURED } from './combo_slots.js';
import { slotLabel } from './ui_combo_structure.js';
import { samplerSideNodes } from './sampler_inputs.js';
const MAIN_FOLDERS = new Set(['checkpoints', 'unet', 'diffusion_models']);
const relOf = model => [String(model.subfolder || '').replace(/^\/+|\/+$/g, ''), model.filename].filter(Boolean).join('/');
/**
* The combo data of `graph`'s sampler side, or null when the workflow has no sampler to read
* from. `localModels(paths, folders)` gives { path → this computer's model }. Returns { data:
* { kind, structure, values, covers }, mainModel, loras, missing }.
*/
export async function keepGraphNodes(graph, localModels) {
const nodes = samplerSideNodes(graph);
if (!nodes.length) return null;
const { structure, values } = await captureStructure(nodes, { labelFor: slotLabel });
if (!structure) return null;
// An empty row of a stack node is no slot (as when saving from the canvas, unticked).
const slots = structure.slots.filter(slot => !(slot.optional && NONE.test(String(values[slot.id] ?? ''))));
const kept = Object.fromEntries(slots.map(slot => [slot.id, values[slot.id]]));
const covers = {};
const missing = [];
let mainModel = null;
const loras = [];
const models = slots.filter(slot => slot.kind === 'model' && !NONE.test(String(kept[slot.id] ?? '')));
const folders = [...new Set(models.map(slot => slot.folder).filter(Boolean))];
const found = await localModels([...new Set(models.map(slot => kept[slot.id]))], folders);
for (const slot of models) {
const model = found[kept[slot.id]];
if (!model) {
missing.push(kept[slot.id]);
continue;
}
kept[slot.id] = relOf(model);
if (model.preview_url) covers[slot.id] = model.preview_url;
if (!mainModel && MAIN_FOLDERS.has(slot.folder)) mainModel = model;
if (slot.folder === 'loras') loras.push(model);
}
return {
data: { kind: STRUCTURED, structure: { ...structure, slots }, values: kept, covers },
mainModel,
loras,
missing,
};
}
+4 -2
View File
@@ -820,7 +820,7 @@ export const i18n = {
keepAs_recipe: "整个工作流",
keepAs_recipeHint: "存成工作流配方,在“工作流”页,可以整个打开",
keepAs_combo: "模型搭配",
keepAs_comboHint: "主模型 + LoRA + 正向提示词,在“搭配”页一键放到画布",
keepAs_comboHint: "出这张图的模型、LoRA 和正负提示词节点,数值原样记下,在“搭配”页一键放到画布",
keepAs_prompt: "只要提示词",
keepAs_promptHint: "正向和负向提示词,存进提示词库",
keepKeptAs: "已存:{name},点这里打开",
@@ -852,6 +852,7 @@ export const i18n = {
keptAs_prompt: "已存下提示词:{name}",
keepExisted: "之前就存过了:{name}",
keepComboMissing: "有 {count} 个模型本机没有,搭配里先空着。",
keepComboMissingNamed: "有 {count} 个模型这台电脑没有,先按原图的名字记着;在搭配里点它换一个本机的模型就行。",
keepOpen: "打开",
keepRemove: "取消收藏",
keepRemoveHint: "把存下的这一项移到系统回收站",
@@ -3632,7 +3633,7 @@ export const i18n = {
keepAs_recipe: "The whole workflow",
keepAs_recipeHint: "As a Workflow Recipe on the Workflows page, to open whole",
keepAs_combo: "Its combo",
keepAs_comboHint: "Main model, LoRAs and positive prompt, on the Combos page",
keepAs_comboHint: "The nodes that made its model and prompts, every value as it was, on the Combos page",
keepAs_prompt: "Just the prompts",
keepAs_promptHint: "The positive and negative prompts, in your saved prompts",
keepKeptAs: "Kept as {name}; open it",
@@ -3664,6 +3665,7 @@ export const i18n = {
keptAs_prompt: "Kept the prompts: {name}",
keepExisted: "Kept before: {name}",
keepComboMissing: "{count} of its models are not on this computer, so the combo leaves them out.",
keepComboMissingNamed: "{count} of its models are not on this computer; the combo keeps them by the picture's names; click one in the combo to choose a model of yours.",
keepOpen: "Open",
keepRemove: "Remove",
keepRemoveHint: "Move what was kept to the Recycle Bin",
+46
View File
@@ -0,0 +1,46 @@
/**
* The part of a workflow that makes a sampler's model and prompts (what a combo kept from an
* output image holds): every node upstream of a sampler's model / positive / negative /
* conditioning inputs — model and CLIP loaders, LoRA chains, CLIP skip and model patches, the
* prompt nodes — but not the sampler itself, nor anything reached only through an image,
* latent or mask (image loaders, sizes, VAE decoding). A sampler is a node with a model input
* and a positive or conditioning input (KSampler, KSampler Advanced, CFG / Basic guiders).
* No DOM; `graph` is a LiteGraph graph (or anything with nodes and getLink / links).
*/
const FEEDS = new Set(['model', 'positive', 'negative', 'conditioning']);
const CUT = new Set(['IMAGE', 'LATENT', 'MASK']);
const inputNames = node => (node.inputs || []).map(input => input?.name);
/** Whether `node` is a sampler (or a guider) whose model and prompts a combo keeps. */
export function isSampler(node) {
const names = inputNames(node);
return names.includes('model') && (names.includes('positive') || names.includes('conditioning'));
}
/** The nodes upstream of the samplers' model and prompt inputs, in the graph's order. */
export function samplerSideNodes(graph) {
const nodes = graph?._nodes || graph?.nodes || [];
const byId = new Map(nodes.map(node => [node.id, node]));
const linkOf = id => graph.getLink?.(id) ?? graph.links?.[id] ?? null;
const samplers = new Set(nodes.filter(isSampler).map(node => node.id));
const kept = new Set();
const waiting = [];
const follow = (input) => {
if (input?.link == null) return;
const link = linkOf(input.link);
if (!link || CUT.has(String(link.type ?? input.type ?? '').toUpperCase())) return;
waiting.push(link.origin_id);
};
for (const id of samplers) {
for (const input of byId.get(id).inputs || []) if (FEEDS.has(input?.name)) follow(input);
}
while (waiting.length) {
const id = waiting.pop();
if (kept.has(id) || samplers.has(id) || !byId.has(id)) continue;
kept.add(id);
for (const input of byId.get(id).inputs || []) follow(input);
}
return nodes.filter(node => kept.has(node.id));
}
+1 -1
View File
@@ -91,7 +91,7 @@ function comboCard(owner, note) {
});
use.title = t('comboUseHint');
body.append(use);
card.append(structured ? cover({ preview_url: structured.cover }) : cover(data.mainModel, data.source_image), body);
card.append(cover(structured ? { preview_url: structured.cover } : data.mainModel, data.source_image), body);
bindMaterialDrag(card, owner, {
payload: () => ({ type: 'combo', filename: note.filename, dragHint: t('comboDragHint'), dragTargetHint: t('comboDragCanvas') }),
accepts: () => false,
+5 -2
View File
@@ -41,10 +41,13 @@ function place(menu, anchor) {
menu.style.top = `${below + height > window.innerHeight - 8 ? Math.max(8, box.top - height - 6) : below}px`;
}
/** The models a new combo names that this computer lacks: kept by the picture's name (nodes), or left out. */
const missingWords = result => t(result.missingKept ? 'keepComboMissingNamed' : 'keepComboMissing', { count: result.missing.length });
function keptMessage(spec, result) {
if (result.existed) return t('keepExisted', { name: result.name });
const message = t(`keptAs_${spec.kind}`, { name: result.name });
return result.missing?.length ? `${message} ${t('keepComboMissing', { count: result.missing.length })}` : message;
return result.missing?.length ? `${message} ${missingWords(result)}` : message;
}
async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen }) {
@@ -62,7 +65,7 @@ async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen }
};
// A new combo's name is a guess (image_keep.js comboTitle): it can be renamed right here.
if (spec.kind === 'combo' && !result.existed) {
const missing = result.missing?.length ? t('keepComboMissing', { count: result.missing.length }) : '';
const missing = result.missing?.length ? missingWords(result) : '';
showKeptComboToast(sourceImage, kept.combo, {
message: [t('keptAs_comboShort'), missing].filter(Boolean).join(' '),
onRenamed: (name) => {
+2 -1
View File
@@ -37,7 +37,8 @@ function comboSearchItem(note) {
const data = note.data || {};
if (data.kind === STRUCTURED) {
const meta = structuredComboMeta(data);
return comboItem(note, { detail: meta.meta, thumb: meta.cover, text: meta.prompt });
const thumb = data.source_image?.filename ? outputThumbnail(data.source_image) : meta.cover;
return comboItem(note, { detail: meta.meta, thumb, text: meta.prompt });
}
const loras = (data.loras || []).length;
const detail = [modelName(data.mainModel), loras ? t('comboLoraCount', { count: loras }) : ''].filter(Boolean).join(' · ');