feat(node): Current node panel with one list of saved parameters

The Node Assistant becomes "Current node" (当前节点): one page instead of the
"actions" / "parameter sets" tabs, model actions on top, Parameters below.

- node_parameter_sets.js: the node type's saved values from the material
  library and from recipes' parameter sets as one list; the same values
  saved in both are one entry naming both sources. For the selected node,
  exactly what an entry would change; seeds (and their after-run control),
  model files and choices this computer lacks are kept.
- ui_node_parameters.js: the list, "apply these N" (one undo in the
  receipt) and "save these values".
- api/node_material.py: POST /anomalous/save_node_material keeps one
  node's values as a node_parameter_selection material, deduplicated by
  type and values; the library lists it under parameters.
- Removed: ui_node_presets.js with its hover popovers, the beta notice and
  their styles and strings. The panel and help texts use the new name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-02 06:09:49 +08:00
co-authored by Claude Opus 5.5
parent 86d165b891
commit a173e4e561
21 changed files with 562 additions and 725 deletions
+8 -4
View File
@@ -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,
+1
View File
@@ -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.
+2 -2
View File
@@ -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;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用。
* **工具箱**:其余工具都在这里,包括工作流分享码(导入导出)、提示词工坊、翻译助手、模型来源(每个模型去哪下载)和提示词笔记。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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
+65
View File
@@ -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)})
+3 -1
View File
@@ -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
+4 -4
View File
@@ -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
+7 -2
View File
@@ -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
+53 -47
View File
@@ -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: `
<div style="line-height: 1.6; font-size: 0.95em; color: #eee; padding: 10px;">
<h3 style="color:#f59e0b; margin-top:0;">📖 使用说明</h3>
<div style="margin:0 0 14px;padding:12px 14px;border:1px solid rgba(255,193,7,.55);border-radius:9px;background:rgba(255,193,7,.1);color:#ffe4a3;"><strong>🧪 测试功能与数据保护</strong><br>工作流配方、素材库和节点助手的“参数方案”仍在测试阶段。更新插件、导入别人的配方、恢复版本、删除配方或应用参数前,请先保存当前画布,并备份 ComfyUI 用户目录里的 <code>workflows/anomalous_recipes</code>、<code>workflows/anomalous_materials</code> 与 <code>workflows/anomalous_parameters</code>。</div>
<p><strong>1. 🧭 左侧图标栏</strong><br>每个页面一个图标:主页、记录、模型、图库、工作流、素材,以及角色语音和音频库。再点一下当前页的图标,或顶栏最左边的按钮,可以收起、展开旁边的列表;窗口较窄时,列表会变成从左边滑出的抽屉。图标栏下半部分是工具:扫描、模型医生、节点助手和工具箱,最下面是设置。</p>
<div style="margin:0 0 14px;padding:12px 14px;border:1px solid rgba(255,193,7,.55);border-radius:9px;background:rgba(255,193,7,.1);color:#ffe4a3;"><strong>🧪 测试功能与数据保护</strong><br>工作流配方、素材库和“当前节点”的参数仍在测试阶段。更新插件、导入别人的配方、恢复版本、删除配方或应用参数前,请先保存当前画布,并备份 ComfyUI 用户目录里的 <code>workflows/anomalous_recipes</code>、<code>workflows/anomalous_materials</code> 与 <code>workflows/anomalous_parameters</code>。</div>
<p><strong>1. 🧭 左侧图标栏</strong><br>每个页面一个图标:主页、记录、模型、图库、工作流、素材,以及角色语音和音频库。再点一下当前页的图标,或顶栏最左边的按钮,可以收起、展开旁边的列表;窗口较窄时,列表会变成从左边滑出的抽屉。图标栏下半部分是工具:扫描、模型医生、当前节点和工具箱,最下面是设置。</p>
<p><strong>2. 🔄 先扫描一次</strong><br>点图标栏的 <strong>扫描</strong>(或主页的“扫描模型文件夹”)。插件按文件哈希从 C 站获取封面、触发词和底模,需要联网;没匹配上的模型可以在扫描页“重新联网查找”,部分限制级模型需要在扫描页的“高级选项”里填写 C 站 API Key。扫描在后台进行,可以关掉窗口。扫描完成后,其余功能才有完整的模型信息。</p>
<p><strong>3. 📦 模型</strong><br>顶部标签按类型(Checkpoint、LoRA、VAE…)切换,连同子文件夹一起列出;在左侧列表点某个文件夹只看那一个,点标签末尾的 ✕ 回到整个类型。卡片上的 <strong>+</strong> 在画布上创建加载节点,铅笔编辑名称、备注和封面,雷达图标只扫描这一个模型。</p>
<p><strong>4. 🩺 模型医生</strong><br>打开别人的工作流或图片、模型报红时点它:按哈希和文件大小在本地找到对应的模型,一键替换。“查看哈希”可以逐项比对。</p>
<p><strong>5. 🤖 节点助手</strong><br>在画布上选中节点后,“动作”页可以看图换模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA;“参数方案”页按配方列出同类节点保存过的参数,点一条就应用到当前节点(种子这类每次都变的值会跳过)。</p>
<p><strong>5. 🤖 当前节点</strong><br>在画布上选中节点后,上面可以看图换模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA;下面的“参数”列出这类节点存过的所有参数(素材库和配方里的合在一起),每条都写明会改哪几项,点“套用”只改这几项(种子和模型不跟着换);“存下当前参数”把这个节点现在的值存进素材库。</p>
<p><strong>6. 🪡 工作流配方</strong><br>把当前画布的完整工作流或其中一段,连同模型、封面、标签、备注和参数存成配方。卡片会显示本机模型是否齐全,拖到画布上即可载入;详情页可以匹配本地模型、查看与复制参数、比较历史出图,以及比较或恢复版本。</p>
<p><strong>7. ✨ 素材</strong><br>在图库或配方里打开一张图的参数详情,可以把原图、完整工作流和节点参数存成素材。拖到画布空白处打开工作流(提示词素材会生成提示词节点),拖到已有节点上则把参数填进去。</p>
<p><strong>8. 🎙️ 角色语音</strong><br>需要另外安装 Anomalous_TTS 节点包。导入 GPT-SoVITS 角色、设置情绪和读音、在剧本台生成语音;音频页右上角的 <strong>!</strong> 有分步说明和遮罩导览。</p>
@@ -459,6 +456,7 @@ export const i18n = {
materialParameterNodes: '可用参数节点',
materialModelReferences: '模型引用',
materialSourceImage: '来源图片',
materialSourceCanvasNode: '来自画布节点',
materialOpenSourceImage: '查看原图',
materialImageDetail: '图片参数详情',
workbenchFullscreen: '全屏工作台',
@@ -479,7 +477,7 @@ export const i18n = {
workbenchCopyWorkflowJson: '复制完整工作流 JSON',
materialInspecting: '正在读取图片中的工作流与参数…',
materialInspectError: '这张图片没有可复用的完整 UI 工作流,暂时不能保存为素材快照。',
materialSnapshotExplanation: '保存后,图片与当前完整工作流会作为一个素材包保留;节点助手会按当前节点类型筛选其中的参数。',
materialSnapshotExplanation: '保存后,图片与当前完整工作流会作为一个素材包保留;“当前节点”会按选中节点的类型列出其中的参数。',
materialName: '素材名称',
materialSaveHint: '完整工作流保留精确种子;作为单节点参数应用时会跳过种子。',
materialSaveSnapshot: '保存图片与完整工作流',
@@ -564,7 +562,7 @@ export const i18n = {
materialClearNodeSelection: '清空选择',
materialSelectedNodeCount: '已选择 {count} 个节点',
materialSaveNode: '保存这组参数',
materialSaveNodeHint: '只保存这个节点的参数供节点助手使用',
materialSaveNodeHint: '只保存这个节点的参数,在“当前节点”里套用',
materialSaveSelectedAction: '保存选中节点参数 ({count})',
materialSaving: '正在保存…',
materialSaved: '✓ 已存入',
@@ -573,7 +571,7 @@ export const i18n = {
materialSelectedNodeMaterial: '节点参数素材',
materialRecipeParameterMaterial: '配方参数素材',
materialSourceParameters: '来源配方 / 参数方案',
materialUseFromNodeAssistant: '这些参数可以在节点助手里直接套用。',
materialUseFromNodeAssistant: '选中同类节点后,可以在“当前节点”里直接套用这些参数。',
materialPromptRoleFromRecipe: '有的正负面是配方里手动指定的;改这个素材不会写回配方。',
materialSourceRecipeCurrent: '来源配方:{name}',
materialSourceRecipeModified: '来源已中断 · 配方已修改:{name}',
@@ -705,6 +703,22 @@ export const i18n = {
promptDropPickBox: '这个节点有好几个提示词框,拖到其中一个上',
promptFilledOne: '已填进 {node}',
promptFilledTwo: '已填进 {node} 和 {other}',
currentNodeParams: '参数',
currentNodeSave: '存下当前参数',
currentNodeSaveName: '给这组参数起个名字(存进素材库)',
currentNodeSaved: '已存进素材库:{name}',
currentNodeSavedBefore: '素材库里已经有一模一样的参数:{name}',
currentNodeSaveFailed: '没能保存这组参数',
currentNodeSearch: '搜索参数…',
currentNodeNoSets: '这种节点还没有存过参数。素材库里的参数素材、配方里的参数方案,以及在这里“存下当前参数”的,都会出现在这里。',
currentNodeNoMatch: '没有匹配的参数',
currentNodeLoadFailed: '没能读出存过的参数',
currentNodeFromRecipe: '配方 · {name}',
currentNodeFromMaterial: '素材库',
currentNodeMoreChanges: '还有 {count} 项',
currentNodeKept: '保持不变:{names}(种子、模型和本机没有的选项不跟着换;模型在上面换)',
currentNodeApply: '套用这 {count} 项',
currentNodeSame: '和当前一样',
promptSelectNodeFirst: '请先在画布上选中一个有提示词框的节点(例如 CLIP 文本编码)。',
promptNodeHasNoBox: '选中的节点没有提示词框(多行文本输入)。',
doctorLead: '检查当前工作流用到的模型在本机找不找得到。工作流里记着每个模型的文件指纹,改过名、换过文件夹的模型也能认出来;只有指纹对得上的文件才会被换上。',
@@ -747,7 +761,6 @@ export const i18n = {
doctorBannerShow: '查看',
doctorBannerClose: '关闭提示',
assistantSelectNode: '请在画布中点击选中任意节点',
assistantNoModelParameter: '该节点没有受支持的模型参数',
assistantSelectedNode: '当前选中节点',
assistantQuickActions: '快捷操作',
assistantChangeWidget: '更换 {name}',
@@ -758,14 +771,12 @@ export const i18n = {
assistantConnectInputs: '接入节点输入端',
assistantConnectOutputs: '接入节点输出端',
assistantPreviewFailed: '无法加载预览',
betaFeature: '测试功能',
assistantBetaNotice: '“参数方案”联动仍在测试。应用前请保存当前工作流;它会修改选中节点的参数,但会忽略种子等运行时变化值。“动作”页不属于测试范围。',
assistantHistoryTitle: '历史生成图 ({count})',
assistantViewAll: '查看全部 →',
assistantRestoreWorkflow: '点击恢复此工作流',
assistantViewGallery: '点击查看完整图库',
pickerNoCompatible: '没有可供选择的兼容模型。',
pickerEyebrow: '节点助手 · 模型选择器',
pickerEyebrow: '当前节点 · 模型选择器',
pickerInsertBeforeTitle: '⬅ 在节点前方插入 LoRA',
pickerInsertAfterTitle: '在节点后方插入 LoRA ➡',
pickerChangeTitle: '🔀 更换当前模型',
@@ -948,7 +959,7 @@ export const i18n = {
activityVoiceStorage: "更改角色存放位置",
sidebarToggle: '收起/展开列表',
sidebarDoctor: '模型医生',
sidebarAssistant: '节点助手',
sidebarAssistant: '当前节点',
sidebarPreflight: '预检导入工作流',
sidebarSettings: '设置中心',
sidebarScanWizard: '📡 模型扫描向导',
@@ -1405,14 +1416,6 @@ export const i18n = {
recipeParameterRename: '重命名',
recipeParameterRenamePrompt: '请输入参数方案的新名称:',
recipeParameterRenameError: '重命名参数方案失败',
assistantTabActions: '动作',
assistantTabPresets: '参数方案',
assistantRecipeGroup: '配方',
assistantDeletedRecipe: '已删除配方',
assistantUnboundRecipe: '全局无绑定',
assistantNotebook: '笔记本',
assistantApplyTo: '应用到',
assistantNoPresets: '该节点类型暂无参数预设。',
recipeStatusReady: '🟢 全部模型就绪',
recipeStatusNeedAttention: '🟡 发现可用匹配 ({count})',
recipeStatusMissing: '🔴 缺少模型 ({count})',
@@ -2025,9 +2028,6 @@ export const i18n = {
assistantApplyScheme: "⚡ Apply Scheme",
assistantApplying: "⏳ Applying...",
assistantApplied: "✅ Applied",
assistantSchemeDetails: "Scheme Details",
assistantSchemeSource: "Source",
assistantHoverToInspect: "Hover to preview full parameters",
promptCombinations: "Prompt Combinations",
promptPlan: "Prompt Plan",
promptSavedPlans: "Saved plans",
@@ -2119,7 +2119,7 @@ export const i18n = {
materialRestoreNoteHint: "Create an editable new note and keep this material. Companion models can be adjusted in the note.",
materialSaveGeneration: "Save these generation settings",
materialSaveGenerationAction: "Save to Material Library",
materialGenerationScope: "Saves complete sampler and size node settings for reuse in Node Assistant.",
materialGenerationScope: "Saves complete sampler and size node settings for reuse from Current node.",
materialCopySuccess: "Copied",
materialCopyFailed: "Copy failed",
materialClickToCopy: "Click to copy",
@@ -2315,12 +2315,12 @@ export const i18n = {
helpContent: `
<div style="line-height: 1.6; font-size: 0.95em; color: #eee; padding: 10px;">
<h3 style="color:#f59e0b; margin-top:0;">📖 How to use</h3>
<div style="margin:0 0 14px;padding:12px 14px;border:1px solid rgba(255,193,7,.55);border-radius:9px;background:rgba(255,193,7,.1);color:#ffe4a3;"><strong>🧪 Preview features and your data</strong><br>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 <code>workflows/anomalous_recipes</code>, <code>workflows/anomalous_materials</code> and <code>workflows/anomalous_parameters</code> in your ComfyUI user folder.</div>
<p><strong>1. 🧭 The rail on the left</strong><br>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.</p>
<div style="margin:0 0 14px;padding:12px 14px;border:1px solid rgba(255,193,7,.55);border-radius:9px;background:rgba(255,193,7,.1);color:#ffe4a3;"><strong>🧪 Preview features and your data</strong><br>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 <code>workflows/anomalous_recipes</code>, <code>workflows/anomalous_materials</code> and <code>workflows/anomalous_parameters</code> in your ComfyUI user folder.</div>
<p><strong>1. 🧭 The rail on the left</strong><br>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.</p>
<p><strong>2. 🔄 Scan once first</strong><br>Click <strong>Scan</strong> 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.</p>
<p><strong>3. 📦 Models</strong><br>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, <strong>+</strong> adds a loader node to the canvas, the pencil edits name, notes and cover, and the radar icon scans just that model.</p>
<p><strong>4. 🩺 Model Doctor</strong><br>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.</p>
<p><strong>5. 🤖 Node Assistant</strong><br>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).</p>
<p><strong>5. 🤖 Current node</strong><br>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, <b>Parameters</b> lists every set of values saved for this kind of node (material library and recipes together), each saying what it would change; <b>Apply</b> changes only those (seeds and models stay). <b>Save these values</b> keeps the node's current values in the material library.</p>
<p><strong>6. 🪡 Workflow recipes</strong><br>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.</p>
<p><strong>7. ✨ Materials</strong><br>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.</p>
<p><strong>8. 🎙️ Character voices</strong><br>Needs the Anomalous_TTS node pack. Import GPT-SoVITS characters, set emotions and pronunciations, and generate speech in the Script Director; the <strong>!</strong> at the top right of the audio page has a step-by-step guide and a spotlight tour.</p>
@@ -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})',
+3 -2
View File
@@ -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];
+107
View File
@@ -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}`);
}
+1 -1
View File
@@ -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);
+11 -60
View File
@@ -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 = `<div style="font-size:36px;margin-bottom:10px;">⚠️</div><div style="text-align:center;color:#aaa;font-size:12px;">${t('assistantNoModelParameter')}</div><div style="font-size:10px;color:#555;margin-top:6px;">${escapeHtml(node.type || '')}</div>`;
actionsContainer.appendChild(noModelWarning);
btnPresets.onclick(); // switch to presets by default if no model actions
body.appendChild(quickActions);
for (const w of modelWidgets) this.renderAssistantModelCard(node, w, body);
}
renderParameterPresets.call(this, node, presetsContainer, forceRefresh);
renderNodeParameters(node, body);
}
+130
View File
@@ -0,0 +1,130 @@
/**
* The current-node panel's parameters: every saved set of values for the node's type
* (node_parameter_sets.js: materials and recipes, merged), each with exactly what it would
* change on this node; one press puts those in (seeds and model files stay, the receipt
* undoes it). "Save these values" keeps the node's current values as a material.
*/
import { app } from "../../../scripts/app.js";
import { translate as t } from './locales.js';
import { anomalousPrompt } from './ui_dialog.js';
import { showMaterialApplication } from './ui_material_application.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { applyParameterChanges, loadParameterSets, parameterChanges, saveNodeParameters } from './node_parameter_sets.js';
const SHOWN_CHANGES = 4;
const SEARCH_FROM = 6; // entries before a search box is worth showing
const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative', both: 'recipePromptRoleBoth' };
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
const brief = (value) => {
const text = typeof value === 'string' ? value.replace(/\s+/g, ' ') : JSON.stringify(value);
return text.length > 48 ? `${text.slice(0, 47)}…` : text;
};
const sourceLabel = source => (source.kind === 'recipe'
? t('currentNodeFromRecipe', { name: source.label })
: t('currentNodeFromMaterial'));
function entryCard(node, entry, receiptHost, redraw) {
const changes = parameterChanges(node, entry.values);
const made = changes.filter(change => !change.keep);
const card = el('div', 'anomalous-node-param');
const head = el('div', 'anomalous-node-param-head');
head.append(el('strong', 'anomalous-node-param-name', entry.name));
if (ROLE_KEYS[entry.role]) head.append(el('span', `anomalous-node-param-role is-${entry.role}`, t(ROLE_KEYS[entry.role])));
const sources = el('div', 'anomalous-node-param-sources');
for (const label of new Set(entry.sources.map(sourceLabel))) sources.append(el('span', '', label));
card.append(head, sources);
if (made.length) {
const list = el('ul', 'anomalous-node-param-changes');
for (const change of made.slice(0, SHOWN_CHANGES)) {
const item = el('li', '', `${change.label}: ${brief(change.from)} → ${brief(change.to)}`);
item.title = `${change.label}: ${change.to}`;
list.append(item);
}
if (made.length > SHOWN_CHANGES) list.append(el('li', 'is-more', t('currentNodeMoreChanges', { count: made.length - SHOWN_CHANGES })));
card.append(list);
}
const kept = changes.filter(change => change.keep);
if (kept.length) card.append(el('p', 'anomalous-node-param-kept', t('currentNodeKept', { names: kept.map(change => change.label).join(', ') })));
const apply = button('anomalous-node-param-apply', made.length ? t('currentNodeApply', { count: made.length }) : t('currentNodeSame'), () => {
try {
const result = applyParameterChanges(app, node, made);
showMaterialApplication(receiptHost, { undo() { result.undo(); redraw(); } }, node);
redraw();
} catch (error) {
showWorkbenchToast(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
}
});
apply.disabled = !made.length;
card.append(apply);
return card;
}
/** Appends the parameters section for `node` to `container` and loads its entries. */
export function renderNodeParameters(node, container) {
const section = el('section', 'anomalous-node-section');
const head = el('div', 'anomalous-node-section-head');
const search = el('input', 'anomalous-node-param-search');
search.type = 'search';
search.placeholder = t('currentNodeSearch');
search.hidden = true;
const list = el('div', 'anomalous-node-param-list');
list.append(el('p', 'anomalous-node-muted', t('loading')));
section.append(head, search, list);
container.append(section);
let entries = [];
const draw = () => {
const query = search.value.trim().toLowerCase();
const shown = entries.filter(entry => !query
|| `${entry.name} ${entry.sources.map(source => source.label).join(' ')}`.toLowerCase().includes(query));
search.hidden = entries.length < SEARCH_FROM;
list.replaceChildren(...(shown.length
? shown.map(entry => entryCard(node, entry, section, draw))
: [el('p', 'anomalous-node-muted', t(entries.length ? 'currentNodeNoMatch' : 'currentNodeNoSets'))]));
};
search.oninput = draw;
const load = async () => {
const run = (section._load = (section._load || 0) + 1);
try {
const loaded = await loadParameterSets(node.type);
if (run !== section._load || !section.isConnected) return;
entries = loaded;
draw();
} catch (error) {
if (run === section._load) list.replaceChildren(el('p', 'anomalous-node-muted is-error', t('currentNodeLoadFailed')));
}
};
const save = async () => {
const name = await anomalousPrompt(t('currentNodeSaveName'), node.title || node.type, t('currentNodeSave'));
if (name === null) return;
try {
const result = await saveNodeParameters(node, name || node.title || node.type);
showWorkbenchToast(t(result.status === 'duplicate' ? 'currentNodeSavedBefore' : 'currentNodeSaved', { name: result.name }));
if (section.isConnected) load();
} catch (error) {
showWorkbenchToast(t('currentNodeSaveFailed'));
}
};
head.append(el('h3', '', t('currentNodeParams')), button('anomalous-node-section-btn', t('currentNodeSave'), save));
load();
}
-529
View File
@@ -1,529 +0,0 @@
/** Node parameter scheme cards, previews, and application. */
import { app } from "../../../scripts/app.js";
import { translate } from "./locales.js";
import { escapeHtml } from "./safe_dom.js";
import { applyNodeMaterialValues } from "./node_material_actions.js";
import { applyMaterialToSelectedNode } from "./ui_material_application.js";
const t = (key, params) => translate(key, params);
export function applyLocalNodeParameters(targetNode, sourceWidgetValues) {
if (!Array.isArray(sourceWidgetValues)) throw new Error('materialNoCompatibleValues');
return applyNodeMaterialValues(app, targetNode, sourceWidgetValues.map((value, index) => ({ index, value })));
}
let activePopoverTimer = null;
let activeHideTimer = null;
let currentPopoverEl = null;
function extractNodeParamSummary(node, widgetValues) {
if (!Array.isArray(widgetValues)) return { isText: false, textPreview: '', fullText: '', pills: [], details: [] };
const widgetNames = (node?.widgets || []).map(w => w.name || '');
const stringVal = widgetValues.find(v => typeof v === 'string' && v.trim().length > 15);
const isText = Boolean(stringVal && (/cliptextencode|prompt/i.test(node?.type || '') || widgetValues.length === 1));
if (isText && stringVal) {
return {
isText: true,
textPreview: stringVal.length > 60 ? stringVal.slice(0, 60) + '...' : stringVal,
fullText: stringVal,
pills: [],
details: widgetValues.map((val, i) => ({
name: widgetNames[i] || `Widget #${i}`,
value: String(val)
}))
};
}
const pills = [];
const details = [];
widgetValues.forEach((val, i) => {
const name = (widgetNames[i] || `param_${i}`).toLowerCase();
const strVal = String(val);
details.push({ name: widgetNames[i] || `Param #${i}`, value: strVal });
if (pills.length < 5) {
if (name.includes('step')) {
pills.push({ label: `${strVal} steps`, type: 'steps' });
} else if (name.includes('cfg')) {
pills.push({ label: `CFG ${strVal}`, type: 'cfg' });
} else if (name.includes('sampler')) {
pills.push({ label: strVal, type: 'sampler' });
} else if (name.includes('scheduler')) {
pills.push({ label: strVal, type: 'scheduler' });
} else if (name.includes('denoise')) {
pills.push({ label: `denoise ${strVal}`, type: 'denoise' });
} else if (name.includes('width') || name.includes('height')) {
pills.push({ label: `${strVal}px`, type: 'size' });
} else if (typeof val === 'number') {
pills.push({ label: `${widgetNames[i] || 'val'}: ${strVal}`, type: 'num' });
} else if (typeof val === 'string' && val.length > 0 && val.length <= 20 && !val.includes('/')) {
pills.push({ label: strVal, type: 'str' });
}
}
});
if (pills.length === 0) {
details.slice(0, 3).forEach(d => {
const shortVal = d.value.length > 15 ? d.value.slice(0, 15) + '...' : d.value;
pills.push({ label: `${d.name}: ${shortVal}`, type: 'generic' });
});
}
return { isText: false, textPreview: '', fullText: '', pills, details };
}
function showParamPopover(anchorEl, { node, sourceTitle, schemeTitle, promptRoleTag, summary, onApply }) {
clearTimeout(activeHideTimer);
clearTimeout(activePopoverTimer);
activePopoverTimer = setTimeout(() => {
if (!anchorEl.isConnected) return;
if (!currentPopoverEl) {
currentPopoverEl = document.createElement('div');
currentPopoverEl.className = 'anomalous-param-popover';
document.body.appendChild(currentPopoverEl);
currentPopoverEl.addEventListener('mouseenter', () => {
clearTimeout(activeHideTimer);
});
currentPopoverEl.addEventListener('mouseleave', () => {
hideParamPopover(true);
});
}
currentPopoverEl.innerHTML = '';
const popHeader = document.createElement('div');
popHeader.style.cssText = 'display:flex;flex-direction:column;gap:4px;border-bottom:1px solid rgba(255,255,255,0.08);padding-bottom:10px;margin-bottom:10px;';
const srcTag = document.createElement('div');
srcTag.style.cssText = 'font-size:10px;color:#9ca3af;display:flex;align-items:center;gap:4px;text-transform:uppercase;letter-spacing:0.5px;';
srcTag.textContent = `${t('assistantSchemeSource') || '来源'}: ${sourceTitle}`;
popHeader.appendChild(srcTag);
const titleDiv = document.createElement('div');
titleDiv.style.cssText = 'font-size:14px;font-weight:700;color:#f3f4f6;display:flex;align-items:center;gap:6px;';
titleDiv.innerHTML = `${promptRoleTag || ''}<span>${escapeHtml(schemeTitle)}</span>`;
popHeader.appendChild(titleDiv);
currentPopoverEl.appendChild(popHeader);
if (summary.isText && summary.fullText) {
const copyRow = document.createElement('div');
copyRow.style.cssText = 'display:flex;justify-content:flex-end;margin-bottom:6px;';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.style.cssText = 'background:transparent;border:1px solid rgba(255,255,255,0.15);color:#cbd5e1;border-radius:4px;padding:3px 8px;font-size:10px;cursor:pointer;';
copyBtn.textContent = '📋 复制文本';
copyBtn.onclick = () => {
navigator.clipboard.writeText(summary.fullText).then(() => {
copyBtn.textContent = '✅ 已复制';
setTimeout(() => copyBtn.textContent = '📋 复制文本', 1200);
});
};
copyRow.appendChild(copyBtn);
currentPopoverEl.appendChild(copyRow);
const textBox = document.createElement('div');
textBox.style.cssText = 'max-height:240px;overflow-y:auto;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:8px;padding:10px;font-size:11.5px;line-height:1.6;color:#e2e8f0;white-space:pre-wrap;word-break:break-word;font-family:monospace;';
textBox.textContent = summary.fullText;
currentPopoverEl.appendChild(textBox);
} else if (summary.details.length > 0) {
const tableWrap = document.createElement('div');
tableWrap.style.cssText = 'max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:5px;';
for (const d of summary.details) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(255,255,255,0.03);padding:6px 10px;border-radius:6px;font-size:11.5px;';
const keySpan = document.createElement('span');
keySpan.style.cssText = 'color:#94a3b8;font-family:monospace;font-size:11px;';
keySpan.textContent = d.name;
const valSpan = document.createElement('span');
valSpan.style.cssText = 'color:#f1f5f9;font-weight:600;font-family:monospace;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
valSpan.textContent = d.value;
valSpan.title = d.value;
row.append(keySpan, valSpan);
tableWrap.appendChild(row);
}
currentPopoverEl.appendChild(tableWrap);
}
const popFooter = document.createElement('div');
popFooter.style.cssText = 'margin-top:12px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.08);display:flex;justify-content:flex-end;';
const popApplyBtn = document.createElement('button');
popApplyBtn.type = 'button';
popApplyBtn.className = 'anomalous-scheme-apply-btn';
popApplyBtn.style.cssText = 'width:100%;padding:8px 14px;font-size:12px;font-weight:700;border-radius:7px;background:rgba(25,118,210,0.9);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;';
popApplyBtn.textContent = t('assistantApplyScheme') || '⚡ 应用方案到当前节点';
popApplyBtn.onclick = () => onApply();
popFooter.appendChild(popApplyBtn);
currentPopoverEl.appendChild(popFooter);
currentPopoverEl.style.display = 'flex';
const anchorRect = anchorEl.getBoundingClientRect();
const popWidth = 340;
let left = anchorRect.left - popWidth - 14;
if (left < 10) {
left = anchorRect.right + 14;
}
if (left + popWidth > window.innerWidth - 10) {
left = window.innerWidth - popWidth - 10;
}
let top = anchorRect.top - 8;
if (top + 420 > window.innerHeight) {
top = Math.max(16, window.innerHeight - 440);
}
currentPopoverEl.style.left = `${Math.max(10, left)}px`;
currentPopoverEl.style.top = `${Math.max(10, top)}px`;
}, 120);
}
function hideParamPopover(immediate = false) {
clearTimeout(activePopoverTimer);
if (immediate) {
if (currentPopoverEl) currentPopoverEl.style.display = 'none';
} else {
activeHideTimer = setTimeout(() => {
if (currentPopoverEl) currentPopoverEl.style.display = 'none';
}, 150);
}
}
function createSchemeCard({ node, sourceTitle, sourceBadge, schemeTitle, role, widgetValues, onApply }) {
const summary = extractNodeParamSummary(node, widgetValues);
const card = document.createElement('div');
card.className = 'anomalous-assistant-scheme-card';
card.style.cssText = 'background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;transition:all 0.2s cubic-bezier(0.16, 1, 0.3, 1);position:relative;cursor:default;';
card.onmouseover = () => {
card.style.borderColor = 'rgba(255,255,255,0.2)';
card.style.background = 'rgba(255,255,255,0.06)';
};
card.onmouseout = () => {
card.style.borderColor = 'rgba(255,255,255,0.08)';
card.style.background = 'rgba(255,255,255,0.04)';
};
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;';
const titleWrap = document.createElement('div');
titleWrap.style.cssText = 'display:flex;align-items:center;gap:6px;min-width:0;flex:1;';
const promptRoleTag = {
positive: `[🟢 ${t('recipePromptRolePositive') || '正向'}] `,
negative: `[🔴 ${t('recipePromptRoleNegative') || '负向'}] `,
both: `[🟣 ${t('recipePromptRoleBoth') || '混合'}] `,
}[role] || (/cliptextencode/i.test(node?.type || '') ? `[⚪ ${t('recipePromptRoleUnknown') || '提示词'}] ` : '');
const titleEl = document.createElement('span');
titleEl.style.cssText = 'font-weight:700;font-size:12px;color:#f3f4f6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
titleEl.innerHTML = `${promptRoleTag}${escapeHtml(schemeTitle)}`;
titleWrap.appendChild(titleEl);
const badgeEl = document.createElement('span');
badgeEl.style.cssText = 'font-size:9px;color:#9ca3af;padding:2px 6px;border-radius:999px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);flex-shrink:0;max-width:35%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
badgeEl.textContent = sourceBadge || sourceTitle;
badgeEl.title = sourceTitle;
headerRow.append(titleWrap, badgeEl);
card.appendChild(headerRow);
const contentRow = document.createElement('div');
contentRow.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0;';
if (summary.isText && summary.textPreview) {
const textSnippet = document.createElement('div');
textSnippet.style.cssText = 'font-size:11px;color:#cbd5e1;background:rgba(0,0,0,0.25);border:1px solid rgba(255,255,255,0.06);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;';
textSnippet.textContent = summary.textPreview;
contentRow.appendChild(textSnippet);
} else if (summary.pills.length > 0) {
for (const pill of summary.pills) {
const pillSpan = document.createElement('span');
let pillBg = 'rgba(255,255,255,0.07)';
let pillColor = '#e2e8f0';
let pillBorder = 'rgba(255,255,255,0.1)';
if (pill.type === 'steps') { pillBg = 'rgba(59,130,246,0.15)'; pillColor = '#93c5fd'; pillBorder = 'rgba(59,130,246,0.3)'; }
else if (pill.type === 'cfg') { pillBg = 'rgba(245,158,11,0.15)'; pillColor = '#fcd34d'; pillBorder = 'rgba(245,158,11,0.3)'; }
else if (pill.type === 'sampler' || pill.type === 'scheduler') { pillBg = 'rgba(168,85,247,0.15)'; pillColor = '#d8b4fe'; pillBorder = 'rgba(168,85,247,0.3)'; }
else if (pill.type === 'denoise') { pillBg = 'rgba(16,185,129,0.15)'; pillColor = '#6ee7b7'; pillBorder = 'rgba(16,185,129,0.3)'; }
pillSpan.style.cssText = `font-size:10px;font-weight:600;padding:2px 7px;border-radius:5px;background:${pillBg};color:${pillColor};border:1px solid ${pillBorder};white-space:nowrap;`;
pillSpan.textContent = pill.label;
contentRow.appendChild(pillSpan);
}
}
card.appendChild(contentRow);
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:2px;';
const hintSpan = document.createElement('span');
hintSpan.style.cssText = 'font-size:10px;color:#64748b;display:flex;align-items:center;gap:4px;';
hintSpan.innerHTML = `<span style="font-size:11px;">🔍</span> <span>${t('assistantHoverToInspect') || '悬停预览详情'}</span>`;
actionRow.appendChild(hintSpan);
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'anomalous-scheme-apply-btn';
applyBtn.style.cssText = 'padding:5px 12px;font-size:11px;font-weight:700;border-radius:6px;background:rgba(25,118,210,0.85);color:#fff;border:1px solid rgba(255,255,255,0.18);cursor:pointer;transition:all 0.15s;display:flex;align-items:center;gap:4px;box-shadow:0 2px 6px rgba(0,0,0,0.2);';
applyBtn.innerHTML = t('assistantApplyScheme') || '⚡ 应用方案';
applyBtn.onmouseover = (e) => { e.stopPropagation(); applyBtn.style.filter = 'brightness(1.15)'; };
applyBtn.onmouseout = (e) => { e.stopPropagation(); applyBtn.style.filter = 'none'; };
applyBtn.onclick = async (e) => {
e.stopPropagation();
applyBtn.disabled = true;
applyBtn.innerHTML = t('assistantApplying') || '⏳ 应用中...';
applyBtn.style.background = 'rgba(255,255,255,0.1)';
try {
await onApply();
applyBtn.innerHTML = t('assistantApplied') || '✅ 已应用';
applyBtn.style.background = 'rgba(16,185,129,0.7)';
} catch (err) {
console.error('[Anomalous] Apply scheme failed:', err);
applyBtn.innerHTML = '⚠️ 失败';
applyBtn.style.background = 'rgba(239,68,68,0.7)';
}
setTimeout(() => {
if (!applyBtn.isConnected) return;
applyBtn.disabled = false;
applyBtn.innerHTML = t('assistantApplyScheme') || '⚡ 应用方案';
applyBtn.style.background = 'rgba(25,118,210,0.85)';
}, 1500);
};
actionRow.appendChild(applyBtn);
card.appendChild(actionRow);
card.addEventListener('mouseenter', () => {
showParamPopover(card, {
node,
sourceTitle,
schemeTitle,
promptRoleTag,
summary,
onApply: () => applyBtn.click()
});
});
card.addEventListener('mouseleave', () => {
hideParamPopover(false);
});
return card;
}
function renderMaterialPresets(node, container, forceRefresh) {
const section = document.createElement('div');
section.className = 'anomalous-assistant-parameter-presets anomalous-assistant-material-presets';
section.style.cssText = 'margin:14px 16px; display:flex; flex-direction:column; gap:8px;';
const header = document.createElement('div');
header.style.cssText = 'color:#68cdb9;font-size:10px;font-weight:750;letter-spacing:0.1em;text-transform:uppercase;';
header.textContent = t('materialLibrary');
const browse = document.createElement('button');
browse.textContent = t('materialShowCompatible');
browse.className = 'anomalous-btn-ghost';
browse.onclick = () => window.anomalousBrowserInstance?.openMaterialLibrary();
header.appendChild(browse);
const loader = document.createElement('div');
loader.style.cssText = 'font-size:12px;color:#555;text-align:center;padding:10px;';
loader.textContent = t('loading');
section.append(header, loader);
container.appendChild(section);
const url = `/anomalous/materials/by_node_type?type=${encodeURIComponent(node.type)}${forceRefresh ? '&refresh=1' : ''}`;
fetch(url, { cache: 'no-store' }).then(response => {
if (!response.ok) throw new Error('material preset request failed');
return response.json();
}).then(payload => {
loader.remove();
const materials = Array.isArray(payload.materials) ? payload.materials : [];
if (!materials.length) {
const empty = document.createElement('div');
empty.style.cssText = 'font-size:11px;color:#666;text-align:center;padding:10px;background:rgba(0,0,0,.2);border-radius:8px;border:1px dashed rgba(255,255,255,.1);';
empty.textContent = t('materialNoNodePresets');
section.appendChild(empty);
return;
}
for (const material of materials) {
const group = document.createElement('div');
group.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid rgba(92,202,180,.2);border-radius:10px;background:rgba(92,202,180,.04);';
const name = document.createElement('strong');
name.innerHTML = `<svg style="width:13px;height:13px;margin-right:5px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>${escapeHtml(material.name || t('materialUntitled'))}`;
name.style.cssText = 'font-size:12px;color:#bcebe1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
group.appendChild(name);
for (const block of (Array.isArray(material.blocks) ? material.blocks : [])) {
const suffix = material.blocks.length > 1 ? ` #${block.occurrence}` : '';
const schemeTitle = `${block.title || block.type}${suffix}`;
const card = createSchemeCard({
node,
sourceTitle: material.name || t('materialLibrary'),
sourceBadge: t('materialLibrary'),
schemeTitle,
role: block.role,
widgetValues: block.widgets_values,
onApply: () => applyMaterialToSelectedNode(node, block, material.workflow_hashes, group)
});
group.appendChild(card);
}
section.appendChild(group);
}
}).catch(error => {
console.error('Failed to load material presets:', error);
loader.textContent = t('materialLoadError');
loader.style.color = '#ff7070';
});
}
export function renderParameterPresets(node, container, forceRefresh = false) {
if (!node || !node.type) return;
const betaNotice = document.createElement('div');
betaNotice.className = 'anomalous-beta-notice anomalous-assistant-beta-notice';
const betaBadge = document.createElement('strong');
betaBadge.className = 'anomalous-beta-badge';
betaBadge.dataset.anomalousI18nKey = 'betaFeature';
betaBadge.textContent = t('betaFeature');
const betaText = document.createElement('span');
betaText.dataset.anomalousI18nKey = 'assistantBetaNotice';
betaText.textContent = t('assistantBetaNotice');
betaNotice.append(betaBadge, betaText);
container.appendChild(betaNotice);
renderMaterialPresets(node, container, forceRefresh);
const presetSection = document.createElement('div');
presetSection.className = 'anomalous-assistant-parameter-presets';
presetSection.style.cssText = 'margin:14px 16px; display:flex; flex-direction:column; gap:8px;';
const header = document.createElement('div');
header.style.cssText = 'color:#8b91a3;font-size:10px;font-weight:750;letter-spacing:0.1em;text-transform:uppercase;';
header.textContent = t('recipeParameterNotebooks') || 'Parameter Notebooks';
presetSection.appendChild(header);
const loader = document.createElement('div');
loader.style.cssText = 'font-size:12px; color:#555; text-align:center; padding:10px;';
loader.textContent = t('loading') || 'Loading...';
presetSection.appendChild(loader);
container.appendChild(presetSection);
// Debounce fetching
if (this._presetFetchTimer) clearTimeout(this._presetFetchTimer);
this._presetFetchTimer = setTimeout(async () => {
try {
const fetchUrl = `/anomalous/parameters/by_node_type?type=${encodeURIComponent(node.type)}${forceRefresh ? '&refresh=1' : ''}`;
const res = await fetch(fetchUrl);
if (!res.ok) throw new Error('Network error');
const data = await res.json();
loader.style.display = 'none';
if (!Array.isArray(data.groups) || data.groups.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'font-size:11px; color:#666; text-align:center; padding:10px; background:rgba(0,0,0,0.2); border-radius:8px; border:1px dashed rgba(255,255,255,0.1);';
empty.textContent = t('assistantNoPresets') || 'No presets found for this node type.';
presetSection.appendChild(empty);
return;
}
const flatContainer = document.createElement('div');
flatContainer.style.cssText = 'display:flex; flex-direction:column; gap:6px; margin-top:8px;';
let presetCount = 0;
let renderedGroups = 0;
for (const group of data.groups) {
const displayName = String(group.recipe_name || group.recipe_filename || t('recipeUntitled'));
// Hide unbound and deleted recipes as per user request
if (group.recipe_filename === 'unbound' || displayName.endsWith('.json')) {
continue;
}
const recipeBox = document.createElement('div');
recipeBox.style.cssText = 'background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.05); border-radius:10px; overflow:hidden;';
const recipeHeader = document.createElement('div');
recipeHeader.style.cssText = 'padding:10px 12px; font-size:12px; font-weight:bold; color:#c9d6ff; cursor:pointer; display:flex; align-items:center; gap:8px; background:rgba(0,0,0,0.2);';
recipeHeader.innerHTML = `<span style="font-size:14px;">🍱</span> <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(displayName)}</span> <span>▼</span>`;
const notebookList = document.createElement('div');
notebookList.style.cssText = 'display:flex; flex-direction:column; gap:6px; padding:8px;';
let hasNodes = false;
for (const nb of (Array.isArray(group.notebooks) ? group.notebooks : [])) {
for (const n of (Array.isArray(nb?.nodes) ? nb.nodes : [])) {
hasNodes = true;
presetCount++;
let summaryTitle = nb.name || t('recipeParameterUntitled') || 'Untitled';
if (nb.nodes.length > 1) {
summaryTitle += ` - ${n.title}`;
}
const card = createSchemeCard({
node,
sourceTitle: displayName,
sourceBadge: t('recipeParameterNotebooks') || '配方',
schemeTitle: summaryTitle,
role: n.role,
widgetValues: n.widgets_values,
onApply: () => applyLocalNodeParameters(node, n.widgets_values)
});
notebookList.appendChild(card);
}
}
if (hasNodes) {
recipeBox.appendChild(recipeHeader);
recipeBox.appendChild(notebookList);
flatContainer.appendChild(recipeBox);
if (renderedGroups > 0) {
notebookList.style.display = 'none';
recipeHeader.querySelector('span:last-child').textContent = '▶';
}
renderedGroups++;
recipeHeader.onclick = () => {
if (notebookList.style.display === 'none') {
notebookList.style.display = 'flex';
recipeHeader.querySelector('span:last-child').textContent = '▼';
} else {
notebookList.style.display = 'none';
recipeHeader.querySelector('span:last-child').textContent = '▶';
}
};
}
}
if (presetCount === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'color:#aaa; font-style:italic; font-size:12px; padding:10px; text-align:center;';
empty.textContent = t('assistantNoPresets') || 'No presets found for this node type.';
presetSection.appendChild(empty);
} else {
presetSection.appendChild(flatContainer);
}
} catch (e) {
console.error("Failed to load parameter presets", e);
loader.textContent = 'Error loading presets.';
loader.style.color = '#ff5252';
}
}, 300);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function createSettingsHub(owner, {
const selectedNode = Object.values(app.canvas?.selected_nodes || {})[0] || null;
b.assistantPanelInitialized = false;
b.initAssistantPanel();
b.diagnoseNode(selectedNode, true);
b.diagnoseNode(selectedNode);
}
if (b.notebookNotesTab) b.notebookNotesTab.textContent = t('promptNotes');
if (b.notebookRecipesTab) b.notebookRecipesTab.textContent = t('recipeTitle');
+1
View File
@@ -18,3 +18,4 @@
@import url("./styles/17-model-types.css?v=20260930-types-1");
@import url("./styles/18-scan-page.css?v=20261002-scan-1");
@import url("./styles/19-model-doctor.css?v=20261002-doctor-1");
@import url("./styles/20-current-node.css?v=20261002-node-1");
-42
View File
@@ -274,12 +274,6 @@ dialog.anomalous-gallery-viewer[open] {
overflow: hidden;
}
/* 彻底隐匿原本粗暴占位的 Beta 警示大黄框 */
.anomalous-recipe-beta-notice,
.anomalous-beta-notice.anomalous-recipe-beta-notice {
display: none !important;
}
/* 一体化 Studio 沉浸顶栏 (无厚重底色,细微下划线,支持弹性折行) */
.anomalous-recipe-topbar {
display: flex;
@@ -1464,42 +1458,6 @@ dialog.anomalous-gallery-viewer[open] {
font-size: 0.75em;
}
.anomalous-beta-notice {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 9px;
margin: 10px 14px 0;
padding: 9px 11px;
border: 1px solid rgba(245, 190, 73, 0.38);
border-radius: 9px;
color: #e9d7aa;
background: linear-gradient(110deg, rgba(245, 158, 11, 0.13), rgba(245, 158, 11, 0.04));
font-size: 0.78rem;
line-height: 1.45;
}
.anomalous-beta-notice > span {
min-width: 0;
overflow-wrap: anywhere;
}
.anomalous-beta-badge {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid rgba(251, 191, 36, 0.5);
border-radius: 999px;
color: #ffe4a3;
background: rgba(245, 158, 11, 0.12);
font-size: 0.7rem;
white-space: nowrap;
}
.anomalous-assistant-beta-notice {
margin: 10px 12px 0;
flex-shrink: 0;
}
.anomalous-recipe-parameter-notebook-row {
display: grid;
min-width: 0;
-28
View File
@@ -2108,34 +2108,6 @@
color: #eee;
}
/* ==========================================================================
Node Assistant Hover Parameter Popover Card
========================================================================== */
.anomalous-param-popover {
position: fixed;
z-index: 1000002;
width: 330px;
max-height: 480px;
display: flex;
flex-direction: column;
background: rgba(22, 22, 27, 0.96);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(255, 255, 255, 0.05);
color: #f3f4f6;
padding: 14px;
box-sizing: border-box;
pointer-events: auto;
animation: anomalous-popover-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes anomalous-popover-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ==========================================================================
Prompt Composer & Paper Note Studio / Material Drag System
========================================================================== */
+161
View File
@@ -0,0 +1,161 @@
/* The current-node panel (Node Assistant): one scrolling body of sections; the parameters list. */
.anomalous-node-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding-bottom: 16px;
}
.anomalous-node-section {
display: flex;
flex-direction: column;
gap: 8px;
margin: 14px 16px 0;
}
.anomalous-node-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.anomalous-node-section-head h3 {
margin: 0;
color: #8b91a3;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.anomalous-node-section-btn {
padding: 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: transparent;
color: #d1d5db;
font-size: 11.5px;
cursor: pointer;
}
.anomalous-node-section-btn:hover {
border-color: rgba(165, 180, 252, 0.45);
color: #ffffff;
}
.anomalous-node-param-search {
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(0, 0, 0, 0.25);
color: #e5e7eb;
font-size: 12px;
}
.anomalous-node-param-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.anomalous-node-param {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.035);
}
.anomalous-node-param-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.anomalous-node-param-name {
overflow: hidden;
color: #f3f4f6;
font-size: 12.5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.anomalous-node-param-role {
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #c4b5fd;
font-size: 10px;
}
.anomalous-node-param-role.is-positive { color: #86efac; }
.anomalous-node-param-role.is-negative { color: #fca5a5; }
.anomalous-node-param-sources {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.anomalous-node-param-sources span {
padding: 1px 7px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
color: #9ca3af;
font-size: 10px;
}
.anomalous-node-param-changes {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
color: #d1d5db;
font-size: 11.5px;
}
.anomalous-node-param-changes li {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.anomalous-node-param-changes li.is-more,
.anomalous-node-param-kept,
.anomalous-node-muted {
margin: 0;
color: #8b91a3;
font-size: 11px;
line-height: 1.5;
}
.anomalous-node-muted.is-error { color: #fca5a5; }
.anomalous-node-param-apply {
align-self: flex-end;
padding: 5px 12px;
border: 1px solid rgba(165, 180, 252, 0.45);
border-radius: 7px;
background: rgba(165, 180, 252, 0.14);
color: #e5e7eb;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
}
.anomalous-node-param-apply:disabled {
border-color: rgba(255, 255, 255, 0.08);
background: transparent;
color: #6b7280;
cursor: default;
}