From 2777d86831e515a2945f11bc70ae10a80aaea7bd Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:41:23 +0800 Subject: [PATCH] Current node: where saved values came from, their picture, side by side Each saved set for the selected node is a card with the picture made with its values (or the kept picture it came from) and a line per source: a workflow's parameter set (with how many pictures it made, Open shows that set), a kept picture (See it), or saved here, with the day. Changes are drawn: numbers as two bars with an arrow, choices as old -> new; what stays says why; Show all lists every value. Compare opens the whole workflow against the canvas. With two sets or more, a side-by-side table (picture on top of each column, changed values marked, Use N under each). Backend: materials/by_node_type tells each record's origin, kept picture and preview; parameters/by_node_type gives each set's signature; new GET /anomalous/parameter_pictures counts the pictures of many sets in one pass, reading each picture's signature once per file version. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 9 +- CHANGELOG.md | 2 + api/__init__.py | 1 + api/materials.py | 8 + api/parameters.py | 21 +- api/recipe_images.py | 31 ++- docs/architecture/backend.md | 8 + docs/architecture/material-library.md | 5 + tests/node_parameter_sets.mjs | 25 +- tests/test_material_browsing.py | 17 ++ tests/test_recipe_roundtrip.py | 21 ++ web/modules/locales.js | 52 +++- web/modules/node_parameter_sets.js | 80 ++++-- web/modules/ui_node_assistant.js | 2 +- web/modules/ui_node_parameters.js | 363 ++++++++++++++++++++++---- web/styles.css | 2 +- web/styles/20-current-node.css | 341 ++++++++++++++++++++++-- 17 files changed, 882 insertions(+), 106 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3d7dc35..f60bf79 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -575,10 +575,13 @@ covered by the `styles.css` manifest. is the page's Download buttons, progress and the dialog that shows and changes where each file goes. `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 + replacement, `ui_node_parameters.js` renders the panel's parameters section (each saved + set as a card with the picture made with it and where it came from, values as bars or + old → new, or all sets side by side), and `node_parameter_sets.js` merges the node type's saved values (material files and recipe - parameter sets) and works out what each would change on the node, keeping seeds, - model files and missing choices; saved values (not a recipe's) are deleted there. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js` + parameter sets, each source with its origin) and works out what each would change on the node, keeping seeds, + model files and missing choices, and asks `/anomalous/parameter_pictures` for the recent pictures made with + the sets' values; saved values (not a recipe's) are deleted there. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js` own the remaining explicit graph changes. - A model's download link lives in its user layer (`source_url` in `.anomalous.json`, via `/anomalous/update_metadata`), the same field the model editor edits; for the open workflow's diff --git a/CHANGELOG.md b/CHANGELOG.md index a420a24..ba9f06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ - Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back. - **What changed since the last picture (和最新出图对比)**: **⇄ Canvas vs. newest picture** at the top of the Gallery and of Activity compares the canvas with the newest picture in the output folder, edits made by hand on the canvas included, and can put the picture's values back. - **Key parameters (关键参数)**: press ☆ before a value's name (tick **Show equal values too** to star one that matches) and it stays at the top of every comparison, for every workflow, kept with your ComfyUI settings. +- **Current node shows where saved values came from (当前节点的参数方案:出处、出图、并排)**: each saved set for the selected node is a card with the picture made with those values (or the picture they were kept from; click to see it large) and a line on where it came from, with the day: **a parameter set of workflow X** (with how many pictures it made, and **Open** to show that set in the workflow), **kept with picture Y** (**See it**), or **saved here**. The values it would change are drawn instead of written: numbers as two bars (now and then) with ↑ or ↓, choices as the old one struck through → the new one. What stays (seeds, models, choices this computer lacks) is said with why; **Show all** lists every value, equal ones too (there is no more "4 shown, N more"). **⇄ Compare** opens the comparison of the whole workflow with the canvas, not only this node. +- **Side by side (并排)**: with two sets or more, switch to a table: the current values and each set in columns (its picture on top), the values a set would change marked, **Use N** under each. The choice is remembered. - **Parameter sets in view (参数方案更好找)**: a workflow card with saved sets shows **▤ N** (how many), which opens them directly. Each set there shows its key values (those unlike the workflow's own in colour), how many values differ, and ⇄ to compare it with the workflow. ### 🗂️ A tidier Workflows page (工作流页面重做) diff --git a/api/__init__.py b/api/__init__.py index 2c59ac9..412e5e6 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -60,6 +60,7 @@ def setup_routes(app): app.router.add_post('/anomalous/rename_parameter', api_rename_parameter) app.router.add_post('/anomalous/delete_parameter', api_delete_parameter) app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery) + app.router.add_get('/anomalous/parameter_pictures', api_get_parameter_pictures) # Recipe Routes app.router.add_get('/anomalous/recipes', recipes.api_get_recipes) diff --git a/api/materials.py b/api/materials.py index 6045e84..0d33a07 100644 --- a/api/materials.py +++ b/api/materials.py @@ -399,6 +399,10 @@ async def api_get_materials_by_node_type(request): continue blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type] if blocks: + # Where the values came from, for Current node: a kept picture, or a node saved there. + source = material.get("source") if isinstance(material.get("source"), dict) else {} + image = source.get("image") if isinstance(source.get("image"), dict) else {} + preview = str((material.get("image") if isinstance(material.get("image"), dict) else {}).get("preview_asset_id") or "") matches.append({ "filename": summary["filename"], "name": summary["name"], @@ -406,6 +410,10 @@ async def api_get_materials_by_node_type(request): "timestamp": summary["timestamp"], "blocks": blocks, "workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks), + "origin": str(source.get("type") or ""), + "source_image": {"filename": str(image["filename"]), "subfolder": str(image.get("subfolder") or "")} + if isinstance(image.get("filename"), str) and image["filename"] else None, + "preview": preview if preview.lower().endswith((".png", ".webp")) else "", }) return matches diff --git a/api/parameters.py b/api/parameters.py index 3ba872d..8c1803d 100644 --- a/api/parameters.py +++ b/api/parameters.py @@ -9,7 +9,7 @@ from aiohttp import web import folder_paths from .utils import require_filename, resolve_within from .recipe_constants import MAX_RECIPE_BYTES -from .recipe_images import _parameter_gallery_images +from .recipe_images import _parameter_gallery_images, _pictures_by_signature from .recipe_store import get_recipes_dir, _read_recipe from .workflow_schema import _parameter_signature from .trash import move_to_trash @@ -188,10 +188,12 @@ async def api_get_parameters_by_type(request): if recipe_fn not in grouped: grouped[recipe_fn] = [] + signature = (nb.get("data", {}).get("parameter_signature") or {}) grouped[recipe_fn].append({ "filename": nb.get("filename"), "name": nb.get("name"), "timestamp": nb.get("timestamp"), + "signature": signature.get("value") if isinstance(signature, dict) else "", "nodes": matched_nodes }) @@ -385,6 +387,23 @@ async def api_rename_parameter(request): return web.json_response({"status": "success", "filename": filename, "name": new_name}) +MAX_PICTURE_SIGNATURES = 64 + + +async def api_get_parameter_pictures(request): + """GET /anomalous/parameter_pictures?signatures=a,b - for Current node: how many recent + output pictures were made with each parameter set's values, and the newest, in one pass.""" + signatures = {value.lower() for value in request.query.get("signatures", "").split(",") if value} + if not signatures or len(signatures) > MAX_PICTURE_SIGNATURES \ + or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in signatures): + return web.json_response({"status": "error", "message": "Invalid signatures"}, status=400) + try: + pictures, scanned = await asyncio.to_thread(_pictures_by_signature, signatures) + except OSError: + return web.json_response({"status": "error", "message": "Could not read the pictures"}, status=500) + return web.json_response({"status": "success", "pictures": pictures, "scanned": scanned}) + + async def api_get_parameter_gallery(request): """Find recent output PNGs whose embedded parameters match one parameter notebook.""" try: diff --git a/api/recipe_images.py b/api/recipe_images.py index a58d90d..fd1532c 100644 --- a/api/recipe_images.py +++ b/api/recipe_images.py @@ -1,6 +1,7 @@ """Contained Recipe image assets and bounded output-gallery inspection.""" from io import BytesIO +from functools import lru_cache import hashlib import heapq import json @@ -192,6 +193,34 @@ def _recipe_gallery_images(node_signature): return matches, scanned +@lru_cache(maxsize=4096) +def _picture_parameter_signature(image_path, mtime_ns): + """A picture's parameter signature, read once per version of the file.""" + return _embedded_parameter_signature(image_path) + + +def _pictures_by_signature(signatures): + """({signature: {"count", "newest": {filename, subfolder}}}, pictures read) for the recent + output pictures made with each of `signatures`, newest first, in one pass.""" + output_dir = folder_paths.get_output_directory() + found = {} + if not os.path.isdir(output_dir): + return found, 0 + scanned = 0 + for mtime_ns, image_path in _recent_output_pngs(output_dir): + scanned += 1 + signature = _picture_parameter_signature(image_path, mtime_ns) + if signature not in signatures: + continue + entry = found.setdefault(signature, {"count": 0, "newest": None}) + entry["count"] += 1 + if entry["newest"] is None: + relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir) + entry["newest"] = {"filename": os.path.basename(image_path), + "subfolder": "" if relative_dir == "." else relative_dir.replace("\\", "/")} + return found, scanned + + def _parameter_gallery_images(parameter_signature): output_dir = folder_paths.get_output_directory() if not os.path.isdir(output_dir): @@ -200,7 +229,7 @@ def _parameter_gallery_images(parameter_signature): scanned = 0 for mtime_ns, image_path in _recent_output_pngs(output_dir): scanned += 1 - if _embedded_parameter_signature(image_path) != parameter_signature: + if _picture_parameter_signature(image_path, mtime_ns) != parameter_signature: continue relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir) subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/") diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index e28918d..eb3d525 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -172,6 +172,14 @@ else 400 and nothing changes) into the user layer, and covers through `set_custo (`api/model_media.py`, read-only) tells it Civitai's kept image (`.civitai_bak.*`, as an image URL) and whether the cover shown is the user's, so it can offer Civitai's back. +Current node's saved values: `GET /anomalous/parameters/by_node_type` gives each recipe set's +file and values' `signature`; `GET /anomalous/parameter_pictures?signatures=a,b` +(`api/parameters.py`, at most 64 signatures, 64 hex characters each) counts, in one pass over +the newest output pictures (`recipe_images._recent_output_pngs`), how many were made with each +set's values and names the newest. A picture's signature is read once per file version +(`lru_cache` of 4096 entries keyed by path and modification time), which the workflow page's +`parameter_gallery` shares. + The scan page's picker reads `GET /anomalous/scan_pickable` (`api/scan_summary.py`): the same files the summary counts (the scan folders' `.safetensors`, each once), the "don't scan" ones included, up to 5000 with the exact `total`. Each carries its locator (`type`, diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md index eefff58..cee384b 100644 --- a/docs/architecture/material-library.md +++ b/docs/architecture/material-library.md @@ -80,6 +80,11 @@ The route family is: - `POST /anomalous/update_material` - `POST /anomalous/delete_material` +`materials/by_node_type` (Current node's saved values) also says where each record's values +came from: `origin` (the source type, `generated_image` or `canvas_node`), `source_image` (the +kept picture's `filename` and `subfolder`, or null) and `preview` (the saved preview asset's id, +for `material_asset`, or ""). + `material_full?include_workflow=0` returns metadata and scoped node blocks (with `workflow_hashes` restricted to them) and no complete workflow; `include_workflow=1` returns the original workflow of a complete snapshot. Selected-node records never diff --git a/tests/node_parameter_sets.mjs b/tests/node_parameter_sets.mjs index 88f854e..966f790 100644 --- a/tests/node_parameter_sets.mjs +++ b/tests/node_parameter_sets.mjs @@ -1,17 +1,21 @@ import assert from 'node:assert/strict'; -import { applyParameterChanges, loadParameterSets, parameterChanges } from '../web/modules/node_parameter_sets.js'; +import { applyParameterChanges, loadParameterSets, loadSetPictures, parameterChanges, parameterRows } from '../web/modules/node_parameter_sets.js'; // Both sources, with the same values saved in a material and in a recipe parameter set. const shared = [123, 'fixed', 30, 5.5, 'euler', 'karras', 1]; +const asked = []; globalThis.fetch = async (url) => ({ ok: true, - json: async () => (String(url).includes('/materials/') + json: async () => (asked.push(String(url)), String(url).includes('/parameter_pictures') + ? { pictures: { ['a'.repeat(64)]: { count: 2, newest: { filename: 'p.png', subfolder: '' } } } } + : String(url).includes('/materials/') ? { materials: [ - { name: 'Slow', timestamp: 10, blocks: [{ title: 'KSampler', widgets_values: shared }] }, + { name: 'Slow', filename: 'm.json', timestamp: 10, origin: 'generated_image', source_image: { filename: 'kept.png', subfolder: '' }, preview: 'preview-1.webp', + blocks: [{ title: 'KSampler', widgets_values: shared }] }, { name: 'Fast', timestamp: 30, blocks: [{ title: 'KSampler', widgets_values: [1, 'fixed', 8, 2, 'lcm', 'sgm_uniform', 1] }] }, ] } : { groups: [ - { recipe_filename: 'r.json', recipe_name: 'Portrait', notebooks: [{ name: 'Slow set', timestamp: 20, nodes: [{ title: 'KSampler', widgets_values: shared }] }] }, + { recipe_filename: 'r.json', recipe_name: 'Portrait', notebooks: [{ name: 'Slow set', filename: 'params_1.json', signature: 'a'.repeat(64), timestamp: 20, nodes: [{ title: 'KSampler', widgets_values: shared }] }] }, { recipe_filename: 'unbound', notebooks: [{ name: 'Loose', timestamp: 99, nodes: [{ widgets_values: [9] }] }] }, { recipe_filename: 'gone.json', recipe_name: 'gone.json', notebooks: [{ name: 'Old', timestamp: 98, nodes: [{ widgets_values: [8] }] }] }, ] }), @@ -22,6 +26,16 @@ globalThis.fetch = async (url) => ({ assert.deepEqual(entries.map(entry => entry.name), ['Fast', 'Slow']); assert.deepEqual(entries[1].sources.map(source => [source.kind, source.label]), [['material', 'Slow'], ['recipe', 'Portrait']]); assert.equal(entries[1].timestamp, 20); + // Where each came from: the kept picture and its saved preview, the recipe's set file and signature. + const [kept, set] = entries[1].sources; + assert.deepEqual([kept.origin, kept.image, kept.preview], ['generated_image', { filename: 'kept.png', subfolder: '' }, '/anomalous/material_asset?filename=m.json&asset=preview-1.webp']); + assert.deepEqual([set.recipeFile, set.setFile, set.setName, set.signature], ['r.json', 'params_1.json', 'Slow set', 'a'.repeat(64)]); + assert.equal(entries[0].sources[0].image, null); + // The pictures made with the sets' values: one request for all of them. + const pictures = await loadSetPictures(entries); + assert.equal(pictures['a'.repeat(64)].count, 2); + assert.equal(asked.filter(url => url.includes('/parameter_pictures')).length, 1); + assert.deepEqual(await loadSetPictures([entries[0]]), {}); // no recipe set, nothing asked } // What would change: seeds, model files and missing choices stay. @@ -42,6 +56,9 @@ function sampler() { const loader = { id: 4, type: 'CheckpointLoaderSimple', widgets: [widget('ckpt_name', 'a.safetensors', ['a.safetensors', 'b.safetensors'])] }; assert.equal(parameterChanges(loader, ['b.safetensors'])[0].keep, 'model'); assert.deepEqual(parameterChanges(node, ['not a number', 'fixed', 20]), []); // a value of another type is not this input + // Every value, the equal ones too, for "show all" and the side-by-side table. + assert.deepEqual(parameterRows(node, [5, 'fixed', 30, 7, 'dpmpp_2m']).map(row => [row.label, row.status]), + [['seed', 'same'], ['control_after_generate', 'same'], ['steps', 'change'], ['cfg', 'same'], ['sampler_name', 'change']]); } // Applying writes only the changes that are made, as one undoable step. diff --git a/tests/test_material_browsing.py b/tests/test_material_browsing.py index 6ee772c..c86a6d3 100644 --- a/tests/test_material_browsing.py +++ b/tests/test_material_browsing.py @@ -73,3 +73,20 @@ class MaterialsByNodeTypeTests(unittest.IsolatedAsyncioTestCase): response = await materials.api_get_materials_by_node_type(SimpleNamespace(query={'type': 'KSampler'})) self.assertEqual([item['name'] for item in json.loads(response.text)['materials']], ['kept']) + async def test_each_says_where_its_values_came_from(self): + with tempfile.TemporaryDirectory() as directory: + workflow = {'nodes': [{'id': 1, 'type': 'KSampler', 'widgets_values': [123, 'fixed', 20]}]} + Path(directory, 'kept.json').write_text(json.dumps({ + 'name': 'kept', 'workflow': workflow, 'kind': 'image_node_selection', + 'source': {'type': 'generated_image', 'image': {'filename': 'ComfyUI_00001_.png', 'subfolder': 'day', 'type': 'output'}}, + 'image': {'preview_asset_id': 'preview-1.webp'}})) + Path(directory, 'here.json').write_text(json.dumps({ + 'name': 'here', 'workflow': workflow, 'kind': 'node_parameter_selection', + 'source': {'type': 'canvas_node'}, 'image': {'preview_asset_id': '../escape.txt'}})) + with mock.patch.object(materials, 'get_materials_dir', lambda: directory): + response = await materials.api_get_materials_by_node_type(SimpleNamespace(query={'type': 'KSampler'})) + found = {item['name']: item for item in json.loads(response.text)['materials']} + self.assertEqual((found['kept']['origin'], found['kept']['source_image'], found['kept']['preview']), + ('generated_image', {'filename': 'ComfyUI_00001_.png', 'subfolder': 'day'}, 'preview-1.webp')) + self.assertEqual((found['here']['origin'], found['here']['source_image'], found['here']['preview']), ('canvas_node', None, '')) + diff --git a/tests/test_recipe_roundtrip.py b/tests/test_recipe_roundtrip.py index 1257dc5..9987ccf 100644 --- a/tests/test_recipe_roundtrip.py +++ b/tests/test_recipe_roundtrip.py @@ -19,6 +19,7 @@ from api import models from api import parameters from api import recipe_packages from api import materials +from api import recipe_images def recipe_payload(workflow): @@ -208,6 +209,26 @@ class RecipeWorkflowValidationTests(unittest.TestCase): self.assertEqual(scanned, 1) self.assertEqual([item["filename"] for item in matches], ["match.png"]) + def test_pictures_of_several_sets_are_counted_in_one_pass(self): + with tempfile.TemporaryDirectory() as directory: + first = valid_workflow() + second = json.loads(json.dumps(first)) + second["nodes"][1]["widgets_values"][2] = 42 + for name, workflow in (("a1.png", first), ("a2.png", first), ("b.png", second)): + info = PngImagePlugin.PngInfo() + info.add_text("workflow", json.dumps(workflow)) + Image.new("RGB", (8, 8), "white").save(Path(directory, name), pnginfo=info) + os.utime(Path(directory, "a2.png"), (2_000_000_000, 2_000_000_000)) # the newest + signature = parameters._parameter_signature(first)["value"] + previous_output_dir = recipe_images.folder_paths.get_output_directory + recipe_images.folder_paths.get_output_directory = lambda: directory + try: + found, scanned = recipe_images._pictures_by_signature({signature, "0" * 64}) + finally: + recipe_images.folder_paths.get_output_directory = previous_output_dir + self.assertEqual(scanned, 3) + self.assertEqual(found, {signature: {"count": 2, "newest": {"filename": "a2.png", "subfolder": ""}}}) + def test_gallery_comparison_reports_parameter_difference_after_node_match(self): recipe = valid_workflow() image_workflow = json.loads(json.dumps(recipe)) diff --git a/web/modules/locales.js b/web/modules/locales.js index 13fdcd6..d271e2e 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -863,10 +863,28 @@ export const i18n = { currentNodeNoSets: "这种节点还没有存过参数。在这里“存下当前参数”、在图库的参数页里存下节点,或配方里的参数方案,都会列在这里。", currentNodeNoMatch: '没有匹配的参数', currentNodeLoadFailed: '没能读出存过的参数', - currentNodeFromRecipe: '配方 · {name}', - currentNodeFromMaterial: "存下的参数", - currentNodeMoreChanges: '还有 {count} 项', - currentNodeKept: '保持不变:{names}(种子、模型和本机没有的选项不跟着换;模型在上面换)', + currentNodeFromSet: '工作流「{recipe}」的参数方案', + currentNodeSetPictures: '出过 {count} 张图', + currentNodeOpenSet: '打开', + currentNodeFromPicture: '收藏图片 {name} 时存的', + currentNodeSeePicture: '看原图', + currentNodeFromHere: '在当前节点里存的', + currentNodeKeepSeed: '种子不跟着换', + currentNodeKeepModel: '模型在上面换', + currentNodeKeepMissing: '本机没有这个选项', + currentNodeKeptLine: '保持不变:{names}', + currentNodeKeptPart: '{names}({why})', + currentNodeKeptSep: ';', + currentNodeShowAll: '显示全部 {count} 项', + currentNodeShowLess: '收起', + currentNodeCompare: '⇄ 对比', + currentNodeCompareHint: '和画布上的整个工作流对比,不只是这个节点', + currentNodeCompareFailed: '没能读出这组参数的工作流', + currentNodeViewCards: '卡片', + currentNodeViewTable: '并排', + currentNodeNow: '现在', + currentNodeApplyShort: '套用 {count} 项', + currentNodeAllSame: '这些参数方案和现在的值都一样。', currentNodeApply: '套用这 {count} 项', currentNodeSame: '和当前一样', doctorLead: '检查当前工作流用到的模型在本机找不找得到。工作流里记着每个模型的文件指纹,改过名、换过文件夹的模型也能认出来;只有指纹对得上的文件才会被换上。', @@ -3238,10 +3256,28 @@ export const i18n = { currentNodeNoSets: "No values saved for this kind of node yet. Values saved here, nodes saved from an image's parameters and recipe parameter sets show up here.", currentNodeNoMatch: 'Nothing matches', currentNodeLoadFailed: 'Could not read the saved values', - currentNodeFromRecipe: 'Recipe · {name}', - currentNodeFromMaterial: "Saved values", - currentNodeMoreChanges: '{count} more', - currentNodeKept: 'Kept: {names} (seeds, models and choices this computer lacks stay; change the model above)', + currentNodeFromSet: 'Parameter set of the workflow “{recipe}”', + currentNodeSetPictures: '{count} pictures made', + currentNodeOpenSet: 'Open', + currentNodeFromPicture: 'Kept with the picture {name}', + currentNodeSeePicture: 'See it', + currentNodeFromHere: 'Saved here in Current node', + currentNodeKeepSeed: 'seeds stay', + currentNodeKeepModel: 'change models above', + currentNodeKeepMissing: 'not on this computer', + currentNodeKeptLine: 'Staying as they are: {names}', + currentNodeKeptPart: '{names} ({why})', + currentNodeKeptSep: '; ', + currentNodeShowAll: 'Show all {count}', + currentNodeShowLess: 'Show less', + currentNodeCompare: '⇄ Compare', + currentNodeCompareHint: 'Compare the whole workflow with the canvas, not only this node', + currentNodeCompareFailed: "Could not read this set's workflow", + currentNodeViewCards: 'Cards', + currentNodeViewTable: 'Side by side', + currentNodeNow: 'Now', + currentNodeApplyShort: 'Use {count}', + currentNodeAllSame: 'All of these match the current values.', currentNodeApply: 'Apply these {count}', currentNodeSame: 'Same as now', 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.', diff --git a/web/modules/node_parameter_sets.js b/web/modules/node_parameter_sets.js index 0ad71cf..569a31c 100644 --- a/web/modules/node_parameter_sets.js +++ b/web/modules/node_parameter_sets.js @@ -2,9 +2,11 @@ * 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. + * one entry that names both sources, each saying where it came from (a recipe's set with its + * file and values' signature, a kept picture, or values saved in Current node). 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. And the recent pictures made with each set's values. No DOM. */ import { applyNodeMaterialValues, isModelFilePath, isVolatileWidget } from './node_material_actions.js'; @@ -18,8 +20,11 @@ async function fetchJson(url, signal) { } /** - * [{ name, role, timestamp, values, sources: [{ kind: 'material' | 'recipe', label }] }], - * newest first. + * [{ name, role, timestamp, values, sources }], newest first. A source is a material + * { kind: 'material', label, filename, timestamp, origin, image, preview } (origin: how it was + * saved, 'generated_image' or 'canvas_node'; image: the kept picture { filename, subfolder } or + * null; preview: its saved preview's URL or '') or a recipe set { kind: 'recipe', label (the + * recipe), recipeFile, setFile, setName, timestamp, signature }. */ export async function loadParameterSets(type, { signal } = {}) { const query = `type=${encodeURIComponent(type)}`; @@ -45,7 +50,11 @@ export async function loadParameterSets(type, { signal } = {}) { 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, filename: material.filename }], + sources: [{ + kind: 'material', label: material.name, filename: material.filename, timestamp: material.timestamp || 0, + origin: material.origin || '', image: material.source_image?.filename ? material.source_image : null, + preview: material.preview ? `/anomalous/material_asset?${new URLSearchParams({ filename: material.filename, asset: material.preview })}` : '', + }], }); } } @@ -60,7 +69,10 @@ export async function loadParameterSets(type, { signal } = {}) { name: nodes.length > 1 ? `${notebook.name} · ${node.title}` : notebook.name, role: node.role || '', timestamp: notebook.timestamp || 0, - sources: [{ kind: 'recipe', label: recipe }], + sources: [{ + kind: 'recipe', label: recipe, recipeFile: group.recipe_filename, setFile: notebook.filename || '', + setName: notebook.name || '', timestamp: notebook.timestamp || 0, signature: notebook.signature || '', + }], }); } } @@ -68,23 +80,53 @@ export async function loadParameterSets(type, { signal } = {}) { return [...entries.values()].sort((a, b) => b.timestamp - a.timestamp); } +/** + * Every value of `values` for `node`: [{ index, label, from, to, status }], status being + * 'change' (put in), 'same', or why the value stays: 'seed' | 'model' | 'missing'. Values of + * another type than the node's input there are left out. + */ +export function parameterRows(node, values) { + const rows = []; + values.forEach((to, index) => { + const widget = node.widgets?.[index]; + if (!widget || to === undefined || to === null) return; + if (widget.value != null && typeof widget.value !== typeof to) return; // not the same input + const label = widget.label || widget.name || `#${index}`; + if (same(widget.value, to)) { + rows.push({ index, label, from: widget.value, to, status: 'same' }); + return; + } + const choices = Array.isArray(widget.options?.values) ? widget.options.values : null; + const status = isVolatileWidget(node, widget, index) || widget.name === 'control_after_generate' ? 'seed' + : isModelFilePath(to) || isModelFilePath(widget.value) ? 'model' + : choices && !choices.includes(to) ? 'missing' : 'change'; + rows.push({ index, label, from: widget.value, to, status }); + }); + return rows; +} + /** * 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; + return parameterRows(node, values).filter(row => row.status !== 'same') + .map(({ index, label, from, to, status }) => ({ index, label, from, to, keep: status === 'change' ? '' : status })); +} + +/** + * The recent pictures made with the recipe sets' values among `entries` (one pass over the + * outputs): { signature: { count, newest: { filename, subfolder } } }; {} when none or unreadable. + */ +export async function loadSetPictures(entries, { signal } = {}) { + const signatures = [...new Set(entries.flatMap(entry => entry.sources.map(source => source.signature)).filter(Boolean))].slice(0, 64); + if (!signatures.length) return {}; + try { + const data = await fetchJson(`/anomalous/parameter_pictures?signatures=${signatures.join(',')}`, signal); + return data.pictures || {}; + } catch { + return {}; + } } /** Puts the changes that are made into the node; the undoable result of node_material_actions. */ diff --git a/web/modules/ui_node_assistant.js b/web/modules/ui_node_assistant.js index 1e8138e..762c7ff 100644 --- a/web/modules/ui_node_assistant.js +++ b/web/modules/ui_node_assistant.js @@ -471,5 +471,5 @@ for (const w of node.widgets) { for (const w of modelWidgets) this.renderAssistantModelCard(node, w, body); } renderNodePrompts(this, node, body); - renderNodeParameters(node, body); + renderNodeParameters(node, body, this); } diff --git a/web/modules/ui_node_parameters.js b/web/modules/ui_node_parameters.js index 9ba475c..e5c2a0d 100644 --- a/web/modules/ui_node_parameters.js +++ b/web/modules/ui_node_parameters.js @@ -1,9 +1,15 @@ /** * 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); saved values (not a recipe's) can be deleted. "Save these values" keeps the - * node's current values for the next node of its kind. + * (node_parameter_sets.js: materials and recipes, merged), as cards or side by side. A card + * shows the picture made with its values (or the one they were kept from) and says where they + * came from: a workflow's parameter set (Open shows it there), a kept picture (open it), or + * saved here, with the day. Then each value it would change as a picture (numbers as two bars, + * choices as old → new) and what stays and why (seeds, model files, choices this computer does + * not have); "Show all" lists the equal values too. ⇄ compares the whole workflow with the + * canvas; one press puts the changes in (the receipt undoes it); saved values (not a + * recipe's) can be deleted. Side by side: the current values and every set in columns, the + * ones a set would change marked. "Save these values" keeps the node's values for the next + * node of its kind. */ import { app } from "../../../scripts/app.js"; @@ -11,11 +17,19 @@ import { translate as t } from './locales.js'; import { anomalousConfirm, anomalousPrompt } from './ui_dialog.js'; import { showApplyReceipt } from './ui_apply_receipt.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; -import { applyParameterChanges, deleteSavedValues, loadParameterSets, parameterChanges, saveNodeParameters } from './node_parameter_sets.js'; +import { openParamCompare } from './ui_param_compare.js'; +import { applyParameterChanges, deleteSavedValues, loadParameterSets, loadSetPictures, parameterRows, saveNodeParameters } from './node_parameter_sets.js'; -const SHOWN_CHANGES = 4; const SEARCH_FROM = 6; // entries before a search box is worth showing +const VIEW_KEY = 'anomalous_node_params_view'; // 'cards' | 'table', this browser's choice const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative', both: 'recipePromptRoleBoth' }; +const KEEP_KEYS = { seed: 'currentNodeKeepSeed', model: 'currentNodeKeepModel', missing: 'currentNodeKeepMissing' }; +const ICONS = { + workflow: '', + picture: '', + saved: '', + lock: '', +}; function el(tag, className, text) { const node = document.createElement(tag); @@ -31,62 +45,297 @@ function button(className, label, onClick) { return node; } +function icon(name) { + const span = el('span', 'anomalous-node-param-icon'); + span.setAttribute('aria-hidden', 'true'); + span.innerHTML = `${ICONS[name]}`; // static markup only + return span; +} + const brief = (value) => { - const text = typeof value === 'string' ? value.replace(/\s+/g, ' ') : JSON.stringify(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')); +/** "10月3日" (with the year when it is not this one); '' without a time. */ +function dayOf(stamp) { + if (!stamp) return ''; + const date = new Date(stamp < 1e12 ? stamp * 1000 : stamp); + if (Number.isNaN(date.getTime())) return ''; + const sameYear = date.getFullYear() === new Date().getFullYear(); + return date.toLocaleDateString(undefined, sameYear ? { month: 'short', day: 'numeric' } : { year: 'numeric', month: 'short', day: 'numeric' }); +} -function entryCard(node, entry, receiptHost, redraw, remove) { - const changes = parameterChanges(node, entry.values); - const made = changes.filter(change => !change.keep); +const outputQuery = image => new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' }); + +/** The entry's picture: the newest made with its values, else the one it was kept from; null for none. */ +function pictureOf(entry, pictures) { + for (const source of entry.sources) { + const made = source.signature && pictures[source.signature]?.newest; + if (made) return { thumb: `/anomalous/output_thumbnail?${outputQuery(made)}`, full: `/view?${outputQuery(made)}&type=output`, title: made.filename }; + } + for (const source of entry.sources) { + if (source.preview) return { thumb: source.preview, full: source.image ? `/view?${outputQuery(source.image)}&type=output` : source.preview, title: source.image?.filename || source.label }; + if (source.image) return { thumb: `/anomalous/output_thumbnail?${outputQuery(source.image)}`, full: `/view?${outputQuery(source.image)}&type=output`, title: source.image.filename }; + } + return null; +} + +/** Shows a recipe's details on one of its parameter sets. */ +function openSet(owner, source) { + // Opening goes to the Workflows page at once (which forgets any state to return to), then reads the recipe. + const opening = owner.openRecipeByFilename(source.recipeFile); + owner.recipeReturnState = { activeTab: 'set', filename: source.setFile }; + return opening; +} + +/** Compares the whole workflow behind the entry with the canvas: a recipe set's, else the kept picture's. */ +async function compareWithCanvas(entry) { + const set = entry.sources.find(source => source.kind === 'recipe' && source.setFile); + if (!set) { + const kept = entry.sources.find(source => source.image); + openParamCompare({ image: { ...kept.image, type: 'output' } }, { canvas: true }); + return; + } + try { + const response = await fetch(`/anomalous/parameters?recipe_filename=${encodeURIComponent(set.recipeFile)}`, { cache: 'no-store' }); + const notebooks = response.ok ? (await response.json()).notebooks || [] : []; + const workflow = notebooks.find(notebook => notebook.filename === set.setFile)?.data?.workflow; + if (!workflow) throw new Error('no workflow'); + openParamCompare({ workflow, label: set.setName || entry.name }, { canvas: true }); + } catch (error) { + showWorkbenchToast(t('currentNodeCompareFailed')); + } +} + +/** One line on where the values came from: an icon, what, the day, and a way to open it. */ +function sourceLine(source, pictures, owner) { + const line = el('div', 'anomalous-node-param-from'); + let text; + let open = null; + if (source.kind === 'recipe') { + line.append(icon('workflow')); + text = t('currentNodeFromSet', { recipe: source.label }); + const count = pictures[source.signature]?.count; + if (count) text += ` · ${t('currentNodeSetPictures', { count })}`; + if (source.setFile && source.recipeFile) open = button('anomalous-node-param-link', t('currentNodeOpenSet'), () => openSet(owner, source)); + } else if (source.image) { + line.append(icon('picture')); + text = t('currentNodeFromPicture', { name: source.image.filename.replace(/\.[^.]+$/, '') }); + open = button('anomalous-node-param-link', t('currentNodeSeePicture'), () => owner.showGalleryViewer?.(`/view?${outputQuery(source.image)}&type=output`)); + } else { + line.append(icon('saved')); + text = source.origin === 'canvas_node' ? t('currentNodeFromHere') : source.label; + } + const day = dayOf(source.timestamp); + line.append(el('span', 'anomalous-node-param-from-text', day ? `${text} · ${day}` : text)); + if (open) line.append(open); + return line; +} + +/** A value the entry would change: numbers as two bars (now, then), other values as old → new. */ +function changeRow(row) { + const line = el('div', 'anomalous-node-param-row is-change'); + line.title = `${row.label}: ${typeof row.to === 'string' ? row.to : JSON.stringify(row.to)}`; + line.append(el('span', 'anomalous-node-param-label', row.label)); + const numbers = typeof row.from === 'number' && typeof row.to === 'number' && Number.isFinite(row.from) && Number.isFinite(row.to); + if (numbers) { + const largest = Math.max(Math.abs(row.from), Math.abs(row.to)) || 1; + const bars = el('span', 'anomalous-node-param-bars'); + for (const [value, kind] of [[row.from, 'is-now'], [row.to, 'is-then']]) { + const bar = el('span', `anomalous-node-param-bar ${kind}`); + bar.style.width = `${Math.max(3, (Math.abs(value) / largest) * 100)}%`; // the value's share of the larger one + bars.append(bar); + } + const values = el('span', 'anomalous-node-param-nums'); + values.append(el('span', 'is-now', brief(row.from)), el('span', '', ' → '), el('strong', '', brief(row.to)), + el('span', 'anomalous-node-param-trend', row.to > row.from ? '↑' : '↓')); + line.append(bars, values); + } else { + const values = el('span', 'anomalous-node-param-swap'); + values.append(el('span', 'anomalous-node-param-pill is-now', brief(row.from)), el('span', '', '→'), el('span', 'anomalous-node-param-pill is-then', brief(row.to))); + line.append(values); + } + return line; +} + +/** A value that stays (with why) or is the same already: dim, on one line. */ +function quietRow(row) { + const line = el('div', `anomalous-node-param-row is-${row.status === 'same' ? 'same' : 'kept'}`); + line.append(el('span', 'anomalous-node-param-label', row.label)); + const value = el('span', 'anomalous-node-param-quiet'); + if (row.status !== 'same') value.append(icon('lock')); + value.append(document.createTextNode(row.status === 'same' ? brief(row.to) : `${brief(row.to)} · ${t(KEEP_KEYS[row.status])}`)); + line.append(value); + line.title = `${row.label}: ${typeof row.to === 'string' ? row.to : JSON.stringify(row.to)}`; + return line; +} + +function applyChanges(node, made, receiptHost, redraw) { + try { + const result = applyParameterChanges(app, node, made); + showApplyReceipt(receiptHost, { undo() { result.undo(); redraw(); } }, node); + redraw(); + } catch (error) { + showWorkbenchToast(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); + } +} + +function entryCard(node, entry, ctx) { + const rows = parameterRows(node, entry.values); + const made = rows.filter(row => row.status === 'change'); + const kept = rows.filter(row => row.status !== 'change' && row.status !== 'same'); + const same = rows.filter(row => row.status === 'same'); + const key = JSON.stringify(entry.values); const card = el('div', 'anomalous-node-param'); + + const top = el('div', 'anomalous-node-param-top'); + const picture = pictureOf(entry, ctx.pictures); + if (picture) { + const thumb = button('anomalous-node-param-thumb', undefined, () => ctx.owner.showGalleryViewer?.(picture.full)); + thumb.title = picture.title; + const img = el('img'); + img.alt = ''; + img.loading = 'lazy'; + img.src = picture.thumb; + img.onerror = () => thumb.replaceChildren(icon('picture')); + thumb.append(img); + top.append(thumb); + } else { + top.append(el('span', 'anomalous-node-param-thumb is-empty')); + top.lastChild.append(icon(entry.sources.some(source => source.kind === 'recipe') ? 'workflow' : 'saved')); + } + const main = el('div', 'anomalous-node-param-main'); 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); + main.append(head); + const seen = new Set(); + for (const source of entry.sources) { + const id = `${source.kind}|${source.setFile || source.filename || source.label}`; + if (seen.has(id)) continue; + seen.add(id); + main.append(sourceLine(source, ctx.pictures, ctx.owner)); } - 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(', ') }))); + top.append(main); + card.append(top); + + const list = el('div', 'anomalous-node-param-rows'); + list.append(...made.map(changeRow)); + const open = ctx.expanded.has(key); + if (open) list.append(...kept.map(quietRow), ...same.map(quietRow)); + else if (kept.length) { + const line = el('div', 'anomalous-node-param-row is-kept'); + const text = el('span', 'anomalous-node-param-quiet'); + const parts = [...new Set(kept.map(row => row.status))].map(status => t('currentNodeKeptPart', { + names: kept.filter(row => row.status === status).map(row => row.label).join(t('modelSourceListSep')), why: t(KEEP_KEYS[status]), + })); + text.append(icon('lock'), document.createTextNode(t('currentNodeKeptLine', { names: parts.join(t('currentNodeKeptSep')) }))); + line.append(text); + list.append(line); + } + if (!made.length && !open) list.append(el('p', 'anomalous-node-muted', t('currentNodeSame'))); + card.append(list); - const apply = button('anomalous-node-param-apply', made.length ? t('currentNodeApply', { count: made.length }) : t('currentNodeSame'), () => { - try { - const result = applyParameterChanges(app, node, made); - showApplyReceipt(receiptHost, { undo() { result.undo(); redraw(); } }, node); - redraw(); - } catch (error) { - showWorkbenchToast(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); - } - }); - apply.disabled = !made.length; const actions = el('div', 'anomalous-node-param-actions'); + if (same.length || kept.length) { + actions.append(button('anomalous-node-param-more', open ? t('currentNodeShowLess') : t('currentNodeShowAll', { count: rows.length }), () => { + if (open) ctx.expanded.delete(key); + else ctx.expanded.add(key); + ctx.redraw(); + })); + } + actions.append(el('span', 'anomalous-node-param-gap')); + if (entry.sources.some(source => (source.kind === 'recipe' && source.setFile) || source.image)) { + const compare = button('anomalous-node-param-secondary', t('currentNodeCompare'), () => compareWithCanvas(entry)); + compare.title = t('currentNodeCompareHint'); + actions.append(compare); + } // Values saved here or kept from images can be deleted; a recipe's sets belong to the recipe. const saved = [...new Set(entry.sources.filter(source => source.kind === 'material' && source.filename).map(source => source.filename))]; - if (saved.length) actions.append(button('anomalous-node-param-delete', t('currentNodeDelete'), () => remove(entry, saved))); + if (saved.length) actions.append(button('anomalous-node-param-delete', t('currentNodeDelete'), () => ctx.remove(entry, saved))); + const apply = button('anomalous-node-param-apply', made.length ? t('currentNodeApply', { count: made.length }) : t('currentNodeSame'), + () => applyChanges(node, made, ctx.section, ctx.redraw)); + apply.disabled = !made.length; actions.append(apply); card.append(actions); return card; } +/** + * Side by side: a column for now and for each set, a row per value any set would change (first) + * or keeps as it is (seeds and the like, after). + */ +function entryTable(node, entries, ctx) { + const sets = entries.map(entry => ({ entry, rows: new Map(parameterRows(node, entry.values).map(row => [row.index, row])) })); + const changed = index => sets.some(set => set.rows.get(index)?.status === 'change'); + const indexes = [...new Set(sets.flatMap(set => [...set.rows.values()].filter(row => row.status !== 'same').map(row => row.index)))] + .sort((a, b) => (changed(b) - changed(a)) || a - b); + const wrap = el('div', 'anomalous-node-param-table-wrap'); + const table = el('table', 'anomalous-node-param-table'); + const head = el('tr'); + head.append(el('th', ''), el('th', 'is-now', t('currentNodeNow'))); + for (const { entry } of sets) { + const cell = el('th'); + const picture = pictureOf(entry, ctx.pictures); + const thumb = picture ? button('anomalous-node-param-table-thumb', undefined, () => ctx.owner.showGalleryViewer?.(picture.full)) + : el('span', 'anomalous-node-param-table-thumb is-empty'); + if (picture) { + const img = el('img'); + img.alt = ''; + img.loading = 'lazy'; + img.src = picture.thumb; + thumb.append(img); + } + cell.append(thumb, el('span', 'anomalous-node-param-table-name', entry.name)); + cell.title = entry.name; + head.append(cell); + } + table.append(head); + for (const index of indexes) { + const widget = node.widgets?.[index]; + const tr = el('tr'); + tr.append(el('td', 'anomalous-node-param-label', widget?.label || widget?.name || `#${index}`), el('td', 'is-now', brief(widget?.value))); + for (const { rows } of sets) { + const row = rows.get(index); + const cell = el('td', row ? `is-${row.status}` : 'is-none', row ? brief(row.to) : '—'); + if (row && row.status !== 'change' && row.status !== 'same') cell.title = t(KEEP_KEYS[row.status]); + tr.append(cell); + } + table.append(tr); + } + const foot = el('tr', 'anomalous-node-param-table-foot'); + foot.append(el('td'), el('td')); + for (const { entry, rows } of sets) { + const made = [...rows.values()].filter(row => row.status === 'change'); + const cell = el('td'); + const apply = button('anomalous-node-param-apply', made.length ? t('currentNodeApplyShort', { count: made.length }) : t('currentNodeSame'), + () => applyChanges(node, made, ctx.section, ctx.redraw)); + apply.disabled = !made.length; + apply.title = entry.name; + cell.append(apply); + foot.append(cell); + } + table.append(foot); + wrap.append(table); + if (!indexes.length) wrap.append(el('p', 'anomalous-node-muted', t('currentNodeAllSame'))); + return wrap; +} + +function readView() { + try { return localStorage.getItem(VIEW_KEY) === 'table' ? 'table' : 'cards'; } catch { return 'cards'; } +} + +function keepView(view) { + try { localStorage.setItem(VIEW_KEY, view); } catch { /* the choice is only a convenience */ } +} + /** Appends the parameters section for `node` to `container` and loads its entries. */ -export function renderNodeParameters(node, container) { +export function renderNodeParameters(node, container, owner) { const section = el('section', 'anomalous-node-section'); const head = el('div', 'anomalous-node-section-head'); + const views = el('div', 'anomalous-node-param-views'); + views.setAttribute('role', 'radiogroup'); const search = el('input', 'anomalous-node-param-search'); search.type = 'search'; search.placeholder = t('currentNodeSearch'); @@ -97,14 +346,30 @@ export function renderNodeParameters(node, container) { container.append(section); let entries = []; + let view = readView(); + const ctx = { owner, section, pictures: {}, expanded: new Set(), redraw: () => draw(), remove: (entry, files) => remove(entry, files) }; + const paintViews = () => { + views.hidden = entries.length < 2; + views.replaceChildren(...[['cards', 'currentNodeViewCards'], ['table', 'currentNodeViewTable']].map(([name, label]) => { + const choice = button(`anomalous-node-param-view${view === name ? ' is-on' : ''}`, t(label), () => { + view = name; + keepView(name); + draw(); + }); + choice.setAttribute('role', 'radio'); + choice.setAttribute('aria-checked', String(view === name)); + return choice; + })); + }; 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)); + || `${entry.name} ${entry.sources.map(source => `${source.label} ${source.image?.filename || ''}`).join(' ')}`.toLowerCase().includes(query)); search.hidden = entries.length < SEARCH_FROM; - list.replaceChildren(...(shown.length - ? shown.map(entry => entryCard(node, entry, section, draw, remove)) - : [el('p', 'anomalous-node-muted', t(entries.length ? 'currentNodeNoMatch' : 'currentNodeNoSets'))])); + paintViews(); + if (!shown.length) list.replaceChildren(el('p', 'anomalous-node-muted', t(entries.length ? 'currentNodeNoMatch' : 'currentNodeNoSets'))); + else if (view === 'table' && entries.length >= 2) list.replaceChildren(entryTable(node, shown, ctx)); + else list.replaceChildren(...shown.map(entry => entryCard(node, entry, ctx))); }; search.oninput = draw; @@ -115,6 +380,11 @@ export function renderNodeParameters(node, container) { if (run !== section._load || !section.isConnected) return; entries = loaded; draw(); + // The pictures made with the sets' values come after: one pass over the recent outputs. + const pictures = await loadSetPictures(loaded); + if (run !== section._load || !section.isConnected || !Object.keys(pictures).length) return; + ctx.pictures = pictures; + draw(); } catch (error) { if (run === section._load) list.replaceChildren(el('p', 'anomalous-node-muted is-error', t('currentNodeLoadFailed'))); } @@ -141,6 +411,9 @@ export function renderNodeParameters(node, container) { showWorkbenchToast(t('currentNodeSaveFailed')); } }; - head.append(el('h3', '', t('currentNodeParams')), button('anomalous-node-section-btn', t('currentNodeSave'), save)); + const tools = el('div', 'anomalous-node-param-tools'); + tools.append(views, button('anomalous-node-section-btn', t('currentNodeSave'), save)); + head.append(el('h3', '', t('currentNodeParams')), tools); + views.hidden = true; load(); } diff --git a/web/styles.css b/web/styles.css index 7480981..611366f 100644 --- a/web/styles.css +++ b/web/styles.css @@ -18,7 +18,7 @@ @import url("./styles/17-model-types.css?v=20261007-theme-2"); @import url("./styles/18-scan-page.css?v=20261011-band-1"); @import url("./styles/19-model-doctor.css?v=20261007-theme-2"); -@import url("./styles/20-current-node.css?v=20261007-theme-2"); +@import url("./styles/20-current-node.css?v=20261011-sets"); @import url("./styles/21-prompt-studio.css?v=20261007-theme-2"); @import url("./styles/22-model-download.css?v=20261007-theme-2"); @import url("./styles/23-combo-structure.css?v=20261007-theme-2"); diff --git a/web/styles/20-current-node.css b/web/styles/20-current-node.css index 684f155..0aa7f9f 100644 --- a/web/styles/20-current-node.css +++ b/web/styles/20-current-node.css @@ -1,4 +1,5 @@ -/* The current-node panel (Node Assistant): one scrolling body of sections; prompts; the parameters list. */ +/* The current-node panel (Node Assistant): one scrolling body of sections; prompts; the parameters + (saved sets as cards with their picture and source, or side by side). */ .anomalous-node-body { display: flex; @@ -56,6 +57,40 @@ font-size: 12px; } +.anomalous-node-param-tools { + display: flex; + align-items: center; + gap: 8px; +} + +/* Cards or side by side. */ +.anomalous-node-param-views { + display: inline-flex; + padding: 2px; + border: 1px solid var(--amb-border); + border-radius: 999px; +} + +.anomalous-node-param-views[hidden] { display: none; } + +.anomalous-node-param-view { + padding: 3px 10px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--amb-text-muted); + font-size: 11px; + cursor: pointer; +} + +.anomalous-node-param-view:hover { color: var(--amb-text-main); } + +.anomalous-node-param-view.is-on { + background: color-mix(in srgb, var(--amb-link) 18%, transparent); + color: var(--amb-text-main); + font-weight: 600; +} + .anomalous-node-param-list { display: flex; flex-direction: column; @@ -65,13 +100,57 @@ .anomalous-node-param { display: flex; flex-direction: column; - gap: 6px; - padding: 10px 12px; + gap: 10px; + padding: 12px; border: 1px solid var(--amb-border); border-radius: 10px; background: rgba(var(--amb-ink-rgb), 0.035); } +/* The picture made with the values, and where they came from. */ +.anomalous-node-param-top { + display: flex; + gap: 12px; + min-width: 0; +} + +.anomalous-node-param-thumb { + display: grid; + flex-shrink: 0; + place-items: center; + width: 52px; + height: 68px; + padding: 0; + border: 1px solid var(--amb-border); + border-radius: 8px; + background: rgba(var(--amb-ink-rgb), 0.05); + color: var(--amb-text-dim); + overflow: hidden; + cursor: zoom-in; +} + +.anomalous-node-param-thumb.is-empty { cursor: default; } + +.anomalous-node-param-thumb > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.anomalous-node-param-thumb > .anomalous-node-param-icon { + width: 20px; + height: 20px; +} + +.anomalous-node-param-main { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; +} + .anomalous-node-param-head { display: flex; align-items: center; @@ -82,7 +161,7 @@ .anomalous-node-param-name { overflow: hidden; color: var(--amb-text-main); - font-size: 12.5px; + font-size: 13px; white-space: nowrap; text-overflow: ellipsis; } @@ -99,39 +178,156 @@ .anomalous-node-param-role.is-positive { color: var(--amb-ok-text); } .anomalous-node-param-role.is-negative { color: var(--amb-danger-text); } -.anomalous-node-param-sources { +.anomalous-node-param-icon { + display: inline-flex; + flex-shrink: 0; + width: 13px; + height: 13px; +} + +.anomalous-node-param-icon > svg { + width: 100%; + height: 100%; +} + +.anomalous-node-param-from { display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.anomalous-node-param-sources span { - padding: 1px 7px; - border: 1px solid var(--amb-border); - border-radius: 999px; + align-items: baseline; + gap: 6px; + min-width: 0; color: var(--amb-text-muted); - font-size: 10px; + font-size: 11.5px; + line-height: 1.45; } -.anomalous-node-param-changes { +.anomalous-node-param-from > .anomalous-node-param-icon { align-self: center; } +.anomalous-node-param-from-text { min-width: 0; overflow-wrap: anywhere; } + +.anomalous-node-param-link { + flex-shrink: 0; + padding: 0; + border: none; + background: none; + color: var(--amb-link); + font: inherit; + cursor: pointer; +} + +.anomalous-node-param-link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +/* The values: the name, then a picture of the change (two bars for numbers, old → new otherwise). */ +.anomalous-node-param-rows { display: flex; flex-direction: column; - gap: 2px; - margin: 0; - padding: 0; - list-style: none; + gap: 6px; +} + +.anomalous-node-param-row { + display: grid; + grid-template-columns: minmax(56px, 112px) minmax(48px, 200px) auto; + justify-content: start; + align-items: center; + gap: 10px; + min-width: 0; color: var(--amb-text-soft); font-size: 11.5px; } -.anomalous-node-param-changes li { +.anomalous-node-param-row.is-kept, +.anomalous-node-param-row.is-same { + grid-template-columns: minmax(56px, 112px) minmax(0, 1fr); + justify-content: stretch; + color: var(--amb-text-dim); +} + +.anomalous-node-param-label { + overflow: hidden; + color: var(--amb-text-muted); + white-space: nowrap; + text-overflow: ellipsis; +} + +.anomalous-node-param-bars { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 40px; +} + +.anomalous-node-param-bar { + height: 5px; + border-radius: 3px; +} + +.anomalous-node-param-bar.is-now { background: rgba(var(--amb-ink-rgb), 0.22); } +.anomalous-node-param-bar.is-then { background: var(--amb-link); } + +.anomalous-node-param-nums { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.anomalous-node-param-nums > .is-now { color: var(--amb-text-dim); } + +.anomalous-node-param-nums > strong { + color: var(--amb-text-main); + font-weight: 600; +} + +.anomalous-node-param-trend { + margin-left: 3px; + color: var(--amb-link); +} + +.anomalous-node-param-swap { + display: flex; + flex-wrap: wrap; + grid-column: 2 / -1; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--amb-text-dim); +} + +.anomalous-node-param-pill { + max-width: 100%; + padding: 1px 8px; + border-radius: 999px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.anomalous-node-param-changes li.is-more, -.anomalous-node-param-kept, +.anomalous-node-param-pill.is-now { + background: rgba(var(--amb-ink-rgb), 0.06); + color: var(--amb-text-dim); + text-decoration: line-through; +} + +.anomalous-node-param-pill.is-then { + background: color-mix(in srgb, var(--amb-link) 16%, transparent); + color: var(--amb-text-main); +} + +.anomalous-node-param-quiet { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* The one line on what stays, across the whole row. */ +.anomalous-node-param-quiet:only-child { + grid-column: 1 / -1; + white-space: normal; +} + .anomalous-node-muted { margin: 0; color: #8b91a3; @@ -141,12 +337,28 @@ .anomalous-node-muted.is-error { color: var(--amb-danger-text); } +/* Show all, compare, delete, apply. */ .anomalous-node-param-actions { display: flex; - justify-content: flex-end; + flex-wrap: wrap; + align-items: center; gap: 6px; } +.anomalous-node-param-gap { flex: 1; } + +.anomalous-node-param-more { + padding: 0; + border: none; + background: none; + color: var(--amb-text-muted); + font-size: 11.5px; + cursor: pointer; +} + +.anomalous-node-param-more:hover { color: var(--amb-text-main); } + +.anomalous-node-param-secondary, .anomalous-node-param-delete { padding: 5px 10px; border: 1px solid var(--amb-border); @@ -157,6 +369,11 @@ cursor: pointer; } +.anomalous-node-param-secondary:hover { + border-color: color-mix(in srgb, var(--amb-link) 45%, transparent); + color: var(--amb-text-main); +} + .anomalous-node-param-delete:hover { border-color: rgba(239, 68, 68, 0.5); color: var(--amb-danger-text); @@ -170,6 +387,7 @@ color: var(--amb-text-main); font-size: 11.5px; font-weight: 600; + white-space: nowrap; cursor: pointer; } @@ -180,6 +398,83 @@ cursor: default; } +/* Side by side: a column for now and for each set, the values a set would put in marked. */ +.anomalous-node-param-table-wrap { + border: 1px solid var(--amb-border); + border-radius: 10px; + overflow-x: auto; +} + +.anomalous-node-param-table { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; +} + +.anomalous-node-param-table th, +.anomalous-node-param-table td { + max-width: 150px; + padding: 6px 10px; + border-bottom: 1px solid var(--amb-border); + overflow: hidden; + color: var(--amb-text-soft); + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; +} + +.anomalous-node-param-table th { + vertical-align: bottom; + font-weight: 600; +} + +.anomalous-node-param-table .is-now { color: var(--amb-text-muted); } + +.anomalous-node-param-table td.is-change { + background: color-mix(in srgb, var(--amb-link) 13%, transparent); + color: var(--amb-text-main); +} + +.anomalous-node-param-table td.is-same, +.anomalous-node-param-table td.is-none { color: var(--amb-text-dim); } + +.anomalous-node-param-table td.is-seed, +.anomalous-node-param-table td.is-model, +.anomalous-node-param-table td.is-missing { + color: var(--amb-text-dim); + font-style: italic; +} + +.anomalous-node-param-table-thumb { + display: block; + width: 44px; + height: 56px; + margin-bottom: 5px; + padding: 0; + border: none; + border-radius: 6px; + background: rgba(var(--amb-ink-rgb), 0.05); + overflow: hidden; +} + +button.anomalous-node-param-table-thumb { cursor: zoom-in; } + +.anomalous-node-param-table-thumb > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.anomalous-node-param-table-name { + display: block; + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; +} + +.anomalous-node-param-table-foot td { border-bottom: none; } + /* A prompt box of the node, with its translation. */ .anomalous-node-prompt { display: flex;