From 978e9f7750b96c33ea4766c92bb894e8ae3a0df8 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Mon, 3 Aug 2026 03:44:06 +0800 Subject: [PATCH] Harden recipe save integrity --- .agents/logs/ai_changelog.md | 18 +++++++ .agents/logs/ai_lessons.md | 6 +++ ARCHITECTURE.md | 4 +- api/recipes.py | 96 +++++++++++++++++++++++++++++++-- web/modules/locales.js | 6 +++ web/modules/recipe_parser.js | 35 ++++++++++-- web/modules/ui_recipe_detail.js | 4 +- web/modules/ui_recipes.js | 56 +++++++++++++++---- web/styles.css | 10 ++++ 9 files changed, 213 insertions(+), 22 deletions(-) diff --git a/.agents/logs/ai_changelog.md b/.agents/logs/ai_changelog.md index 6a9169a..0d50eb7 100644 --- a/.agents/logs/ai_changelog.md +++ b/.agents/logs/ai_changelog.md @@ -1,5 +1,23 @@ # AI Changelog +## [Snapshot] 2026-08-03 — Harden Workflow Recipe save integrity + +**Implemented** + +- Captured the serialized canvas once before the recipe save dialog; the exact snapshot, rather than a later live-canvas serialization, is now sent to save/update. +- Kept generic node summaries bounded for browsing, but resolve saved editable/pinnable values from the authoritative workflow by node ID and widget index. Long prompt edits therefore retain their complete value. +- Preserved only valid existing pinned parameters during a canvas-based recipe update and display a localized count when prior pins no longer match the edited graph. +- Added backend graph topology validation (node IDs, widget bounds, link endpoint existence, and bounded node/link/group counts) plus save/update/restore integrity receipts containing persisted counts and fingerprint. +- Switched missing-node preflight to the saved workflow instead of bounded presentation metadata, and added a concise save receipt beside the Workspace action controls. + +**Validation** + +- `node tests/recipe_parser_roundtrip.mjs` +- `python_embeded/python.exe tests/test_recipe_roundtrip.py` +- Validated the real schema-v4 fixture: 13 nodes, 17 links, 0 groups +- `python_embeded/python.exe -m py_compile api/recipes.py` +- `git diff --check` + ## [Planning Audit] 2026-08-03 — Workflow Recipe integrity and UX hardening - Added `.agents/plans/workflow_recipe_audit_and_hardening_plan.md` after a read-only audit of recipe save/update, detail rendering, canvas actions, model identity, history, package transfer, Workspace lifecycle, and CSS interaction structure. diff --git a/.agents/logs/ai_lessons.md b/.agents/logs/ai_lessons.md index b9fd2b3..2c2bbd0 100644 --- a/.agents/logs/ai_lessons.md +++ b/.agents/logs/ai_lessons.md @@ -5,6 +5,12 @@ This document serves as an architectural retrospective and UX diagnostic log for --- +## 57. A Summary Must Never Become the Save/Edit Source + +**The Problem**: Recipe metadata deliberately truncates generic widget values for fast cards and detail browsing. Reusing it for edit controls meant a long prompt could be saved back as its truncated preview. A canvas-based update also started with an empty pin selection, silently dropping valid prior key parameters. + +**The Solution**: Capture one serialized workflow snapshot before the save dialog, use it as the only outbound graph, and resolve editable/pinnable widget values from that snapshot by stable node ID plus widget index. Carry forward only pin keys that still resolve on the current graph and make dropped pins explicit. Server acceptance must validate basic graph topology and return a compact receipt so the UI can show what was accepted. + ## 51. A Bounded Card Summary Is Not a Detail Value (卡片摘要不能冒充详情值) **The Problem (问题)**: The recipe card intentionally limits generic widget text for fast browsing, but the detail panel reused that same summary. Long prompts and output settings looked saved yet could not be inspected or copied in full, while the negative prompt was hidden behind a positive-only overview layout. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 569696f..43a88a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,7 +68,7 @@ All endpoints are prefixed with `/anomalous/`. 7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`. 8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`. 9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle. -10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, and use atomic replacement. A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename. +10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, validate graph node IDs/widget bounds/link endpoints before writing, and use atomic replacement. Successful save, update, and restore responses include a compact integrity receipt (node/link/group counts and the persisted workflow fingerprint). A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename. ### NON-NEGOTIABLE: Model Sidecar & Cover Lifecycle / 模型伴生文件与封面生命周期死规矩 * `.civitai_bak.*` is a persistent restore source created from a real Civitai download. Setting a custom cover MUST modify only `.preview.*`; it MUST NOT delete, overwrite, or repurpose `.civitai_bak.*`. @@ -106,7 +106,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. * **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. Its shared user-facing surface is **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This presentation rename intentionally leaves notebook routes, storage, and internal property names stable for compatibility. -* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace 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). Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Models & reproducibility, Parameters, and Versions tabs with one vertical scroll region. Opening Models & reproducibility resolves current previews through `/anomalous/resolve_paths_to_previews` using `exact_only: true` and a category folder boundary, so it never launches a recursive model scan. Its availability refresh also accepts only bounded exact references and never computes a full-file hash. The save dialog may pin any safe JSON-serializable widget, bind an output cover, and explicitly retain bounded model-preview snapshots. The edit dialog can update recipe metadata, pins, and safe primitive widget values without loading a graph; it applies changes only to 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). `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. +* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace 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). Save captures the serialized graph exactly once before the dialog; that snapshot, not a later live-canvas serialization, is the workflow sent to the API. Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Models & reproducibility, Parameters, and Versions tabs with one vertical scroll region. Opening Models & reproducibility resolves current previews through `/anomalous/resolve_paths_to_previews` using `exact_only: true` and a category folder boundary, so it never launches a recursive model scan. Its availability refresh also accepts only bounded exact references and never computes a full-file hash. The save dialog may pin any safe JSON-serializable widget, restores only pins still present on a canvas update, bind an output cover, and explicitly retain bounded model-preview snapshots. The edit dialog resolves safe primitive values from the full workflow by node ID/widget index before updating the matching serialized slot; bounded summaries are never the edit source. 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). Missing-node preflight also reads authoritative workflow node types. `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. * **Recipe interaction extensions (`recipe_diff.js`, `recipe_actions.js`)**: Recipe cards filter only lightweight name/notes/tags metadata. Version comparison fetches one validated historical JSON and returns bounded semantic changes without instantiating or mutating either graph. Append clones serialized nodes into the live graph with collision-free IDs, transactional link remapping, placement, selection, and rollback. Open/Append UI actions share the same exported handlers from `ui_recipe_detail.js`, including dirty-canvas confirmation, missing-node warning, error feedback, and Workspace closure after success. * **Recipe detail presentation**: Copy actions use visible temporary success/failure feedback. Name, notes, and tags can be edited inline from the loaded detail view and are persisted through the existing full-recipe update contract, preserving the serialized workflow and history behavior. Workflow fingerprints and model SHA-256 values are advanced information shown only inside collapsed disclosure panels; card and detail surfaces use bounded glass-style cards, responsive grids, and restrained hover transitions. Model references are rendered as independent responsive cards rather than one dense shared row, so each reference keeps its preview, status, and actions together. The Overview separates base model and each LoRA into one full-width composition block; sampling values remain in a compact key-value grid. * **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality. diff --git a/api/recipes.py b/api/recipes.py index e4ab56e..ccdbe2d 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -35,6 +35,10 @@ MAX_PREVIEW_SNAPSHOTS = 12 MAX_PREVIEW_SNAPSHOT_BYTES = 96 * 1024 MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES = 1_250_000 MAX_PREVIEW_SOURCE_BYTES = 20 * 1024 * 1024 +MAX_WORKFLOW_NODES = 5_000 +MAX_WORKFLOW_LINKS = 30_000 +MAX_WORKFLOW_GROUPS = 2_000 +MAX_WIDGET_VALUES_PER_NODE = 2_048 SAFE_THUMBNAIL_PREFIXES = ( "data:image/jpeg;base64,", "data:image/png;base64,", @@ -284,6 +288,79 @@ def _read_recipe(path): return json.load(recipe_file) +def _workflow_node_key(value): + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise ValueError("Invalid workflow node id") + value = str(value).strip() + if not value: + raise ValueError("Invalid workflow node id") + return value + + +def _workflow_link_records(workflow): + links = workflow.get("links", []) + if links is None: + return [] + if isinstance(links, list): + return links + if isinstance(links, dict): + return list(links.values()) + raise ValueError("Invalid workflow links") + + +def _validate_workflow(workflow): + """Reject malformed graph topology before it reaches user recipe storage.""" + if not isinstance(workflow, dict): + raise ValueError("Invalid recipe workflow") + nodes = workflow.get("nodes") + if not isinstance(nodes, list) or len(nodes) > MAX_WORKFLOW_NODES: + raise ValueError("Invalid workflow nodes") + + node_ids = set() + for node in nodes: + if not isinstance(node, dict): + raise ValueError("Invalid workflow node") + node_key = _workflow_node_key(node.get("id")) + if node_key in node_ids: + raise ValueError("Duplicate workflow node id") + node_ids.add(node_key) + values = node.get("widgets_values") + if values is not None and (not isinstance(values, list) or len(values) > MAX_WIDGET_VALUES_PER_NODE): + raise ValueError("Invalid workflow widget values") + + groups = workflow.get("groups", []) + if groups is not None and (not isinstance(groups, list) or len(groups) > MAX_WORKFLOW_GROUPS): + raise ValueError("Invalid workflow groups") + + links = _workflow_link_records(workflow) + if len(links) > MAX_WORKFLOW_LINKS: + raise ValueError("Too many workflow links") + for link in links: + if isinstance(link, list) and len(link) >= 5: + origin_id, target_id = link[1], link[3] + elif isinstance(link, dict): + origin_id, target_id = link.get("origin_id"), link.get("target_id") + else: + raise ValueError("Invalid workflow link") + if _workflow_node_key(origin_id) not in node_ids or _workflow_node_key(target_id) not in node_ids: + raise ValueError("Dangling workflow link") + + +def _recipe_receipt(recipe, filename): + """Return a small, user-visible confirmation for the accepted graph.""" + workflow = recipe.get("workflow") if isinstance(recipe, dict) else {} + params = recipe.get("params") if isinstance(recipe, dict) else {} + return { + "filename": filename, + "node_count": len(workflow.get("nodes", [])) if isinstance(workflow, dict) else 0, + "link_count": len(_workflow_link_records(workflow)) if isinstance(workflow, dict) else 0, + "group_count": len(workflow.get("groups", [])) if isinstance(workflow.get("groups", []), list) else 0, + "parameter_node_count": len(params.get("nodes", [])) if isinstance(params, dict) and isinstance(params.get("nodes", []), list) else 0, + "pinned_count": len(params.get("pinned", [])) if isinstance(params, dict) and isinstance(params.get("pinned", []), list) else 0, + "workflow_fingerprint": recipe.get("workflow_fingerprint") if isinstance(recipe, dict) else None, + } + + def _list_recipes(recipes_dir): recipes = [] try: @@ -356,6 +433,7 @@ def _normalise_recipe(payload): workflow = payload.get("workflow") if not isinstance(params, dict) or not isinstance(workflow, dict): raise ValueError("Invalid recipe workflow") + _validate_workflow(workflow) thumbnail = payload.get("thumbnail") if thumbnail is not None: @@ -515,7 +593,11 @@ async def api_save_recipe(request): await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe) except (OSError, ValueError): return web.json_response({"status": "error", "message": "Could not save recipe"}, status=500) - return web.json_response({"status": "success", "filename": filename}) + return web.json_response({ + "status": "success", + "filename": filename, + "receipt": _recipe_receipt(recipe, filename), + }) async def api_delete_recipe(request): @@ -607,7 +689,11 @@ async def api_update_recipe(request): 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}) + return web.json_response({ + "status": "success", + "filename": filename, + "receipt": _recipe_receipt(recipe, filename), + }) async def api_get_recipe_history(request): @@ -683,7 +769,11 @@ async def api_restore_recipe_version(request): 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}) + return web.json_response({ + "status": "success", + "filename": filename, + "receipt": _recipe_receipt(recipe, filename), + }) async def api_refresh_recipe_identity(request): diff --git a/web/modules/locales.js b/web/modules/locales.js index 6deff82..1fdd3b2 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -99,6 +99,8 @@ export const i18n = { recipeCancel: '取消', recipeSave: '保存', recipeSaving: '正在保存…', + recipeSaveReceipt: '已保存:{nodes} 个节点,{links} 条连线,{groups} 个分组。', + recipeSaveReceiptMismatch: '已保存,但完整性回执与当前画布不一致;请重新打开配方核对。', recipeEmpty: '还没有工作流配方。保存一个已经跑通的工作流吧。', recipeLoadError: '无法加载工作流配方。', recipeUntitled: '未命名配方', @@ -156,6 +158,7 @@ export const i18n = { recipeRecentImages: '最近生成结果', recipeRecentImagesUnavailable: '暂时无法读取最近生成结果。', recipeChoosePinnedParams: '选择关键参数', + recipePinnedParamsUnavailable: '{count} 个原有关键参数已不再对应当前画布,保存后不会保留。', recipePinnedParams: '关键参数', recipeKeepImage: '保留当前封面', recipeEdit: '编辑', @@ -346,6 +349,8 @@ export const i18n = { recipeCancel: 'Cancel', recipeSave: 'Save', recipeSaving: 'Saving…', + recipeSaveReceipt: 'Saved: {nodes} nodes, {links} links, {groups} groups.', + recipeSaveReceiptMismatch: 'Saved, but the integrity receipt differs from the current canvas. Reopen the recipe to verify it.', recipeEmpty: 'No Workflow Recipes yet. Save a workflow that is working well.', recipeLoadError: 'Could not load Workflow Recipes.', recipeUntitled: 'Untitled Recipe', @@ -403,6 +408,7 @@ export const i18n = { recipeRecentImages: 'Recent outputs', recipeRecentImagesUnavailable: 'Recent outputs are currently unavailable.', recipeChoosePinnedParams: 'Choose key parameters', + recipePinnedParamsUnavailable: '{count} previous key parameter(s) no longer match the canvas and will not be retained.', recipePinnedParams: 'Key parameters', recipeKeepImage: 'Keep current cover', recipeEdit: 'Edit', diff --git a/web/modules/recipe_parser.js b/web/modules/recipe_parser.js index dccd7f8..41d21de 100644 --- a/web/modules/recipe_parser.js +++ b/web/modules/recipe_parser.js @@ -123,17 +123,22 @@ export function extractRecipeParameterChoices(graph) { * New summaries retain their original widget index; older recipes gracefully * fall back to the visible widget order. */ -export function extractRecipeParameterChoicesFromMetadata(params) { +export function extractRecipeParameterChoicesFromMetadata(params, workflow = null) { const choices = []; + const workflowNodes = new Map((workflow?.nodes || []).filter(Boolean).map((node) => [node.id, node])); 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); + const widgetIndex = Number.isInteger(widget?.index) ? widget.index : visibleIndex; + const workflowValues = workflowNodes.get(node?.id)?.widgets_values; + const fullValue = Array.isArray(workflowValues) && widgetIndex >= 0 && widgetIndex < workflowValues.length + ? workflowValues[widgetIndex] + : widget?.value; + const value = clonePinnableValue(fullValue); if (value === null || value === '') continue; - const widgetIndex = Number.isInteger(widget?.index) ? widget.index : null; choices.push({ - key: `${node?.id ?? 'unknown'}:${widgetIndex ?? visibleIndex}:${widgetName}`, + key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`, nodeId: node?.id ?? null, nodeType: node?.type || 'Unknown', nodeTitle: node?.title || null, @@ -386,6 +391,28 @@ export function extractRecipeMetadata(graph) { return metadata; } +/** + * Capture the graph once at save time. The serialized workflow is the source + * of truth; metadata and pin choices are only bounded presentation layers. + */ +export function captureRecipeDraft(graph) { + const workflow = graph?.serialize?.(); + const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : []; + const links = Array.isArray(workflow?.links) + ? workflow.links + : (workflow?.links && typeof workflow.links === 'object' ? Object.values(workflow.links) : []); + return { + workflow, + metadata: extractRecipeMetadata(graph), + parameterChoices: extractRecipeParameterChoices(graph), + stats: { + nodeCount: nodes.length, + linkCount: links.length, + groupCount: Array.isArray(workflow?.groups) ? workflow.groups.length : 0, + }, + }; +} + /** Capture a bounded canvas preview; the optional element makes this testable. */ export function captureCanvasThumbnail(canvasEl = null) { const source = canvasEl || document.getElementById('comfy-canvas-element') || document.querySelector('canvas'); diff --git a/web/modules/ui_recipe_detail.js b/web/modules/ui_recipe_detail.js index 6a8985e..74c30ea 100644 --- a/web/modules/ui_recipe_detail.js +++ b/web/modules/ui_recipe_detail.js @@ -44,8 +44,8 @@ function closeRecipeWorkspace(owner) { function missingRecipeNodeTypes(recipe) { const registry = globalThis.LiteGraph?.registered_node_types; if (!registry) return []; - return [...new Set((recipe?.params?.nodes || []) - .map((node) => node?.type) + return [...new Set((recipe?.workflow?.nodes || []) + .map((node) => node?.type || node?.class_type) .filter((type) => type && !registry[type]))]; } diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index 4fe4be8..3c7ac29 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -4,9 +4,8 @@ import { app } from '../../../scripts/app.js'; import { i18n } from './locales.js'; import { captureCanvasThumbnail, + captureRecipeDraft, applyRecipeWidgetChanges, - extractRecipeMetadata, - extractRecipeParameterChoices, extractRecipeParameterChoicesFromMetadata, } from './recipe_parser.js'; import { @@ -21,6 +20,10 @@ const t = (key) => { return i18n[lang]?.[key] || i18n.en?.[key] || key; }; +function formatRecipeText(key, values = {}) { + return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key)); +} + function appendText(parent, tagName, text, className = '') { const element = document.createElement(tagName); if (className) element.className = className; @@ -410,7 +413,7 @@ async function editRecipe(owner, recipe, filename, history = null) { } applyRecipeWidgetChanges(editable.params, editable.workflow, result.changes); - const updatedChoices = extractRecipeParameterChoicesFromMetadata(editable.params); + const updatedChoices = extractRecipeParameterChoicesFromMetadata(editable.params, editable.workflow); editable.name = result.name; editable.tags = result.tags; editable.notes = result.notes; @@ -437,6 +440,10 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices, initial pinnedKeys: new Set(), saveModelPreviewSnapshots: initial?.presentation?.save_model_preview_snapshots === true, }; + const availablePinKeys = new Set(parameterChoices.map((choice) => choice.key)); + const previousPinnedKeys = new Set((initial?.params?.pinned || []).map((pin) => pin?.key).filter(Boolean)); + selection.pinnedKeys = new Set([...previousPinnedKeys].filter((key) => availablePinKeys.has(key))); + const unavailablePinCount = previousPinnedKeys.size - selection.pinnedKeys.size; const overlay = document.createElement('div'); overlay.className = 'anomalous-recipe-dialog-overlay'; overlay.setAttribute('role', 'dialog'); @@ -579,8 +586,16 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices, initial const parameterDetails = document.createElement('details'); parameterDetails.className = 'anomalous-recipe-pin-picker'; const parameterSummary = document.createElement('summary'); - parameterSummary.textContent = `${t('recipeChoosePinnedParams')} (0)`; + parameterSummary.textContent = `${t('recipeChoosePinnedParams')} (${selection.pinnedKeys.size})`; parameterDetails.appendChild(parameterSummary); + if (unavailablePinCount) { + appendText( + parameterDetails, + 'small', + formatRecipeText('recipePinnedParamsUnavailable', { count: unavailablePinCount }), + 'anomalous-recipe-node-hint', + ); + } let parametersRendered = false; parameterDetails.ontoggle = () => { if (!parameterDetails.open || parametersRendered) return; @@ -599,6 +614,7 @@ function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices, initial label.className = 'anomalous-recipe-pin-choice'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.checked = selection.pinnedKeys.has(choice.key); checkbox.onchange = () => { if (checkbox.checked) selection.pinnedKeys.add(choice.key); else selection.pinnedKeys.delete(choice.key); @@ -699,7 +715,7 @@ function editableValueControl(choice, changes) { function showRecipeEditDialog(owner, recipeData, filename, history) { return new Promise((resolve) => { const params = recipeData.params || {}; - const choices = extractRecipeParameterChoicesFromMetadata(params); + const choices = extractRecipeParameterChoicesFromMetadata(params, recipeData.workflow); const selectedPins = new Set((params.pinned || []).map((pin) => pin.key)); const changes = new Map(); const overlay = document.createElement('div'); @@ -925,6 +941,7 @@ export async function showRecipes() { save.dataset.recipeSaveCurrent = 'true'; save.type = 'button'; save.onclick = () => this.handleSaveRecipe(); + this.recipeSaveStatus = appendText(actionBar, 'small', '', 'anomalous-recipe-save-status'); const importButton = appendText(actionBar, 'button', t('recipeImport'), 'anomalous-btn-primary'); importButton.type = 'button'; importButton.onclick = () => { @@ -1098,11 +1115,14 @@ export async function handleSaveRecipe() { alert(t('recipeSaveError')); return; } - const metadata = extractRecipeMetadata(app.graph); + const draft = captureRecipeDraft(app.graph); + if (!draft.workflow || !Array.isArray(draft.workflow.nodes)) { + alert(t('recipeSaveError')); + return; + } const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas); - const parameterChoices = extractRecipeParameterChoices(app.graph); const editing = this.recipeEditing || null; - const details = await showRecipeSaveDialog(this, canvasThumbnail, parameterChoices, editing?.data || null); + const details = await showRecipeSaveDialog(this, canvasThumbnail, draft.parameterChoices, editing?.data || null); if (!details) return; const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]'); @@ -1111,7 +1131,7 @@ export async function handleSaveRecipe() { saveButton.textContent = t('recipeSaving'); } try { - metadata.pinned = details.pinned; + draft.metadata.pinned = details.pinned; let thumbnail = details.thumbnail; if (details.sourceImage) { try { @@ -1128,8 +1148,8 @@ export async function handleSaveRecipe() { name: details.name, tags: details.tags, notes: details.notes, - params: metadata, - workflow: app.graph.serialize(), + params: draft.metadata, + workflow: draft.workflow, thumbnail, source_image: details.sourceImage, presentation: { save_model_preview_snapshots: details.saveModelPreviewSnapshots }, @@ -1137,6 +1157,20 @@ export async function handleSaveRecipe() { }); const payload = await response.json(); if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed'); + const receipt = payload.receipt || {}; + const receiptMatchesDraft = receipt.node_count === draft.stats.nodeCount + && receipt.link_count === draft.stats.linkCount + && receipt.group_count === draft.stats.groupCount; + if (this.recipeSaveStatus) { + this.recipeSaveStatus.textContent = receiptMatchesDraft + ? formatRecipeText('recipeSaveReceipt', { + nodes: receipt.node_count, + links: receipt.link_count, + groups: receipt.group_count, + }) + : t('recipeSaveReceiptMismatch'); + this.recipeSaveStatus.classList.toggle('is-warning', !receiptMatchesDraft); + } this.recipeEditing = null; await this.refreshRecipes(); } catch (error) { diff --git a/web/styles.css b/web/styles.css index 4516f2c..b1753b8 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1121,6 +1121,16 @@ transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; } +.anomalous-recipe-save-status { + color: #8bd5ca; + flex: 1 1 180px; + line-height: 1.35; +} + +.anomalous-recipe-save-status.is-warning { + color: #ffcf70; +} + .anomalous-recipe-actions button:hover, .anomalous-recipe-detail-actions button:hover, .anomalous-recipe-inline-controls button:hover {