diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1cc5449..4c93a36 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -105,6 +105,8 @@ covered by the `styles.css` manifest.
- `api/material_schema.py`, `api/material_assets.py`, and `api/material_store.py`
own curated material shaping, private assets, persistence/cache, search and
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
+ `api/node_material.py` saves one canvas node's current values as a
+ `node_parameter_selection` material (the current-node panel's "save").
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
`api/folder_types.py` own the formerly mixed utility route families.
- `model_policies.py` owns shared backend rename and protected-category policy.
@@ -404,10 +406,12 @@ covered by the `styles.css` manifest.
- `model_check.js` checks the open workflow's models against this computer (Model
Doctor's verdicts, no DOM) and puts a found file into its node; `ui_doctor.js` is the
doctor page and `ui_doctor_banner.js` the bar over the canvas when an opened workflow
- misses models. `ui_node_assistant.js` owns the selected-node assistant (actions, LoRA
- insertion, history), `ui_node_model_picker.js` owns native combo
- replacement, and `ui_node_presets.js` owns parameter preset rendering and
- application. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
+ misses models. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
+ insertion, model cards and history), `ui_node_model_picker.js` owns native combo
+ replacement, `ui_node_parameters.js` renders the panel's parameters section, and
+ `node_parameter_sets.js` merges the node type's saved values (materials and recipe
+ parameter sets) and works out what each would change on the node, keeping seeds,
+ model files and missing choices. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
own the remaining explicit graph changes.
- `ui_model_sources.js` owns the Model Source Hub (模型来源统一中控中心), providing dual-scope
(Workflow and Library) source inspection, external platform jumping, canvas `Note` node generation,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcb1c40..86783aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@
- **Model Doctor redone (模型医生重做)**: when a workflow opens with models this computer does not have, a bar over the canvas says how many, and how many are here under another name or folder; **Put in** swaps those in and **Show** opens the doctor. The doctor page lists what needs you first and says why in plain words (same file found elsewhere, same size only, not on this computer, no fingerprint saved in the workflow), with a button for each: put in, use it, pick one yourself, or look it up on Civitai; the models that are fine fold away. Opening the doctor no longer changes the workflow by itself, and a model put in by the doctor or its picker no longer stays marked red by ComfyUI. The "auto-detect on startup" switch (it did nothing) and the doctor's own deep hash scan are gone: the check hashes same-size files itself, and the page points to the scan page when unscanned models could be the missing ones.
- **Prompt boxes found by what they are (提示词框按节点定义识别)**: dropping or writing a prompt (material library, Prompt Studio, translator) now finds a node's prompt boxes by ComfyUI's own definition (multiline text inputs) instead of guessing from names, so a filename or "value" box no longer takes a prompt and third-party prompt nodes are recognised. Whether a box is positive or negative follows its wiring to the sampler, not the node's title: a CLIP Text Encode connected to the negative input gets the negative prompt even if it is still called "CLIP Text Encode". Prompt Studio's "take prompts from the selected node" also reads the roles right again (it could not follow links in current ComfyUI).
- **See where a dragged prompt goes (拖提示词前先看清楚落在哪)**: while you drag a prompt from the material library, every prompt box on the canvas is outlined (green for positive, red for negative, grey when it is not wired to a sampler) and the box under the pointer lights up. The label next to the pointer says exactly what releasing does, e.g. "Release: into #6's positive prompt box; the negative prompt box on the same sampler (#7) too". Drop on a box and that box is filled; the matching negative (or positive) box of the same sampler gets the other half of the material. On a node with several prompt boxes, point at the one you mean instead of letting it guess. A short message confirms what was filled; the library's receipt undoes it. Dropping a prompt plan on empty canvas used to create an empty prompt node; it now creates a positive and, when the plan has one, a negative node with their text.
+- **Current node (当前节点, formerly Node Assistant)**: the panel no longer splits into "actions" and "parameter sets" tabs; it is one page with the model actions on top and **Parameters** below. Parameters lists every saved set of values for this kind of node in one list — from the material library and from recipes' parameter sets, the same values saved twice shown once — and each entry says exactly what it would change on the selected node ("steps: 20 → 30"). **Apply these N** changes only those; seeds and model files stay (change the model above), and the receipt undoes it. **Save these values** keeps the node's current values in the material library, so the next node of that kind can use them.
### 🪶 Lighter on memory (更省内存)
- The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid.
diff --git a/README.md b/README.md
index e04bff1..c224645 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ Tools on the rail:
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
* **Model Doctor** checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder (recognised by hash and file size); its page lists the rest with what you can do.
-* **Node Assistant** shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and applies saved parameters.
+* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change.
* **Toolbox** holds the rest: workflow share codes (import / export), Prompt Studio, the prompt translator, Model Sources (where each model can be downloaded) and Prompt Notes.
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback.
@@ -106,7 +106,7 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
* **模型医生**:打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在医生页里自己挑或去 Civitai 找。
-* **节点助手**:选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA,或套用保存过的参数。
+* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用。
* **工具箱**:其余工具都在这里,包括工作流分享码(导入导出)、提示词工坊、翻译助手、模型来源(每个模型去哪下载)和提示词笔记。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。
diff --git a/api/__init__.py b/api/__init__.py
index a35db8b..359d75c 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -1,6 +1,6 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
- model_media, model_metadata, model_resolution, model_type_listing, recipe_packages, recipes, scan_report, scan_summary,
+ model_media, model_metadata, model_resolution, model_type_listing, node_material, recipe_packages, recipes, scan_report, scan_summary,
translation_routes, version_manager,
)
from .scanner import *
@@ -84,6 +84,7 @@ def setup_routes(app):
app.router.add_post('/anomalous/save_parameter_material', materials.api_save_parameter_material)
app.router.add_post('/anomalous/save_prompt_note_material', materials.api_save_prompt_note_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
+ app.router.add_post('/anomalous/save_node_material', node_material.api_save_node_material)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
diff --git a/api/material_store.py b/api/material_store.py
index 442aaec..95ab079 100644
--- a/api/material_store.py
+++ b/api/material_store.py
@@ -254,7 +254,8 @@ def _query_materials(materials_dir, query):
if len(node_type) > 200:
raise ValueError("Invalid node type")
if len(search) > 200 or len(tag) > 60 or kind not in (
- "", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "prompt_note_bundle", "prompt_text", "prompt_plan"
+ "", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "node_parameter_selection",
+ "prompt_note_bundle", "prompt_text", "prompt_plan"
):
raise ValueError("Invalid material filter")
materials = [material for material in materials
diff --git a/api/node_material.py b/api/node_material.py
new file mode 100644
index 0000000..251d158
--- /dev/null
+++ b/api/node_material.py
@@ -0,0 +1,65 @@
+"""Saving one canvas node's current values as a parameter material (the current-node panel's "save")."""
+
+import asyncio
+import hashlib
+import json
+import time
+import uuid
+
+from aiohttp import web
+
+from . import materials
+from .material_schema import MAX_MATERIAL_NAME_LENGTH, _material_summary, _normalise_material_tags
+from .notebooks import MAX_NOTEBOOK_BYTES
+
+
+def _node_material(payload):
+ node = payload.get("node")
+ if not isinstance(node, dict):
+ raise ValueError("Invalid node")
+ node_type = node.get("type")
+ if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
+ raise ValueError("Invalid node type")
+ values = node.get("widgets_values")
+ if not isinstance(values, list) or not values:
+ raise ValueError("Invalid node values")
+ encoded = json.dumps([node_type, values], ensure_ascii=False, allow_nan=False, sort_keys=True)
+ if len(encoded.encode("utf-8")) > MAX_NOTEBOOK_BYTES:
+ raise ValueError("Node values are too large")
+ title = node.get("title") if isinstance(node.get("title"), str) else ""
+ title = title.strip()[:200] or node_type
+ name = payload.get("name") or title
+ if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
+ raise ValueError("Invalid material name")
+ return {
+ "schema_version": materials.MATERIAL_SCHEMA_VERSION,
+ "id": uuid.uuid4().hex,
+ "kind": "node_parameter_selection",
+ "name": name,
+ "tags": _normalise_material_tags(payload.get("tags", [])),
+ "timestamp": int(time.time() * 1000),
+ "source": {
+ "type": "canvas_node",
+ "parameter_name": title,
+ "parameter_signature": hashlib.sha256(encoded.encode("utf-8")).hexdigest(),
+ },
+ "workflow": {"nodes": [{"id": 1, "type": node_type, "title": title, "widgets_values": values}]},
+ "selection": {"scope": "nodes", "node_ids": [1]},
+ "capabilities": ["apply_node_parameters"],
+ }
+
+
+async def api_save_node_material(request):
+ try:
+ material = _node_material(await request.json())
+ except (AttributeError, TypeError, ValueError):
+ return web.json_response({"status": "error", "message": "Invalid node material"}, status=400)
+ filename = f"material_{int(time.time())}_{material['id']}.json"
+ try:
+ duplicate = await asyncio.to_thread(
+ materials._persist_parameter_material, materials.get_materials_dir(), filename, material, False)
+ except (OSError, ValueError):
+ return web.json_response({"status": "error", "message": "Could not save node material"}, status=500)
+ if duplicate:
+ return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
+ return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md
index ee8b857..6a38d71 100644
--- a/docs/architecture/backend.md
+++ b/docs/architecture/backend.md
@@ -59,7 +59,9 @@ persistence/history live in `workflow_schema.py`, `recipe_schema.py`,
`recipe_images.py`, and `recipe_store.py`; `recipes.py` is the HTTP facade.
Material shaping, private assets, and the single persistence lock/summary cache
live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
-`materials.py` owns request/response mapping and compatibility entry points.
+`materials.py` owns request/response mapping and compatibility entry points;
+`node_material.py` saves one canvas node's values as a `node_parameter_selection`
+material (deduplicated by node type and values like other parameter materials).
The former mixed utility routes are separated: `media_routes.py` owns card
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md
index 28f99f9..6755a7e 100644
--- a/docs/architecture/frontend.md
+++ b/docs/architecture/frontend.md
@@ -76,13 +76,13 @@ The main surfaces are:
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`.
-- Node Assistant/Model Doctor: selected-node actions, parameter presets, and
+- Current node (Node Assistant)/Model Doctor: selected-node actions, saved parameters, and
missing-model recovery. `model_check.js` decides each workflow model's state and
applies fixes; `ui_doctor.js` (the page) and `ui_doctor_banner.js` (the bar shown
after a workflow opens) only render it and call it on a press.
- `ui_node_assistant.js` owns the selected-node panel and history, `ui_node_model_picker.js`
- owns the native-widget model replacer, and `ui_node_presets.js` owns parameter
- preset previews and application.
+ `ui_node_assistant.js` owns the current-node panel and model history, `ui_node_model_picker.js`
+ owns the native-widget model replacer, and `ui_node_parameters.js` with
+ `node_parameter_sets.js` own the panel's one list of saved values for the node type.
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
`material_inspector.js` shares image metadata helpers and node-parameter rendering
diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md
index eb85d3f..18c07a2 100644
--- a/docs/architecture/material-library.md
+++ b/docs/architecture/material-library.md
@@ -95,8 +95,13 @@ parameter rendering; the workbench does not import the library UI.
Gallery image clicks open the focused pan/zoom viewer, while the dedicated
parameter action opens the Image Detail Studio. Drag, delete, and cover-selection
behavior remains independent of those two entry points.
-`ui_node_presets.js` presents Node Assistant presets; it shares node application
-with the library through `ui_material_application.js` and `node_material_actions.js`.
+The current-node panel lists, for the selected node's type, the material blocks and the
+recipes' parameter sets as one list (`node_parameter_sets.js`); identical values from both
+are one entry naming both sources. Each entry shows exactly what it would change on the
+node; applying writes only those values through `node_material_actions.js` (one undo),
+never seeds, model files or choices this computer lacks. "Save these values" stores the
+node's current values as a `node_parameter_selection` material (`api/node_material.py`),
+which the library lists under parameters.
`ui_recipe_detail.js` can publish the active Recipe parameters or the selected
Parameter Notebook as `recipe_parameter_selection`. The primary panel saves all
diff --git a/web/modules/locales.js b/web/modules/locales.js
index 2e26dcf..0f62d9b 100644
--- a/web/modules/locales.js
+++ b/web/modules/locales.js
@@ -3,7 +3,7 @@ export const i18n = {
actionTools: "工具",
actionScan: "扫描",
actionDoctor: "医生",
- actionAssistant: "助手",
+ actionAssistant: "节点",
actionMaterials: "素材",
actionSettings: "设置",
updateGuideTitle: "本次更新",
@@ -130,9 +130,6 @@ export const i18n = {
assistantApplyScheme: "⚡ 应用方案",
assistantApplying: "⏳ 应用中...",
assistantApplied: "✅ 已应用",
- assistantSchemeDetails: "方案参数详情",
- assistantSchemeSource: "来源",
- assistantHoverToInspect: "悬停可预览详细参数",
promptCombinations: "提示词搭配",
promptPlan: "提示词方案",
promptSavedPlans: "已存方案",
@@ -224,7 +221,7 @@ export const i18n = {
materialRestoreNoteHint: "创建一份可编辑的新笔记,保留这份素材。配套模型可在笔记中调整。",
materialSaveGeneration: "保存这组生成参数",
materialSaveGenerationAction: "存入素材库",
- materialGenerationScope: "保存采样与尺寸节点的整组参数,可在节点助手中使用。",
+ materialGenerationScope: "保存采样与尺寸节点的整组参数,可在“当前节点”里套用。",
materialCopySuccess: "已复制",
materialCopyFailed: "复制失败",
materialClickToCopy: "点击复制数值",
@@ -420,12 +417,12 @@ export const i18n = {
helpContent: `
2. 🔄 先扫描一次 点图标栏的 扫描(或主页的“扫描模型文件夹”)。插件按文件哈希从 C 站获取封面、触发词和底模,需要联网;没匹配上的模型可以在扫描页“重新联网查找”,部分限制级模型需要在扫描页的“高级选项”里填写 C 站 API Key。扫描在后台进行,可以关掉窗口。扫描完成后,其余功能才有完整的模型信息。
🧪 Preview features and your data Workflow recipes, the Material Library and the Node Assistant's parameter presets are still in preview. Before updating the plugin, importing someone else's recipe, restoring a version, deleting a recipe or applying parameters, save your canvas and back up workflows/anomalous_recipes, workflows/anomalous_materials and workflows/anomalous_parameters in your ComfyUI user folder.
-
1. 🧭 The rail on the left One icon per page: Home, Activity, Models, Gallery, Workflows, Materials, then Voices and Audio. Click the current page's icon again, or the button at the far left of the header, to hide or show its list; in a narrow window the list slides in as a drawer. Below the pages are the tools: Scan, Model Doctor, Node Assistant and the Toolbox, with Settings at the bottom.
+
🧪 Preview features and your data Workflow recipes, the Material Library and Current node's parameters are still in preview. Before updating the plugin, importing someone else's recipe, restoring a version, deleting a recipe or applying parameters, save your canvas and back up workflows/anomalous_recipes, workflows/anomalous_materials and workflows/anomalous_parameters in your ComfyUI user folder.
+
1. 🧭 The rail on the left One icon per page: Home, Activity, Models, Gallery, Workflows, Materials, then Voices and Audio. Click the current page's icon again, or the button at the far left of the header, to hide or show its list; in a narrow window the list slides in as a drawer. Below the pages are the tools: Scan, Model Doctor, Current node and the Toolbox, with Settings at the bottom.
2. 🔄 Scan once first Click Scan on the rail (or "Scan model folders" on Home). The plugin fetches covers, trigger words and base models from Civitai by file hash, so it needs the internet; models that were not matched can be looked up again from the scan page, and some restricted models need a Civitai API key, entered under Advanced on the scan page. The scan runs in the background, so you can close the window. Once scanned, every other feature has full model information.
3. 📦 Models The chips at the top switch between types (Checkpoint, LoRA, VAE…) and list the whole type, subfolders included; click a folder in the list to see only that one, and the ✕ at the end of the chips to go back. On a card, + adds a loader node to the canvas, the pencil edits name, notes and cover, and the radar icon scans just that model.
4. 🩺 Model Doctor When someone else's workflow or image opens with red, missing models, click it: it finds your local copy of each model by hash and file size and swaps it in. "View hash" compares them side by side.
-
5. 🤖 Node Assistant Select a node on the canvas. The Actions tab swaps its model from the covers or inserts a LoRA before or after a compatible MODEL + CLIP chain; the Presets tab lists, by recipe, the saved parameters of nodes of the same type, and one click applies them (values that change every run, like the seed, are skipped).
+
5. 🤖 Current node Select a node on the canvas. On top, swap its model from the covers or insert a LoRA before or after a compatible MODEL + CLIP chain; below, Parameters lists every set of values saved for this kind of node (material library and recipes together), each saying what it would change; Apply changes only those (seeds and models stay). Save these values keeps the node's current values in the material library.
6. 🪡 Workflow recipes Save the whole canvas, or a part of it, with its models, cover, tags, notes and parameters. A card shows whether the models are on this computer; drag it onto the canvas to load it. The detail view matches local models, shows and copies parameters, compares past outputs and compares or restores versions.
7. ✨ Materials Open an image's parameter details in the Gallery or a recipe to save the image, its full workflow and its node settings as a material. Drop it on an empty canvas to open the workflow (prompt materials create prompt nodes), or on a node to fill in its settings.
8. 🎙️ Character voices Needs the Anomalous_TTS node pack. Import GPT-SoVITS characters, set emotions and pronunciations, and generate speech in the Script Director; the ! at the top right of the audio page has a step-by-step guide and a spotlight tour.
@@ -2354,6 +2354,7 @@ export const i18n = {
materialParameterNodes: 'Reusable Parameter Nodes',
materialModelReferences: 'Model References',
materialSourceImage: 'Source Image',
+ materialSourceCanvasNode: 'From a canvas node',
materialOpenSourceImage: 'View Original',
materialImageDetail: 'Image Parameter Details',
workbenchFullscreen: 'Fullscreen Workbench',
@@ -2374,7 +2375,7 @@ export const i18n = {
workbenchCopyWorkflowJson: 'Copy Workflow JSON',
materialInspecting: 'Reading the embedded workflow and parameters…',
materialInspectError: 'This image has no reusable full UI workflow and cannot be saved as a snapshot yet.',
- materialSnapshotExplanation: 'The image and exact full workflow are kept as one bundle. Node Assistant filters its parameters by the selected node type.',
+ materialSnapshotExplanation: 'The image and exact full workflow are kept as one bundle. Current node lists its parameters for the selected node\'s type.',
materialName: 'Material Name',
materialSaveHint: 'The full workflow keeps the exact seed; single-node application skips seed widgets.',
materialSaveSnapshot: 'Save image and full workflow',
@@ -2459,7 +2460,7 @@ export const i18n = {
materialClearNodeSelection: 'Clear Selection',
materialSelectedNodeCount: '{count} nodes selected',
materialSaveNode: '+ Save node parameters',
- materialSaveNodeHint: 'Save only this node\'s parameters for Node Assistant',
+ materialSaveNodeHint: 'Save only this node\'s parameters, to apply from Current node',
materialSaveSelectedAction: 'Save selected node parameters ({count})',
materialSaving: 'Saving…',
materialSaved: '✓ Saved',
@@ -2468,7 +2469,7 @@ export const i18n = {
materialSelectedNodeMaterial: 'Node Parameter Material',
materialRecipeParameterMaterial: 'Recipe Parameter Material',
materialSourceParameters: 'Source Recipe / Parameter Set',
- materialUseFromNodeAssistant: 'These parameters can be applied from Node Assistant.',
+ materialUseFromNodeAssistant: 'Select a node of the same kind and apply these from Current node.',
materialPromptRoleFromRecipe: 'Some roles were set manually on the recipe. Changing this material will not write back to the recipe.',
materialSourceRecipeCurrent: 'Source recipe: {name}',
materialSourceRecipeModified: 'Source broken · recipe changed: {name}',
@@ -2600,6 +2601,22 @@ export const i18n = {
promptDropPickBox: 'This node has several prompt boxes; drop on one of them',
promptFilledOne: 'Filled {node}',
promptFilledTwo: 'Filled {node} and {other}',
+ currentNodeParams: 'Parameters',
+ currentNodeSave: 'Save these values',
+ currentNodeSaveName: 'Name these values (saved to the material library)',
+ currentNodeSaved: 'Saved to the material library: {name}',
+ currentNodeSavedBefore: 'The library already has exactly these values: {name}',
+ currentNodeSaveFailed: 'Could not save these values',
+ currentNodeSearch: 'Search…',
+ currentNodeNoSets: 'No values saved for this kind of node yet. Parameter materials, recipe parameter sets and values saved here show up here.',
+ currentNodeNoMatch: 'Nothing matches',
+ currentNodeLoadFailed: 'Could not read the saved values',
+ currentNodeFromRecipe: 'Recipe · {name}',
+ currentNodeFromMaterial: 'Material library',
+ currentNodeMoreChanges: '{count} more',
+ currentNodeKept: 'Kept: {names} (seeds, models and choices this computer lacks stay; change the model above)',
+ currentNodeApply: 'Apply these {count}',
+ currentNodeSame: 'Same as now',
promptSelectNodeFirst: 'Select a node with a prompt box on the canvas first (e.g. CLIP Text Encode).',
promptNodeHasNoBox: 'The selected node has no prompt box (multiline text input).',
doctorLead: 'Checks whether this computer has the models the open workflow uses. The workflow carries each model\'s file fingerprint (hash), so a model you renamed or moved is still recognised; only a file with the same fingerprint is put in.',
@@ -2642,7 +2659,6 @@ export const i18n = {
doctorBannerShow: 'Show',
doctorBannerClose: 'Close',
assistantSelectNode: 'select any node in the canvas',
- assistantNoModelParameter: 'No supported model parameter on this node',
assistantSelectedNode: 'Selected node',
assistantQuickActions: 'Quick actions',
assistantChangeWidget: 'Change {name}',
@@ -2653,14 +2669,12 @@ export const i18n = {
assistantConnectInputs: 'Connect to node inputs',
assistantConnectOutputs: 'Connect to node outputs',
assistantPreviewFailed: 'Failed to load preview',
- betaFeature: 'Beta feature',
- assistantBetaNotice: 'The Parameter Set integration is still in beta. Save the current workflow before applying a preset; it changes the selected node while ignoring volatile values such as seeds. The Actions tab is not part of this beta.',
assistantHistoryTitle: 'History ({count})',
assistantViewAll: 'View All →',
assistantRestoreWorkflow: 'Click to restore workflow',
assistantViewGallery: 'Click to view full gallery',
pickerNoCompatible: 'No compatible models are available.',
- pickerEyebrow: 'Node Assistant · Model Picker',
+ pickerEyebrow: 'Current node · Model Picker',
pickerInsertBeforeTitle: '⬅ Insert LoRA Before Node',
pickerInsertAfterTitle: 'Insert LoRA After Node ➡',
pickerChangeTitle: '🔀 Change Current Model',
@@ -2843,7 +2857,7 @@ export const i18n = {
activityVoiceStorage: "Changed where characters are kept",
sidebarToggle: 'Show or hide the list',
sidebarDoctor: 'Model Doctor',
- sidebarAssistant: 'Node Assistant',
+ sidebarAssistant: 'Current node',
sidebarPreflight: 'Preflight Import',
sidebarSettings: 'Settings Hub',
sidebarScanWizard: '📡 Model Scan Wizard',
@@ -3300,14 +3314,6 @@ export const i18n = {
recipeParameterRename: 'Rename',
recipeParameterRenamePrompt: 'Enter a new name for this parameter notebook:',
recipeParameterRenameError: 'Failed to rename parameter notebook',
- assistantTabActions: 'Actions',
- assistantTabPresets: 'Parameter Presets',
- assistantRecipeGroup: 'Recipe',
- assistantDeletedRecipe: 'Deleted Recipe',
- assistantUnboundRecipe: 'Global Unbound',
- assistantNotebook: 'Notebook',
- assistantApplyTo: 'Apply to',
- assistantNoPresets: 'No parameter presets found for this node type.',
recipeStatusReady: '🟢 Ready to Run',
recipeStatusNeedAttention: '🟡 Matches Available ({count})',
recipeStatusMissing: '🔴 Missing Models ({count})',
diff --git a/web/modules/node_material_actions.js b/web/modules/node_material_actions.js
index 2b9e407..a23c6a1 100644
--- a/web/modules/node_material_actions.js
+++ b/web/modules/node_material_actions.js
@@ -13,7 +13,8 @@ export function selectedMaterialNode(app) {
return graph?.getNodeById(node.id) === node ? node : null;
}
-function volatile(node, widget, index) {
+/** Seeds and the like: values that change on every run and are never applied from a material. */
+export function isVolatileWidget(node, widget, index) {
return /(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(widget?.name || '')
|| (node.type === 'KSampler' && index === 0) || (node.type === 'KSamplerAdvanced' && index === 1);
}
@@ -33,7 +34,7 @@ function replaceHashes(graph, id, records) {
export function applyNodeMaterialValues(app, node, entries, options = {}) {
const graph = app.graph?.getNodeById(node?.id) === node ? app.graph : (node?.graph || app.canvas?.graph || app.graph);
if (!node || graph?.getNodeById(node.id) !== node || !Array.isArray(node.widgets)) throw new Error('materialTargetChanged');
- const changes = entries.filter(({ index }) => !volatile(node, node.widgets[index], index));
+ const changes = entries.filter(({ index }) => !isVolatileWidget(node, node.widgets[index], index));
if (!changes.length) throw new Error('materialNoCompatibleValues');
for (const { index, value } of changes) {
const widget = node.widgets[index];
diff --git a/web/modules/node_parameter_sets.js b/web/modules/node_parameter_sets.js
new file mode 100644
index 0000000..cdf1aef
--- /dev/null
+++ b/web/modules/node_parameter_sets.js
@@ -0,0 +1,107 @@
+/**
+ * The saved parameters for a node type, as one list: material blocks
+ * (GET /anomalous/materials/by_node_type) and recipes' parameter sets
+ * (GET /anomalous/parameters/by_node_type), with the same values saved twice merged into
+ * one entry that names both sources. For a live node, what an entry would change: every
+ * differing value, except seeds and how they change after a run, model files (the model
+ * section changes those) and choices this computer does not have, which stay. No DOM.
+ */
+
+import { applyNodeMaterialValues, isModelFilePath, isVolatileWidget } from './node_material_actions.js';
+
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+async function fetchJson(url, signal) {
+ const response = await fetch(url, { cache: 'no-store', signal });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json();
+}
+
+/**
+ * [{ name, role, timestamp, values, sources: [{ kind: 'material' | 'recipe', label }] }],
+ * newest first.
+ */
+export async function loadParameterSets(type, { signal } = {}) {
+ const query = `type=${encodeURIComponent(type)}`;
+ const [materials, recipes] = await Promise.all([
+ fetchJson(`/anomalous/materials/by_node_type?${query}`, signal),
+ fetchJson(`/anomalous/parameters/by_node_type?${query}`, signal),
+ ]);
+ const entries = new Map();
+ const add = (values, entry) => {
+ if (!Array.isArray(values) || !values.length) return;
+ const key = JSON.stringify(values);
+ const known = entries.get(key);
+ if (!known) entries.set(key, { ...entry, values });
+ else {
+ known.sources.push(...entry.sources);
+ known.timestamp = Math.max(known.timestamp, entry.timestamp);
+ }
+ };
+ for (const material of materials.materials || []) {
+ const blocks = material.blocks || [];
+ for (const block of blocks) {
+ add(block.widgets_values, {
+ name: blocks.length > 1 ? `${material.name} · ${block.title || block.type}` : material.name,
+ role: block.role || '',
+ timestamp: material.timestamp || 0,
+ sources: [{ kind: 'material', label: material.name }],
+ });
+ }
+ }
+ for (const group of recipes.groups || []) {
+ const recipe = String(group.recipe_name || group.recipe_filename || '');
+ // Sets not bound to a recipe, or whose recipe is gone (only its file name is left).
+ if (group.recipe_filename === 'unbound' || recipe.endsWith('.json')) continue;
+ for (const notebook of group.notebooks || []) {
+ const nodes = notebook.nodes || [];
+ for (const node of nodes) {
+ add(node.widgets_values, {
+ name: nodes.length > 1 ? `${notebook.name} · ${node.title}` : notebook.name,
+ role: node.role || '',
+ timestamp: notebook.timestamp || 0,
+ sources: [{ kind: 'recipe', label: recipe }],
+ });
+ }
+ }
+ }
+ return [...entries.values()].sort((a, b) => b.timestamp - a.timestamp);
+}
+
+/**
+ * What putting `values` into `node` would change: [{ index, label, from, to, keep }], keep
+ * being '' for a change that is made, or why the value stays: 'seed' | 'model' | 'missing'.
+ */
+export function parameterChanges(node, values) {
+ const changes = [];
+ values.forEach((to, index) => {
+ const widget = node.widgets?.[index];
+ if (!widget || to === undefined || to === null || same(widget.value, to)) return;
+ if (widget.value != null && typeof widget.value !== typeof to) return; // not the same input
+ const choices = Array.isArray(widget.options?.values) ? widget.options.values : null;
+ const keep = isVolatileWidget(node, widget, index) || widget.name === 'control_after_generate' ? 'seed'
+ : isModelFilePath(to) || isModelFilePath(widget.value) ? 'model'
+ : choices && !choices.includes(to) ? 'missing' : '';
+ changes.push({ index, label: widget.label || widget.name || `#${index}`, from: widget.value, to, keep });
+ });
+ return changes;
+}
+
+/** Puts the changes that are made into the node; the undoable result of node_material_actions. */
+export function applyParameterChanges(app, node, changes) {
+ return applyNodeMaterialValues(app, node, changes.filter(change => !change.keep).map(({ index, to }) => ({ index, value: to })));
+}
+
+/** Saves the node's current values as a material; { status: 'success' | 'duplicate', name }. */
+export async function saveNodeParameters(node, name) {
+ const values = node.serialize?.().widgets_values || node.widgets_values || (node.widgets || []).map(widget => widget.value);
+ const response = await fetch('/anomalous/save_node_material', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, node: { type: node.type, title: node.title || node.type, widgets_values: values } }),
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (payload.status === 'success') return { status: 'success', name: payload.material?.name || name };
+ if (payload.status === 'duplicate') return { status: 'duplicate', name: payload.name };
+ throw new Error(payload.message || `HTTP ${response.status}`);
+}
diff --git a/web/modules/ui_material_detail.js b/web/modules/ui_material_detail.js
index 7bdcb96..64cc0e8 100644
--- a/web/modules/ui_material_detail.js
+++ b/web/modules/ui_material_detail.js
@@ -355,7 +355,7 @@ function buildMaterialMediaStage(owner, material, sourceNameElement) {
sourceCopy.className = 'anomalous-library-detail-source-copy';
text(sourceCopy, 'span', isPromptMaterial(material) ? t('materialPromptNoteBundle') : material.kind === 'recipe_parameter_selection'
? t('materialSourceParameters')
- : t('materialSourceImage'));
+ : t(material.kind === 'node_parameter_selection' ? 'materialSourceCanvasNode' : 'materialSourceImage'));
sourceCopy.appendChild(sourceNameElement);
sourceMeta.appendChild(sourceCopy);
diff --git a/web/modules/ui_node_assistant.js b/web/modules/ui_node_assistant.js
index 922b315..f8797c7 100644
--- a/web/modules/ui_node_assistant.js
+++ b/web/modules/ui_node_assistant.js
@@ -1,13 +1,12 @@
-/** Node Assistant panel: the selected node's actions (change its model, insert a LoRA),
- * model history cards and parameter presets. */
+/** The current-node panel (Node Assistant): the selected node's model actions (change it,
+ * insert a LoRA), model cards with their history, and its parameters (ui_node_parameters.js). */
import { app } from "../../../scripts/app.js";
import { translate } from "./locales.js";
import { analyzeModelChainInsertion, getModelChainInsertionCapabilities } from "./graph_splice.js";
import { formatModelTypeLabel, inferPickerModelType } from "./model_picker.js";
-import { escapeHtml } from "./safe_dom.js";
import { findModelComboWidget, getNativeWidgetValues } from "./ui_node_model_picker.js";
-import { renderParameterPresets } from "./ui_node_presets.js";
+import { renderNodeParameters } from "./ui_node_parameters.js";
const t = (key, params) => translate(key, params);
@@ -333,8 +332,7 @@ export function openLoraInsertionPicker(anchorNode, direction) {
});
}
-export function diagnoseNode(node, forceRefresh = false) {
- // This method serves the Node Assistant panel only
+export function diagnoseNode(node) {
if (!this.assistantPanelInitialized) {
this.initAssistantPanel();
}
@@ -382,7 +380,7 @@ for (const w of node.widgets) {
refreshBtn.onmouseout = () => refreshBtn.style.background = 'none';
refreshBtn.onclick = () => {
refreshBtn.style.transform = 'rotate(180deg)';
- setTimeout(() => this.diagnoseNode(node, true), 150);
+ setTimeout(() => this.diagnoseNode(node), 150);
};
titleBar.appendChild(refreshBtn);
@@ -456,59 +454,12 @@ for (const w of node.widgets) {
onClick: () => this.openLoraInsertionPicker(node, 'after'),
capability: insertionCapabilities.after,
});
- const tabsRow = document.createElement('div');
- tabsRow.style.cssText = 'display:flex; padding: 10px 16px 0; gap: 8px; flex-shrink:0;';
-
- const btnActions = document.createElement('button');
- btnActions.textContent = t('assistantTabActions') || '🛠️ Quick Actions';
- btnActions.style.cssText = 'flex:1; padding: 8px; border-radius: 8px; background: rgba(255,255,255,0.1); color: #fff; cursor: pointer; border: none; font-size: 11px; font-weight: bold; transition: background 0.2s;';
-
- const btnPresets = document.createElement('button');
- btnPresets.textContent = t('assistantTabPresets') || '📚 Parameter Presets';
- btnPresets.style.cssText = 'flex:1; padding: 8px; border-radius: 8px; background: transparent; color: #aaa; cursor: pointer; border: none; font-size: 11px; font-weight: bold; transition: background 0.2s;';
-
- tabsRow.append(btnActions, btnPresets);
- nodeContent.appendChild(tabsRow);
-
- const actionsContainer = document.createElement('div');
- actionsContainer.style.cssText = 'display:flex; flex-direction:column; flex:1; overflow-y:auto; min-height:0;';
-
- const presetsContainer = document.createElement('div');
- presetsContainer.style.cssText = 'display:none; flex-direction:column; flex:1; overflow-y:auto; min-height:0;';
-
- nodeContent.append(actionsContainer, presetsContainer);
-
- btnActions.onclick = () => {
- btnActions.style.background = 'rgba(255,255,255,0.1)';
- btnActions.style.color = '#fff';
- btnPresets.style.background = 'transparent';
- btnPresets.style.color = '#aaa';
- actionsContainer.style.display = 'flex';
- presetsContainer.style.display = 'none';
- };
-
- btnPresets.onclick = () => {
- btnPresets.style.background = 'rgba(255,255,255,0.1)';
- btnPresets.style.color = '#fff';
- btnActions.style.background = 'transparent';
- btnActions.style.color = '#aaa';
- presetsContainer.style.display = 'flex';
- actionsContainer.style.display = 'none';
- };
-
+ const body = document.createElement('div');
+ body.className = 'anomalous-node-body';
+ nodeContent.appendChild(body);
if (hasModelOrInsertion) {
- actionsContainer.appendChild(quickActions);
- for (const w of modelWidgets) {
- this.renderAssistantModelCard(node, w, actionsContainer);
- }
- } else {
- const noModelWarning = document.createElement('div');
- noModelWarning.style.cssText = 'display:flex; flex-direction:column; align-items:center; justify-content:center; padding: 30px 20px;';
- noModelWarning.innerHTML = `