From 2d0eebcf777fbc6d8b9b8a0c7ed23c377a9bb255 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Sun, 2 Aug 2026 17:47:22 +0800 Subject: [PATCH] feat: edit workflow recipes with history --- ARCHITECTURE.md | 2 +- api/__init__.py | 3 + api/recipes.py | 160 ++++++++++++++++++- web/modules/locales.js | 30 ++++ web/modules/recipe_parser.js | 95 ++++++++++- web/modules/ui_recipes.js | 296 +++++++++++++++++++++++++++++++++-- web/styles.css | 58 +++++++ 7 files changed, 629 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a684e50..2a1214d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,7 +97,7 @@ Instead of fragmenting the class scope and losing context, we extracted all UI p * **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked. * **Gallery Viewer (`ui_gallery.js`)**: A fullscreen modal for viewing images. * **Notebook (`ui_notebooks.js`)**: A specialized editor for composing prompts and drag-dropping Lora/Model nodes. -* **Workflow Recipes (`ui_recipes.js`)**: A second section inside the existing Notebook modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two read-only summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). The save dialog may pin any safe JSON-serializable widget as a prominent key parameter and bind no image, the current canvas, or a recent generated output. Sensitive-looking widgets and complex values are excluded from card metadata but remain in the authoritative workflow JSON. `recipe_parser.js` never mutates the canvas; an unfamiliar custom node may have a less detailed card but must never block saving. Restoring requires explicit confirmation because it replaces the current graph. +* **Workflow Recipes (`ui_recipes.js`)**: A second section inside the existing Notebook modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). The save dialog may pin any safe JSON-serializable widget as a prominent key parameter and bind no image, the current canvas, or a recent generated output. The edit dialog can update recipe metadata, pins, and safe primitive widget values without loading a graph; it applies changes only to the corresponding serialized widget slots and leaves complex/sensitive values opaque. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions) under the user recipe directory, never in the extension repository. `recipe_parser.js` never mutates the live canvas; an unfamiliar custom node may have a less detailed card but must never block saving. Restoring a graph or historical version requires explicit confirmation because it replaces user state. * **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality. ### Graph Splicing and Picker Context (`graph_splice.js`, `model_picker.js`) diff --git a/api/__init__.py b/api/__init__.py index aa3e601..6f7244d 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -28,7 +28,10 @@ def setup_routes(app): app.router.add_get('/anomalous/recipes', api_get_recipes) app.router.add_get('/anomalous/recipe_full', api_get_recipe_full) app.router.add_post('/anomalous/save_recipe', api_save_recipe) + app.router.add_post('/anomalous/update_recipe', api_update_recipe) app.router.add_post('/anomalous/delete_recipe', api_delete_recipe) + app.router.add_get('/anomalous/recipe_history', api_get_recipe_history) + app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version) app.router.add_post('/anomalous/translate', api_translate) app.router.add_get('/anomalous/base_models', api_base_models) diff --git a/api/recipes.py b/api/recipes.py index 45c5b26..94facb6 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -7,6 +7,7 @@ extension repository, so personal prompts and graphs are never Git content. import asyncio import json import os +import shutil import tempfile import time import uuid @@ -24,6 +25,7 @@ MAX_NOTES_LENGTH = 3000 MAX_THUMBNAIL_LENGTH = 1_500_000 MAX_SOURCE_SUBFOLDER_LENGTH = 500 MAX_RECIPE_BYTES = 12 * 1024 * 1024 +MAX_HISTORY_VERSIONS = 20 SAFE_THUMBNAIL_PREFIXES = ( "data:image/jpeg;base64,", "data:image/png;base64,", @@ -164,6 +166,78 @@ def _write_recipe(recipes_dir, filename, recipe): pass +def _history_dir(recipes_dir, filename, create=False): + """Return the contained, user-data-only history directory for one recipe.""" + stem = os.path.splitext(require_filename(filename))[0] + history_dir = resolve_within(recipes_dir, ".history", stem) + if create: + os.makedirs(history_dir, exist_ok=True) + return history_dir + + +def _archive_recipe(recipes_dir, filename, recipe): + """Atomically retain a bounded pre-update snapshot before replacing a recipe.""" + history_dir = _history_dir(recipes_dir, filename, create=True) + version_name = f"version_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.json" + _write_recipe(history_dir, version_name, recipe) + + entries = [] + with os.scandir(history_dir) as scan: + for entry in scan: + if entry.is_file() and entry.name.endswith(".json"): + entries.append(entry) + entries.sort(key=lambda entry: entry.stat().st_mtime_ns, reverse=True) + for entry in entries[MAX_HISTORY_VERSIONS:]: + try: + os.remove(resolve_within(history_dir, entry.name)) + except OSError: + pass + + +def _list_recipe_history(recipes_dir, filename): + history_dir = _history_dir(recipes_dir, filename) + versions = [] + try: + with os.scandir(history_dir) as entries: + for entry in entries: + if not entry.is_file() or not entry.name.endswith(".json"): + continue + try: + version = require_filename(entry.name) + data = _read_recipe(resolve_within(history_dir, version)) + if not isinstance(data, dict): + continue + versions.append({ + "version": version, + "timestamp": data.get("timestamp", 0), + "name": data.get("name", ""), + }) + except (OSError, ValueError, json.JSONDecodeError): + continue + except OSError: + return [] + versions.sort(key=lambda item: item["timestamp"], reverse=True) + return versions + + +def _updated_recipe(payload, existing): + recipe = _normalise_recipe(payload) + created_timestamp = existing.get("created_timestamp", existing.get("timestamp")) + if isinstance(created_timestamp, int): + recipe["created_timestamp"] = created_timestamp + recipe["updated_timestamp"] = recipe["timestamp"] + recipe["schema_version"] = 2 + return recipe + + +def _delete_recipe_with_history(recipes_dir, filename): + """Delete an explicitly selected recipe and its contained local history.""" + os.remove(resolve_within(recipes_dir, filename)) + history_dir = _history_dir(recipes_dir, filename) + if os.path.isdir(history_dir): + shutil.rmtree(history_dir) + + async def api_get_recipes(request): recipes_dir = get_recipes_dir() recipes = await asyncio.to_thread(_list_recipes, recipes_dir) @@ -178,6 +252,9 @@ async def api_save_recipe(request): except Exception: return web.json_response({"status": "error", "message": "Invalid request"}, status=400) + recipe["created_timestamp"] = recipe["timestamp"] + recipe["updated_timestamp"] = recipe["timestamp"] + recipe["schema_version"] = 2 filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json" try: recipes_dir = get_recipes_dir() @@ -201,7 +278,7 @@ async def api_delete_recipe(request): return web.json_response({"status": "error", "message": "Invalid request"}, status=400) try: - await asyncio.to_thread(os.remove, path) + await asyncio.to_thread(_delete_recipe_with_history, recipes_dir, filename) except FileNotFoundError: return web.json_response({"status": "error", "message": "File not found"}, status=404) except OSError: @@ -226,3 +303,84 @@ async def api_get_recipe_full(request): except OSError: return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500) return web.json_response({"status": "success", "data": data}) + + +async def api_update_recipe(request): + """Replace one recipe while preserving its prior state in local history.""" + try: + payload = await request.json() + filename = require_filename(payload.get("filename", "")) + if not filename.endswith(".json"): + raise ValueError("Invalid filename") + recipes_dir = get_recipes_dir() + path = resolve_within(recipes_dir, filename) + existing = await asyncio.to_thread(_read_recipe, path) + if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict): + raise ValueError("Invalid recipe") + recipe = _updated_recipe(payload, existing) + except (AttributeError, ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "File not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500) + except Exception: + return web.json_response({"status": "error", "message": "Invalid request"}, status=400) + + try: + await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing) + await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe) + except OSError: + return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500) + return web.json_response({"status": "success", "filename": filename}) + + +async def api_get_recipe_history(request): + try: + filename = require_filename(request.query.get("filename", "")) + if not filename.endswith(".json"): + raise ValueError("Invalid filename") + recipes_dir = get_recipes_dir() + # Verify the root recipe exists before exposing its history directory. + await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename)) + versions = await asyncio.to_thread(_list_recipe_history, recipes_dir, filename) + except (ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "File not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not read recipe history"}, status=500) + return web.json_response({"status": "success", "versions": versions}) + + +async def api_restore_recipe_version(request): + try: + payload = await request.json() + filename = require_filename(payload.get("filename", "")) + version = require_filename(payload.get("version", "")) + if not filename.endswith(".json") or not version.endswith(".json"): + raise ValueError("Invalid filename") + recipes_dir = get_recipes_dir() + existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename)) + historical = await asyncio.to_thread( + _read_recipe, + resolve_within(_history_dir(recipes_dir, filename), version), + ) + if not isinstance(existing, dict) or not isinstance(historical, dict): + raise ValueError("Invalid recipe") + recipe = _updated_recipe(historical, existing) + except (AttributeError, ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "File not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500) + except Exception: + return web.json_response({"status": "error", "message": "Invalid request"}, status=400) + + try: + await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing) + await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe) + except OSError: + return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500) + return web.json_response({"status": "success", "filename": filename}) diff --git a/web/modules/locales.js b/web/modules/locales.js index 0c502c4..29bda0e 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -124,6 +124,21 @@ export const i18n = { recipeRecentImagesUnavailable: '暂时无法读取最近生成结果。', recipeChoosePinnedParams: '选择关键参数', recipePinnedParams: '关键参数', + recipeKeepImage: '保留当前封面', + recipeEdit: '编辑', + recipeEditTitle: '编辑工作流配方', + recipeEditNodeParams: '编辑节点参数', + recipeComplexParam: '复杂值仅展示;完整值仍安全保存在工作流中。', + recipeHistory: '版本历史', + recipeHistoryEmpty: '还没有历史版本。更新配方后会自动保留旧版本。', + recipeUnknownVersion: '未知时间', + recipeRestoreVersion: '恢复此版本', + recipeRestoreVersionConfirm: '恢复此版本会覆盖当前配方,但当前内容会先自动存入历史。继续吗?', + recipeEditCanvas: '载入画布编辑', + recipeEditCanvasConfirm: '载入会替换当前画布。完成结构编辑后,点击“更新当前配方”即可保存为新版本。继续吗?', + recipeUpdate: '保存更新', + recipeUpdateCurrent: '更新当前配方', + recipeUpdateError: '更新工作流配方失败。', }, en: { @@ -251,5 +266,20 @@ export const i18n = { recipeRecentImagesUnavailable: 'Recent outputs are currently unavailable.', recipeChoosePinnedParams: 'Choose key parameters', recipePinnedParams: 'Key parameters', + recipeKeepImage: 'Keep current cover', + recipeEdit: 'Edit', + recipeEditTitle: 'Edit Workflow Recipe', + recipeEditNodeParams: 'Edit node parameters', + recipeComplexParam: 'Complex values are shown only; the full value remains safely stored in the workflow.', + recipeHistory: 'Version history', + recipeHistoryEmpty: 'No prior version yet. The previous version is retained automatically on update.', + recipeUnknownVersion: 'Unknown time', + recipeRestoreVersion: 'Restore this version', + recipeRestoreVersionConfirm: 'Restore this version? The current recipe will first be retained in history.', + recipeEditCanvas: 'Edit on canvas', + recipeEditCanvasConfirm: 'Loading replaces the current canvas. After structural edits, use Update Current Recipe to save a new version. Continue?', + recipeUpdate: 'Save updates', + recipeUpdateCurrent: 'Update Current Recipe', + recipeUpdateError: 'Could not update this Workflow Recipe.', } }; diff --git a/web/modules/recipe_parser.js b/web/modules/recipe_parser.js index fd3833f..c582567 100644 --- a/web/modules/recipe_parser.js +++ b/web/modules/recipe_parser.js @@ -78,7 +78,7 @@ function extractGenericNodeSummary(node) { if (SENSITIVE_WIDGET_NAME.test(name) || normaliseName(widget?.type) === 'button') continue; const value = summariseWidgetValue(widget?.value); if (value === null || value === '') continue; - widgets.push({ name, value }); + widgets.push({ name, value, index }); if (widgets.length >= MAX_WIDGETS_PER_NODE) break; } @@ -118,6 +118,99 @@ export function extractRecipeParameterChoices(graph) { return choices; } +/** + * Return editable/pinnable choices from an already saved recipe summary. + * New summaries retain their original widget index; older recipes gracefully + * fall back to the visible widget order. + */ +export function extractRecipeParameterChoicesFromMetadata(params) { + const choices = []; + for (const node of params?.nodes || []) { + for (const [visibleIndex, widget] of (node?.widgets || []).entries()) { + const widgetName = textValue(widget?.name) || `widget_${visibleIndex + 1}`; + if (SENSITIVE_WIDGET_NAME.test(widgetName)) continue; + const value = clonePinnableValue(widget?.value); + if (value === null || value === '') continue; + const widgetIndex = Number.isInteger(widget?.index) ? widget.index : null; + choices.push({ + key: `${node?.id ?? 'unknown'}:${widgetIndex ?? visibleIndex}:${widgetName}`, + nodeId: node?.id ?? null, + nodeType: node?.type || 'Unknown', + nodeTitle: node?.title || null, + widgetName, + widgetIndex, + value, + }); + if (choices.length >= MAX_PARAMETER_CHOICES) return choices; + } + } + return choices; +} + +function valuesMatch(left, right) { + if (left === right) return true; + try { return JSON.stringify(left) === JSON.stringify(right); } catch (error) { return false; } +} + +function findSummaryWidget(params, change) { + const node = (params?.nodes || []).find((item) => item?.id === change.nodeId); + if (!node) return null; + const widget = (node.widgets || []).find((item) => Number.isInteger(change.widgetIndex) && item.index === change.widgetIndex) + || (node.widgets || []).find((item) => item.name === change.widgetName && valuesMatch(item.value, change.previousValue)); + return widget ? { node, widget } : null; +} + +function syncCommonRecipeMetadata(params, change) { + const type = normaliseName(change.nodeType); + const widget = normaliseName(change.widgetName); + const value = change.value; + if (/^(checkpointloader(simple)?|unetloader)$/.test(type) && /^(ckpt_name|checkpoint|unet_name|unet|model_name)$/.test(widget)) { + params.baseModel = textValue(value) || null; + if (params.baseModel) params.baseModels = [params.baseModel, ...(params.baseModels || []).filter((item) => item !== params.baseModel)]; + } + if (/lora.*loader|loader.*lora/.test(type)) { + const loraIndex = (params.nodes || []).filter((item) => /lora.*loader|loader.*lora/i.test(item?.type || '')).findIndex((item) => item.id === change.nodeId); + const lora = params.loras?.[loraIndex]; + if (lora) { + if (/^(lora_name|lora|model_name)$/.test(widget)) lora.name = textValue(value); + if (/^(strength_model|model_strength)$/.test(widget)) lora.strength_model = numberValue(value); + if (/^(strength_clip|clip_strength)$/.test(widget)) lora.strength_clip = numberValue(value); + } + } + if (/^ksampler(advanced)?$|samplercustom/.test(type)) { + const field = { seed: 'seed', noise_seed: 'seed', steps: 'steps', cfg: 'cfg', cfg_scale: 'cfg', sampler_name: 'sampler_name', sampler: 'sampler_name', scheduler: 'scheduler', denoise: 'denoise' }[widget]; + if (field) params[field] = ['steps', 'cfg', 'denoise'].includes(field) ? numberValue(value) : value; + } + if (/cliptextencode/.test(type) && /^(text|prompt)$/.test(widget)) { + const prompts = Array.isArray(params.promptPositive) ? params.promptPositive : []; + const position = prompts.findIndex((item) => item === change.previousValue); + if (position >= 0) prompts[position] = textValue(value); + } +} + +/** + * Apply direct safe-widget edits to a serialized recipe without instantiating + * its graph. Unknown nodes stay opaque; only known widget slots are touched. + */ +export function applyRecipeWidgetChanges(params, workflow, changes) { + for (const change of changes || []) { + const found = findSummaryWidget(params, change); + if (!found) continue; + const { widget } = found; + const workflowNode = (workflow?.nodes || []).find((node) => node?.id === change.nodeId); + let widgetIndex = Number.isInteger(change.widgetIndex) ? change.widgetIndex : widget.index; + if (!Number.isInteger(widgetIndex) && Array.isArray(workflowNode?.widgets_values)) { + widgetIndex = workflowNode.widgets_values.findIndex((value) => valuesMatch(value, change.previousValue)); + } + if (!Number.isInteger(widgetIndex) || widgetIndex < 0 || !Array.isArray(workflowNode?.widgets_values)) continue; + widget.value = change.value; + widget.index = widgetIndex; + workflowNode.widgets_values[widgetIndex] = change.value; + syncCommonRecipeMetadata(params, change); + } + return { params, workflow }; +} + function buildNodeIndex(graph) { return new Map((graph?._nodes || []).filter(Boolean).map((node) => [node.id, node])); } diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index 2dcdae5..c327e77 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -4,8 +4,10 @@ import { app } from '../../../scripts/app.js'; import { i18n } from './locales.js'; import { captureCanvasThumbnail, + applyRecipeWidgetChanges, extractRecipeMetadata, extractRecipeParameterChoices, + extractRecipeParameterChoicesFromMetadata, } from './recipe_parser.js'; const t = (key) => { @@ -196,11 +198,11 @@ function renderParams(parent, params = {}) { parent.appendChild(summary); } -function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { +function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices, initial = null) { return new Promise((resolve) => { const selection = { - thumbnail: safeThumbnail(canvasThumbnail), - sourceImage: null, + thumbnail: safeThumbnail(initial?.thumbnail) || safeThumbnail(canvasThumbnail), + sourceImage: initial?.source_image || null, pinnedKeys: new Set(), }; const overlay = document.createElement('div'); @@ -217,7 +219,7 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { nameInput.className = 'anomalous-nb-select'; nameInput.type = 'text'; nameInput.maxLength = 120; - nameInput.value = t('recipeDefaultName'); + nameInput.value = initial?.name || t('recipeDefaultName'); nameLabel.appendChild(nameInput); const tagsLabel = appendText(dialog, 'label', t('recipeTags')); @@ -226,6 +228,7 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { tagsInput.type = 'text'; tagsInput.maxLength = 300; tagsInput.placeholder = t('recipeTagsHint'); + tagsInput.value = Array.isArray(initial?.tags) ? initial.tags.join(', ') : ''; tagsLabel.appendChild(tagsInput); const notesLabel = appendText(dialog, 'label', t('recipeNotes')); @@ -233,6 +236,7 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { notesInput.className = 'anomalous-nb-textarea'; notesInput.maxLength = 3000; notesInput.placeholder = t('recipeNotesHint'); + notesInput.value = initial?.notes || ''; notesLabel.appendChild(notesInput); const coverSection = document.createElement('section'); @@ -243,7 +247,8 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { const coverPreview = document.createElement('img'); coverPreview.className = 'anomalous-recipe-dialog-preview'; coverPreview.alt = t('recipeThumbnail'); - if (selection.thumbnail) coverPreview.src = selection.thumbnail; + const initialPreview = selection.thumbnail || outputImageUrl(selection.sourceImage); + if (initialPreview) coverPreview.src = initialPreview; else coverPreview.style.display = 'none'; const choiceButtons = []; @@ -264,18 +269,29 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { noneChoice.type = 'button'; choiceButtons.push(noneChoice); noneChoice.onclick = () => selectCover(noneChoice, null, null, null); - if (selection.thumbnail) { + if (safeThumbnail(initial?.thumbnail) || initial?.source_image) { + const existingChoice = appendText(coverChoices, 'button', t('recipeKeepImage'), 'anomalous-recipe-cover-choice selected'); + existingChoice.type = 'button'; + choiceButtons.push(existingChoice); + existingChoice.onclick = () => selectCover( + existingChoice, + initial?.source_image || null, + safeThumbnail(initial?.thumbnail) || outputImageUrl(initial?.source_image), + safeThumbnail(initial?.thumbnail), + ); + } + if (safeThumbnail(canvasThumbnail)) { const canvasChoice = document.createElement('button'); canvasChoice.type = 'button'; - canvasChoice.className = 'anomalous-recipe-cover-choice selected'; + canvasChoice.className = `anomalous-recipe-cover-choice${initial ? '' : ' selected'}`; const canvasImage = document.createElement('img'); - canvasImage.src = selection.thumbnail; + canvasImage.src = canvasThumbnail; canvasImage.alt = t('recipeCanvasPreview'); appendText(canvasChoice, 'span', t('recipeCanvasPreview')); canvasChoice.prepend(canvasImage); choiceButtons.push(canvasChoice); canvasChoice.onclick = () => selectCover(canvasChoice, null, canvasThumbnail, safeThumbnail(canvasThumbnail)); - } else { + } else if (!initial) { noneChoice.classList.add('selected'); } @@ -393,6 +409,216 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices) { }); } +function editableValueControl(choice, changes) { + const value = choice.value; + const wrapper = document.createElement('label'); + wrapper.className = 'anomalous-recipe-edit-param'; + appendText(wrapper, 'span', choice.widgetName, 'anomalous-recipe-widget-name'); + if (!['string', 'number', 'boolean'].includes(typeof value)) { + appendText(wrapper, 'code', displayWidgetValue(value), 'anomalous-recipe-widget-value'); + appendText(wrapper, 'small', t('recipeComplexParam'), 'anomalous-recipe-node-hint'); + return wrapper; + } + + const input = typeof value === 'string' && value.length > 120 + ? document.createElement('textarea') + : document.createElement('input'); + input.className = typeof input.value === 'string' && input.tagName === 'TEXTAREA' + ? 'anomalous-nb-textarea' + : 'anomalous-nb-select'; + if (typeof value === 'boolean') { + input.type = 'checkbox'; + input.checked = value; + } else { + input.type = typeof value === 'number' ? 'number' : 'text'; + input.value = String(value); + if (typeof value === 'number') input.step = 'any'; + } + const update = () => { + let nextValue; + if (typeof value === 'boolean') nextValue = input.checked; + else if (typeof value === 'number') { + nextValue = Number(input.value); + if (!Number.isFinite(nextValue)) return; + } else nextValue = input.value; + changes.set(choice.key, { ...choice, previousValue: value, value: nextValue }); + }; + input.onchange = update; + input.oninput = typeof value === 'string' ? update : null; + wrapper.appendChild(input); + return wrapper; +} + +function showRecipeEditDialog(owner, recipeData, filename, history) { + return new Promise((resolve) => { + const params = recipeData.params || {}; + const choices = extractRecipeParameterChoicesFromMetadata(params); + const selectedPins = new Set((params.pinned || []).map((pin) => pin.key)); + const changes = new Map(); + const overlay = document.createElement('div'); + overlay.className = 'anomalous-recipe-dialog-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + const dialog = document.createElement('div'); + dialog.className = 'anomalous-recipe-dialog anomalous-recipe-edit-dialog'; + appendText(dialog, 'h3', t('recipeEditTitle')); + + const nameLabel = appendText(dialog, 'label', t('recipeName')); + const nameInput = document.createElement('input'); + nameInput.className = 'anomalous-nb-select'; + nameInput.type = 'text'; + nameInput.maxLength = 120; + nameInput.value = recipeData.name || ''; + nameLabel.appendChild(nameInput); + + const tagsLabel = appendText(dialog, 'label', t('recipeTags')); + const tagsInput = document.createElement('input'); + tagsInput.className = 'anomalous-nb-select'; + tagsInput.type = 'text'; + tagsInput.maxLength = 300; + tagsInput.value = Array.isArray(recipeData.tags) ? recipeData.tags.join(', ') : ''; + tagsInput.placeholder = t('recipeTagsHint'); + tagsLabel.appendChild(tagsInput); + + const notesLabel = appendText(dialog, 'label', t('recipeNotes')); + const notesInput = document.createElement('textarea'); + notesInput.className = 'anomalous-nb-textarea'; + notesInput.maxLength = 3000; + notesInput.value = recipeData.notes || ''; + notesInput.placeholder = t('recipeNotesHint'); + notesLabel.appendChild(notesInput); + + if (choices.length) { + const pinDetails = document.createElement('details'); + pinDetails.className = 'anomalous-recipe-pin-picker'; + const pinSummary = document.createElement('summary'); + pinSummary.textContent = `${t('recipeChoosePinnedParams')} (${selectedPins.size})`; + pinDetails.appendChild(pinSummary); + const pinList = document.createElement('div'); + pinList.className = 'anomalous-recipe-pin-list'; + for (const choice of choices) { + const label = document.createElement('label'); + label.className = 'anomalous-recipe-pin-choice'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = selectedPins.has(choice.key); + checkbox.onchange = () => { + if (checkbox.checked) selectedPins.add(choice.key); + else selectedPins.delete(choice.key); + pinSummary.textContent = `${t('recipeChoosePinnedParams')} (${selectedPins.size})`; + }; + appendText(label, 'span', `${choice.nodeTitle || choice.nodeType}: ${choice.widgetName}`); + label.prepend(checkbox); + pinList.appendChild(label); + } + pinDetails.appendChild(pinList); + dialog.appendChild(pinDetails); + } + + if (choices.length) { + const parameterDetails = document.createElement('details'); + parameterDetails.className = 'anomalous-recipe-node-details'; + const parameterSummary = document.createElement('summary'); + parameterSummary.textContent = t('recipeEditNodeParams'); + parameterDetails.appendChild(parameterSummary); + let rendered = false; + parameterDetails.ontoggle = () => { + if (!parameterDetails.open || rendered) return; + rendered = true; + const list = document.createElement('div'); + list.className = 'anomalous-recipe-node-list'; + let currentNode = null; + for (const choice of choices) { + const nodeKey = `${choice.nodeId}:${choice.nodeType}:${choice.nodeTitle || ''}`; + if (nodeKey !== currentNode) { + appendText(list, 'strong', choice.nodeTitle || choice.nodeType || t('recipeUnknownNode'), 'anomalous-recipe-pin-node'); + currentNode = nodeKey; + } + list.appendChild(editableValueControl(choice, changes)); + } + parameterDetails.appendChild(list); + }; + dialog.appendChild(parameterDetails); + } + + const historyDetails = document.createElement('details'); + historyDetails.className = 'anomalous-recipe-node-details'; + const historySummary = document.createElement('summary'); + historySummary.textContent = `${t('recipeHistory')} (${history.length})`; + historyDetails.appendChild(historySummary); + const historyList = document.createElement('div'); + historyList.className = 'anomalous-recipe-history-list'; + if (!history.length) { + appendText(historyList, 'small', t('recipeHistoryEmpty'), 'anomalous-recipe-node-hint'); + } else { + for (const version of history) { + const row = document.createElement('div'); + row.className = 'anomalous-recipe-history-row'; + const date = Number.isFinite(Number(version.timestamp)) + ? new Date(Number(version.timestamp)).toLocaleString() + : t('recipeUnknownVersion'); + appendText(row, 'span', `${date} · ${version.name || t('recipeUntitled')}`); + const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger'); + restore.type = 'button'; + restore.onclick = async () => { + if (!confirm(t('recipeRestoreVersionConfirm'))) return; + try { + const response = await fetch('/anomalous/restore_recipe_version', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename, version: version.version }), + }); + if (!response.ok) throw new Error('recipe history restore failed'); + overlay.remove(); + await owner.refreshRecipes(); + resolve({ mode: 'restored' }); + } catch (error) { + console.error('Could not restore Workflow Recipe version:', error); + alert(t('recipeUpdateError')); + } + }; + row.appendChild(restore); + historyList.appendChild(row); + } + } + historyDetails.appendChild(historyList); + dialog.appendChild(historyDetails); + + const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error'); + const actions = document.createElement('div'); + actions.className = 'anomalous-recipe-actions'; + const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger'); + const canvas = appendText(actions, 'button', t('recipeEditCanvas'), 'anomalous-btn-primary'); + const save = appendText(actions, 'button', t('recipeUpdate'), 'anomalous-btn-success'); + for (const button of [cancel, canvas, save]) button.type = 'button'; + const close = (value) => { overlay.remove(); resolve(value); }; + cancel.onclick = () => close(null); + overlay.onclick = (event) => { if (event.target === overlay) close(null); }; + canvas.onclick = () => close({ mode: 'canvas' }); + save.onclick = () => { + const name = nameInput.value.trim(); + if (!name) { + error.textContent = t('recipeNameRequired'); + nameInput.focus(); + return; + } + close({ + mode: 'save', + name, + tags: [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20), + notes: notesInput.value.trim(), + pinnedKeys: selectedPins, + changes: [...changes.values()], + }); + }; + dialog.appendChild(actions); + overlay.appendChild(dialog); + (owner.nbPanel || document.body).appendChild(overlay); + nameInput.focus(); + nameInput.select(); + }); +} + export async function showRecipes() { if (!this.notebookContainer) { this.nbPanel.style.display = 'flex'; @@ -471,6 +697,49 @@ export function renderRecipeList(recipes) { const actions = document.createElement('div'); actions.className = 'anomalous-recipe-actions'; + const edit = appendText(actions, 'button', t('recipeEdit'), 'anomalous-btn-success'); + edit.type = 'button'; + edit.onclick = async () => { + try { + const [fullResponse, historyResponse] = await Promise.all([ + fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(recipe.filename)}`), + fetch(`/anomalous/recipe_history?filename=${encodeURIComponent(recipe.filename)}`), + ]); + const payload = await fullResponse.json(); + const historyPayload = historyResponse.ok ? await historyResponse.json() : { versions: [] }; + if (!fullResponse.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow'); + const editable = JSON.parse(JSON.stringify(payload.data)); + const result = await showRecipeEditDialog(this, editable, recipe.filename, historyPayload.versions || []); + if (!result || result.mode === 'restored') return; + if (result.mode === 'canvas') { + if (!confirm(t('recipeEditCanvasConfirm'))) return; + app.loadGraphData(editable.workflow); + app.canvas?.setDirty?.(true, true); + this.recipeEditing = { filename: recipe.filename, data: editable }; + const saveButton = this.recipeView?.querySelector('.anomalous-recipe-actionbar button'); + if (saveButton) saveButton.textContent = t('recipeUpdateCurrent'); + return; + } + + applyRecipeWidgetChanges(editable.params, editable.workflow, result.changes); + const updatedChoices = extractRecipeParameterChoicesFromMetadata(editable.params); + editable.name = result.name; + editable.tags = result.tags; + editable.notes = result.notes; + editable.params.pinned = updatedChoices.filter((choice) => result.pinnedKeys.has(choice.key)); + const response = await fetch('/anomalous/update_recipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: recipe.filename, ...editable }), + }); + const updatedPayload = await response.json(); + if (!response.ok || updatedPayload.status !== 'success') throw new Error('recipe update failed'); + await this.refreshRecipes(); + } catch (error) { + console.error('Could not edit Workflow Recipe:', error); + alert(t('recipeUpdateError')); + } + }; const restore = appendText(actions, 'button', t('recipeRestore'), 'anomalous-btn-primary'); restore.type = 'button'; restore.onclick = async () => { @@ -524,7 +793,8 @@ export async function handleSaveRecipe() { const metadata = extractRecipeMetadata(app.graph); const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas); const parameterChoices = extractRecipeParameterChoices(app.graph); - const details = await showRecipeSaveDialog(this, canvasThumbnail, parameterChoices); + const editing = this.recipeEditing || null; + const details = await showRecipeSaveDialog(this, canvasThumbnail, parameterChoices, editing?.data || null); if (!details) return; const saveButton = this.recipeView?.querySelector('.anomalous-recipe-actionbar button'); @@ -542,10 +812,11 @@ export async function handleSaveRecipe() { console.warn('Could not persist bound recipe image thumbnail:', error); } } - const response = await fetch('/anomalous/save_recipe', { + const response = await fetch(editing ? '/anomalous/update_recipe' : '/anomalous/save_recipe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + ...(editing ? { filename: editing.filename } : {}), name: details.name, tags: details.tags, notes: details.notes, @@ -557,6 +828,7 @@ export async function handleSaveRecipe() { }); const payload = await response.json(); if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed'); + this.recipeEditing = null; await this.refreshRecipes(); } catch (error) { console.error('Could not save Workflow Recipe:', error); @@ -564,7 +836,7 @@ export async function handleSaveRecipe() { } finally { if (saveButton) { saveButton.disabled = false; - saveButton.textContent = t('recipeSaveCurrent'); + saveButton.textContent = this.recipeEditing ? t('recipeUpdateCurrent') : t('recipeSaveCurrent'); } } } diff --git a/web/styles.css b/web/styles.css index 105b8c2..1badbae 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1182,6 +1182,28 @@ padding: 3px 0; } +.anomalous-recipe-edit-param { + display: grid; + grid-template-columns: minmax(110px, 34%) minmax(0, 1fr); + align-items: start; + gap: 8px; + padding: 7px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.anomalous-recipe-edit-param .anomalous-nb-select, +.anomalous-recipe-edit-param .anomalous-nb-textarea { + width: 100%; + margin: 0; + box-sizing: border-box; +} + +.anomalous-recipe-edit-param input[type="checkbox"] { + width: auto; + min-height: 22px; + justify-self: start; +} + .anomalous-recipe-widget-name { flex: 0 0 34%; overflow: hidden; @@ -1258,6 +1280,10 @@ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.6); } +.anomalous-recipe-edit-dialog { + width: min(760px, 100%); +} + .anomalous-recipe-dialog h3, .anomalous-recipe-dialog label { display: flex; @@ -1362,6 +1388,38 @@ background: rgba(0, 255, 204, 0.05); } +.anomalous-recipe-history-list { + display: flex; + flex-direction: column; + gap: 7px; + padding-top: 8px; +} + +.anomalous-recipe-history-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px; + border: 1px solid #3f3f3f; + border-radius: 7px; + color: #c9c9c9; + font-size: 0.82rem; +} + +.anomalous-recipe-history-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 560px) { + .anomalous-recipe-edit-param { + grid-template-columns: 1fr; + } +} + .anomalous-recipe-dialog-error { min-height: 1em; color: #ff7777;