diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 35f5bea..77f1351 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -381,7 +381,8 @@ covered by the `styles.css` manifest.
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, flat material library archiving card with
clean single-icon feedback, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
- `notebook_canvas.js` owns LiteGraph creation. Prompt Notes open from the Material Library's top bar
+ `notebook_canvas.js` owns LiteGraph creation and putting a note into the open workflow, whose plan
+ (main loader, LoRA chain rebuilt in place, positive prompt box) is `notebook_apply.js`. Prompt Notes open from the Material Library's top bar
with defensive workspace return state restoration, TDZ-safe summary initialization, and responsive empty-state fallback rendering. `ui_recipe_detail.js`
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4416749..d261eb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -66,6 +66,7 @@
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI.
### Fixes
+- **Prompt Notes go into the workflow you have open (提示词笔记可以套到当前工作流)**: next to **New group of nodes** (formerly Send to canvas), **Put into this workflow** changes the open workflow instead: the main model loader takes the note's model, the LoRAs right after it become the note's (what they fed stays wired, and a LoRA the note keeps keeps its strengths), and the positive prompt box of that model's sampler takes the note's prompt. A window lists each change and anything left as is (and why) before anything happens; one Ctrl+Z undoes it. With several loaders or prompt boxes, the selected node decides. A new group from a UNet note now connects its prompt nodes to the canvas's CLIP loader when there is exactly one, and says so when not.
- **Recipe detail, plainer (配方详情更清爽)**: the models a recipe uses are one list: preview, name, the node, whether this computer has it under the saved name, size, your note and its download page. The recipe's own "match local model / apply match" buttons, the identity badges, the advanced blocks with paths and hashes, the origin editor and the recipe fingerprint are gone: when the workflow opens, Model Check finds renamed or moved models and puts them in, and saving the recipe again keeps that (the old version is kept). The status says "Every model is on this computer" or "N models missing" with what happens next. The save dialog and the empty page speak plainly (no more "unclosed node chain" or "self-healing"), and the parameters page lost its save-to-library button on every node (pick nodes with "select some parameters" instead).
- **Shared workflows say where their models come from (分享的工作流带上模型来源)**: saving a workflow or an image now also stores each model's download link (the one you set in the model editor, else its Civitai page) unless the workflow already keeps one, and drops links of models it no longer uses, so after Model Check swaps in your own model, what you export names your file, fingerprint and link while the workflow you opened stays as it was. GGUF, .pth and .sft models get fingerprints too. The share-code export says how many models carry a fingerprint, warns when some have none, and can leave the links out.
- **Model Check finds renamed models by fingerprint again (按指纹找回改名的模型)**: when a workflow's model was renamed or moved and no scan had hashed the file yet, Model Check failed to hash the same-size files itself (an error in the background) and called the model missing. It now hashes them and finds it.
diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md
index a769ff4..d540d56 100644
--- a/docs/architecture/frontend.md
+++ b/docs/architecture/frontend.md
@@ -73,7 +73,9 @@ The main surfaces are:
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
- Workspace: Prompt Notes, Workflow Recipes, and the Material Library. Prompt Note
catalog/persistence, editing, and canvas creation are separated across
- `ui_notebooks.js`, `ui_notebook_editor.js`, and `notebook_canvas.js`. Material
+ `ui_notebooks.js`, `ui_notebook_editor.js`, and `notebook_canvas.js`;
+ `notebook_apply.js` plans and makes a note's changes to the open workflow
+ (reads the canvas first, writes one Ctrl+Z step). Material
discovery/pagination, cards, detail, and node application are separated across
`ui_materials.js`, `ui_material_cards.js`, `ui_material_detail.js`, and
`ui_material_application.js`.
diff --git a/web/modules/browser.js b/web/modules/browser.js
index 61e36ba..5bdec46 100644
--- a/web/modules/browser.js
+++ b/web/modules/browser.js
@@ -9,7 +9,7 @@ import { openScanPage, renderScanPage, leaveScanPage } from './ui_scan_page.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
import { showNotebooks, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
-import { sendNotebookToCanvas } from './notebook_canvas.js';
+import { applyNotebookToWorkflow, sendNotebookToCanvas } from './notebook_canvas.js';
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
import { renderRecipeList, handleSaveRecipe } from './ui_recipes.js';
import { showMaterials, refreshMaterials, openSavedMaterial, openMaterialLibrary } from './ui_materials.js';
@@ -178,6 +178,7 @@ AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
+AnomalousBrowser.prototype.applyNotebookToWorkflow = applyNotebookToWorkflow;
AnomalousBrowser.prototype.showRecipes = showRecipes;
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
diff --git a/web/modules/locales.js b/web/modules/locales.js
index c2659f2..cd28bd1 100644
--- a/web/modules/locales.js
+++ b/web/modules/locales.js
@@ -598,7 +598,25 @@ export const i18n = {
renameNotebook: '重命名',
baseModel: '基础模型',
mainModel: '主模型',
- sendToCanvas: '发送到画布',
+ sendToCanvas: "新建一组节点",
+ noteApplyButton: "套到当前工作流",
+ noteApplyTitle: "把这份笔记套到当前工作流",
+ noteApplyModel: "主模型:{from} → {to}",
+ noteApplyLoras: "LoRA:{from} → {to}",
+ noteApplyNone: "(无)",
+ noteApplyPrompt: "正向提示词:换成笔记里的({count} 字)",
+ noteApplySkipped: "没改:{reason}",
+ noteApplyNoLoader: "画布上没有主模型加载节点",
+ noteApplyManyLoaders: "画布上有 {count} 个主模型加载节点,先选中要换的那个",
+ noteApplyKindMismatch: "笔记里的 {name} 和画布上的加载节点不是一类(Checkpoint / UNet),主模型没换",
+ noteApplyModelMissing: "本机找不到笔记里的主模型 {name}",
+ noteApplyLoraMissing: "本机找不到 LoRA {name},跳过了",
+ noteApplyNoPromptBox: "没找到正向提示词框",
+ noteApplyManyPromptBoxes: "有好几个正向提示词框,先选中要换的那个节点",
+ noteApplyNothing: "这份笔记和当前工作流一样,没有要改的。",
+ noteApplyUndoHint: "改完可以按 Ctrl+Z 撤销,“记录”里也能撤销。",
+ noteApplyConfirm: "套用",
+ noteNeedsClip: "UNet 模型不带文本编码器:画布上没有唯一的 CLIP 加载节点,提示词节点的 clip 要你自己接。",
translate: '翻译',
newNotebookName: '新笔记本名称',
noBaseModel: '请先选择 Base Model',
@@ -2525,7 +2543,25 @@ export const i18n = {
renameNotebook: 'Rename',
baseModel: 'Base Model',
mainModel: 'Main Model',
- sendToCanvas: 'Send to Canvas',
+ sendToCanvas: "New group of nodes",
+ noteApplyButton: "Put into this workflow",
+ noteApplyTitle: "Put this note into the open workflow",
+ noteApplyModel: "Main model: {from} → {to}",
+ noteApplyLoras: "LoRAs: {from} → {to}",
+ noteApplyNone: "(none)",
+ noteApplyPrompt: "Positive prompt: replaced with the note's ({count} characters)",
+ noteApplySkipped: "Left as is: {reason}",
+ noteApplyNoLoader: "the canvas has no main model loader",
+ noteApplyManyLoaders: "the canvas has {count} main model loaders; select the one to change first",
+ noteApplyKindMismatch: "{name} and the canvas loader are not the same kind (Checkpoint / UNet), so the main model stays",
+ noteApplyModelMissing: "the note's main model {name} is not on this computer",
+ noteApplyLoraMissing: "LoRA {name} is not on this computer, skipped",
+ noteApplyNoPromptBox: "no positive prompt box found",
+ noteApplyManyPromptBoxes: "several positive prompt boxes; select the node to change first",
+ noteApplyNothing: "The workflow already matches this note; nothing to change.",
+ noteApplyUndoHint: "Ctrl+Z undoes it, and so does the activity log.",
+ noteApplyConfirm: "Put in",
+ noteNeedsClip: "A UNet model has no text encoder and the canvas has no single CLIP loader, so connect the prompt nodes' clip yourself.",
translate: 'Translate',
newNotebookName: 'New Notebook Name',
noBaseModel: 'Select a Base Model first',
diff --git a/web/modules/notebook_apply.js b/web/modules/notebook_apply.js
new file mode 100644
index 0000000..47a1555
--- /dev/null
+++ b/web/modules/notebook_apply.js
@@ -0,0 +1,243 @@
+/**
+ * Puts a Prompt Note into the open workflow instead of building new nodes: the main model
+ * loader takes the note's model, the LoRA chain right after it is rebuilt with the note's
+ * LoRAs (what the chain fed keeps its wiring; a LoRA kept keeps its strengths), and the
+ * positive prompt box of the sampler that model reaches takes the note's prompt. A
+ * selected loader or prompt node is taken first. planNoteApply() only reads the canvas and
+ * says what would change; applyNotePlan() makes the change as one Ctrl+Z step.
+ */
+
+import { recordCanvasStep } from './canvas_history.js';
+import { inferModelFolderTypes } from './model_policies.js';
+import { promptBoxes, wiredRole } from './prompt_boxes.js';
+
+const MAIN_TYPES = ['checkpoints', 'diffusion_models', 'unet'];
+const MAX_HOPS = 12;
+
+const slashes = (value) => String(value).replace(/\\/g, '/');
+export const modelName = (value) => slashes(value).split('/').pop().replace(/\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$/i, '');
+
+/** A note model's value as a loader shows it: `subfolder/filename`. */
+export function notePath(model) {
+ const sub = String(model?.subfolder || '').replace(/^\/+|\/+$/g, '');
+ return sub ? `${sub}/${model.filename}` : String(model?.filename || '');
+}
+
+function modelWidget(node, types) {
+ return (node?.widgets || []).find(w => w.type === 'combo' && inferModelFolderTypes(node, w).some(type => types.includes(type))) || null;
+}
+
+function choices(values) {
+ return typeof values === 'function' ? values() : values;
+}
+
+function choiceFor(values, path) {
+ const wanted = slashes(path);
+ return (choices(values) || []).find(value => typeof value === 'string' && slashes(value) === wanted) || null;
+}
+
+const outSlot = (node, type) => (node.outputs || []).findIndex(output => output.type === type);
+const inSlot = (node, type) => (node.inputs || []).findIndex(input => input.type === type);
+const linkOf = (graph, id) => (id == null ? null : graph.getLink?.(id) ?? graph.links?.[id] ?? null);
+
+function linksFrom(graph, node, slot) {
+ if (slot < 0) return [];
+ return (node.outputs[slot].links || [])
+ .map(id => linkOf(graph, id))
+ .filter(Boolean)
+ .map(link => ({ node: graph.getNodeById(link.target_id), slot: link.target_slot }))
+ .filter(target => target.node);
+}
+
+const isLoraNode = (node) => Boolean(modelWidget(node, ['loras'])) && inSlot(node, 'MODEL') >= 0;
+
+/** LoRA loaders fed one after another from the loader's MODEL output. */
+function loraChain(graph, loader) {
+ const chain = [];
+ for (let current = loader; ;) {
+ const targets = linksFrom(graph, current, outSlot(current, 'MODEL'));
+ const next = targets.length === 1 ? targets[0] : null;
+ if (!next || !isLoraNode(next.node) || next.slot !== inSlot(next.node, 'MODEL')) return chain;
+ chain.push(next.node);
+ current = next.node;
+ }
+}
+
+/**
+ * The CLIP path through the chain: `source`/`slot` feed the first LoRA's CLIP (the loader
+ * when there is no chain), `end` is the last LoRA it passes. A chain without CLIP: null.
+ */
+function clipPath(graph, loader, chain) {
+ if (!chain.length) return { source: loader, slot: outSlot(loader, 'CLIP'), end: loader };
+ const first = linkOf(graph, chain[0].inputs?.[inSlot(chain[0], 'CLIP')]?.link);
+ const source = first && graph.getNodeById(first.origin_id);
+ if (!source) return null;
+ let end = chain[0];
+ for (const node of chain.slice(1)) {
+ const link = linkOf(graph, node.inputs?.[inSlot(node, 'CLIP')]?.link);
+ if (!link || link.origin_id !== end.id) break;
+ end = node;
+ }
+ return { source, slot: first.origin_slot, end };
+}
+
+/** Samplers (or guiders) the loader's model reaches. */
+function samplersOf(graph, loader) {
+ const found = new Set();
+ const seen = new Set([loader.id]);
+ let frontier = linksFrom(graph, loader, outSlot(loader, 'MODEL')).map(target => target.node);
+ for (let hop = 0; hop < MAX_HOPS && frontier.length; hop++) {
+ const next = [];
+ for (const node of frontier) {
+ if (seen.has(node.id)) continue;
+ seen.add(node.id);
+ if (/guider/i.test(node.type) || (node.inputs || []).some(input => /positive/i.test(input.name))) {
+ found.add(node.id);
+ continue;
+ }
+ (node.outputs || []).forEach((_, slot) => next.push(...linksFrom(graph, node, slot).map(target => target.node)));
+ }
+ frontier = next;
+ }
+ return found;
+}
+
+function positiveBox(graph, selected, samplers) {
+ if (selected) {
+ const boxes = promptBoxes(selected);
+ const own = boxes.filter(box => box.role === 'positive');
+ if (own.length === 1) return { node: selected, box: own[0] };
+ if (boxes.length === 1 && !boxes[0].role) return { node: selected, box: boxes[0] };
+ }
+ const found = [];
+ for (const node of graph._nodes || []) {
+ const boxes = promptBoxes(node).filter(box => box.role === 'positive');
+ if (!boxes.length) continue;
+ const reach = wiredRole(node, graph).samplers;
+ if (!samplers.size || [...reach].some(id => samplers.has(id))) found.push(...boxes.map(box => ({ node, box })));
+ }
+ return found.length === 1 ? found[0] : found.length;
+}
+
+/** The LoRA choices a loader type offers, from ComfyUI's definition (null: unknown). */
+function loraChoices(type) {
+ const input = globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData?.input;
+ const spec = input?.required?.lora_name || input?.optional?.lora_name;
+ return Array.isArray(spec?.[0]) ? spec[0] : null;
+}
+
+/**
+ * What putting `note` into the canvas would change: { graph, loader, widget, modelTo,
+ * chain, loras, loraType, prompt, lines, skipped }. `lines` and `skipped` are
+ * [key, params] pairs for the confirmation; nothing is written.
+ */
+export function planNoteApply(app, note, selected = null) {
+ const graph = app.canvas?.graph || app.graph;
+ const plan = { graph, lines: [], skipped: [], loras: null, prompt: null, modelTo: null };
+ if (!graph) return plan;
+ const loaders = (graph._nodes || []).filter(node => modelWidget(node, MAIN_TYPES) && outSlot(node, 'MODEL') >= 0);
+ const loader = selected && loaders.includes(selected) ? selected : loaders.length === 1 ? loaders[0] : null;
+ if (!loader) plan.skipped.push(loaders.length ? ['noteApplyManyLoaders', { count: loaders.length }] : ['noteApplyNoLoader']);
+
+ if (loader) {
+ plan.loader = loader;
+ plan.widget = modelWidget(loader, MAIN_TYPES);
+ const loaderIsUnet = !inferModelFolderTypes(loader, plan.widget).includes('checkpoints');
+ const main = note.mainModel;
+ if (main) {
+ const noteIsUnet = main.type === 'unet' || main.type === 'diffusion_models';
+ const value = choiceFor(plan.widget.options?.values, notePath(main));
+ if (noteIsUnet !== loaderIsUnet) plan.skipped.push(['noteApplyKindMismatch', { name: modelName(main.filename) }]);
+ else if (!value) plan.skipped.push(['noteApplyModelMissing', { name: modelName(main.filename) }]);
+ else if (value !== plan.widget.value) {
+ plan.modelTo = value;
+ plan.lines.push(['noteApplyModel', { from: modelName(plan.widget.value), to: modelName(value) }]);
+ }
+ }
+
+ plan.chain = loraChain(graph, loader);
+ plan.loraType = plan.chain[0]?.type || (loaderIsUnet ? 'LoraLoaderModelOnly' : 'LoraLoader');
+ const available = loraChoices(plan.loraType);
+ const wanted = [];
+ for (const lora of note.loras || []) {
+ const value = available ? choiceFor(available, notePath(lora)) : notePath(lora);
+ if (value) wanted.push(value);
+ else plan.skipped.push(['noteApplyLoraMissing', { name: modelName(lora.filename) }]);
+ }
+ const current = plan.chain.map(node => modelWidget(node, ['loras']).value);
+ if (wanted.map(slashes).join('|') !== current.map(slashes).join('|')) {
+ plan.loras = wanted;
+ const list = (values) => values.map(modelName).join('、');
+ plan.lines.push(['noteApplyLoras', { from: list(current), to: list(wanted) }]);
+ }
+ }
+
+ const text = String(note.promptEn || '').trim();
+ if (text) {
+ const target = positiveBox(graph, selected, loader ? samplersOf(graph, loader) : new Set());
+ if (typeof target === 'number') plan.skipped.push([target ? 'noteApplyManyPromptBoxes' : 'noteApplyNoPromptBox']);
+ else if (target.box.widget.value !== text) {
+ plan.prompt = { ...target, text };
+ plan.lines.push(['noteApplyPrompt', { count: text.length }]);
+ }
+ }
+ return plan;
+}
+
+function setValue(app, node, widget, value) {
+ const before = widget.value;
+ const index = node.widgets.indexOf(widget);
+ widget.value = value;
+ if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = value;
+ widget.callback?.(value, app.canvas, node);
+ node.onWidgetChanged?.(widget.name, value, before, widget);
+}
+
+function rebuildChain(plan) {
+ const { graph, loader, chain } = plan;
+ const last = chain.at(-1) || loader;
+ const modelTargets = linksFrom(graph, last, outSlot(last, 'MODEL')).filter(target => !chain.includes(target.node));
+ const takesClip = Boolean(globalThis.LiteGraph.registered_node_types?.[plan.loraType]?.nodeData?.input?.required?.clip);
+ const path = takesClip && outSlot(loader, 'CLIP') >= 0 ? clipPath(graph, loader, chain) : null;
+ const clipTargets = path ? linksFrom(graph, path.end, outSlot(path.end, 'CLIP')).filter(target => !chain.includes(target.node)) : [];
+ // A LoRA the note keeps keeps its strengths.
+ const kept = new Map(chain.map(node => [slashes(modelWidget(node, ['loras']).value),
+ Object.fromEntries((node.widgets || []).map(widget => [widget.name, widget.value]))]));
+ const spots = chain.map(node => [...node.pos]);
+ const base = spots.at(-1) || [loader.pos[0], loader.pos[1] + (loader.size?.[1] || 100) + 40];
+ for (const node of chain) graph.remove(node);
+
+ let model = loader;
+ let clip = path ? { node: path.source, slot: path.slot } : null;
+ plan.loras.forEach((value, index) => {
+ const node = globalThis.LiteGraph.createNode(plan.loraType);
+ node.pos = spots[index] || [base[0], base[1] + (index - spots.length + (spots.length ? 1 : 0)) * 130];
+ graph.add(node);
+ const values = kept.get(slashes(value)) || {};
+ for (const widget of node.widgets || []) {
+ if (widget === modelWidget(node, ['loras'])) widget.value = value;
+ else if (values[widget.name] !== undefined) widget.value = values[widget.name];
+ }
+ model.connect(outSlot(model, 'MODEL'), node, inSlot(node, 'MODEL'));
+ if (clip) clip.node.connect(clip.slot, node, inSlot(node, 'CLIP'));
+ model = node;
+ if (clip) clip = { node, slot: outSlot(node, 'CLIP') };
+ });
+ for (const target of modelTargets) model.connect(outSlot(model, 'MODEL'), target.node, target.slot);
+ if (clip) for (const target of clipTargets) clip.node.connect(clip.slot, target.node, target.slot);
+}
+
+/** Makes the planned changes as one Ctrl+Z step. */
+export function applyNotePlan(app, plan) {
+ const { graph } = plan;
+ graph.beforeChange?.();
+ try {
+ if (plan.modelTo) setValue(app, plan.loader, plan.widget, plan.modelTo);
+ if (plan.loras) rebuildChain(plan);
+ if (plan.prompt) setValue(app, plan.prompt.node, plan.prompt.box.widget, plan.prompt.text);
+ } finally {
+ graph.afterChange?.();
+ }
+ graph.setDirtyCanvas?.(true, true);
+ recordCanvasStep(app);
+}
diff --git a/web/modules/notebook_canvas.js b/web/modules/notebook_canvas.js
index edbe8ef..b4185a1 100644
--- a/web/modules/notebook_canvas.js
+++ b/web/modules/notebook_canvas.js
@@ -1,8 +1,14 @@
-/** Create a movable LiteGraph group from the active Prompt Note. */
+/**
+ * The active Prompt Note on the canvas: as a new group of nodes that follows the pointer
+ * until a click, or put into the open workflow (notebook_apply.js) after showing what changes.
+ */
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { recordCanvasStep } from './canvas_history.js';
+import { applyNotePlan, planNoteApply } from './notebook_apply.js';
+import { selectedMaterialNode } from './node_material_actions.js';
+import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
const t = (key, params) => translate(key, params);
@@ -14,6 +20,31 @@ export function isUnetModel(model) {
return model?.type === 'unet' || model?.type === 'diffusion_models';
}
+/** Puts the active note into the open workflow after a confirmation listing each change. */
+export async function applyNotebookToWorkflow() {
+ if (!this.currentNotebook) return;
+ const plan = planNoteApply(app, this.currentNotebook.data || {}, selectedMaterialNode(app));
+ const say = ([key, params = {}]) => t(key, Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v === '' ? t('noteApplyNone') : v])));
+ const reasons = plan.skipped.map(item => t('noteApplySkipped', { reason: say(item) }));
+ if (!plan.lines.length) {
+ await anomalousAlert([t('noteApplyNothing'), ...reasons].join('\n'), t('noteApplyTitle'));
+ return;
+ }
+ const message = [...plan.lines.map(say), ...reasons, '', t('noteApplyUndoHint')].join('\n');
+ if (!await anomalousConfirm(message, t('noteApplyTitle'), { okLabel: t('noteApplyConfirm') })) return;
+ applyNotePlan(app, plan);
+ this.nbPanel.style.display = 'none';
+ this.close();
+}
+
+/** The one text-encoder output already on the canvas (a loader), for a UNet note's prompts. */
+function canvasClipSource(exclude) {
+ const sources = (app.graph?._nodes || []).filter(node => !exclude.includes(node)
+ && (node.outputs || []).some(output => output.type === 'CLIP')
+ && !(node.inputs || []).some(input => input.type === 'CLIP' || input.type === 'MODEL'));
+ return sources.length === 1 ? sources[0] : null;
+}
+
export function sendNotebookToCanvas() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
@@ -68,8 +99,13 @@ export function sendNotebookToCanvas() {
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = data.promptEn;
}
+ const clipSource = lastClipSlot === null ? canvasClipSource(groupNodes.map(item => item.node)) : null;
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, posNode, 0);
+ } else if (clipSource) {
+ clipSource.connect(clipSource.outputs.findIndex(output => output.type === 'CLIP'), posNode, 0);
+ } else {
+ anomalousAlert(t('noteNeedsClip'));
}
const negNode = LiteGraph.createNode("CLIPTextEncode");
@@ -83,6 +119,8 @@ export function sendNotebookToCanvas() {
}
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, negNode, 0);
+ } else if (clipSource) {
+ clipSource.connect(clipSource.outputs.findIndex(output => output.type === 'CLIP'), negNode, 0);
}
}
diff --git a/web/modules/ui_notebook_editor.js b/web/modules/ui_notebook_editor.js
index df23867..26568d9 100644
--- a/web/modules/ui_notebook_editor.js
+++ b/web/modules/ui_notebook_editor.js
@@ -39,7 +39,7 @@ function createNotebookToolbar(ctx, notebook) {
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
- saveBtn.innerHTML = `💾 ${t('saveNotebook') || (window.anomalous_browser_lang === 'zh' ? '保存笔记' : 'Save Note')}`;
+ saveBtn.textContent = `💾 ${t('saveNotebook')}`;
saveBtn.className = 'anomalous-btn-primary';
saveBtn.onclick = async () => {
const orig = saveBtn.innerHTML;
@@ -54,10 +54,10 @@ function createNotebookToolbar(ctx, notebook) {
}, 1500);
};
- const sendBtn = document.createElement('button');
- sendBtn.type = 'button';
- sendBtn.innerHTML = `🚀 ${t('sendToCanvas') || (window.anomalous_browser_lang === 'zh' ? '发送到画布' : 'Send to Canvas')}`;
- sendBtn.className = 'anomalous-btn-success';
+ // Into the open workflow (model, LoRAs, prompt), or as a new group of nodes.
+ const applyBtn = Object.assign(document.createElement('button'), { type: 'button', className: 'anomalous-btn-success', textContent: t('noteApplyButton') });
+ applyBtn.onclick = () => ctx.applyNotebookToWorkflow();
+ const sendBtn = Object.assign(document.createElement('button'), { type: 'button', className: 'anomalous-btn-ghost', textContent: t('sendToCanvas') });
sendBtn.onclick = () => ctx.sendNotebookToCanvas();
// Floating More Dropdown Menu
@@ -67,7 +67,7 @@ function createNotebookToolbar(ctx, notebook) {
const moreBtn = document.createElement('button');
moreBtn.type = 'button';
moreBtn.className = 'anomalous-nb-more-btn';
- moreBtn.innerHTML = `··· ${t('notebookMore') || (window.anomalous_browser_lang === 'zh' ? '更多' : 'More')} ▾`;
+ moreBtn.innerHTML = `··· ${t('notebookMore')} ▾`;
const dropdownMenu = document.createElement('div');
dropdownMenu.className = 'anomalous-nb-dropdown-menu';
@@ -119,7 +119,7 @@ function createNotebookToolbar(ctx, notebook) {
document.addEventListener('click', onDocClick);
moreWrapper.append(moreBtn, dropdownMenu);
- rightBtns.append(saveBtn, sendBtn, moreWrapper);
+ rightBtns.append(saveBtn, applyBtn, sendBtn, moreWrapper);
tb.append(titleBox, rightBtns);
return tb;
}