From 00f7010064ff11de7a42534df182daab70d511b9 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Mon, 3 Aug 2026 03:58:05 +0800 Subject: [PATCH] Guard recipe card actions --- .agents/logs/ai_changelog.md | 14 ++++++ .agents/logs/ai_lessons.md | 6 +++ ARCHITECTURE.md | 2 +- web/modules/ui_recipes.js | 88 ++++++++++++++++-------------------- 4 files changed, 61 insertions(+), 49 deletions(-) diff --git a/.agents/logs/ai_changelog.md b/.agents/logs/ai_changelog.md index 8346956..d785219 100644 --- a/.agents/logs/ai_changelog.md +++ b/.agents/logs/ai_changelog.md @@ -1,5 +1,19 @@ # AI Changelog +## [Snapshot] 2026-08-03 — Guard recipe card actions + +**Implemented** + +- Added a single card-action runner for detail, edit, export, open, append, and delete actions. +- Buttons are disabled while their asynchronous operation is active and always restored in `finally`, including cancellation and failed requests. +- Removed duplicated card-level error handling where the runner now owns the failure feedback. + +**Validation** + +- `node --check web/modules/ui_recipes.js` +- `node --check web/modules/ui_recipe_detail.js` +- `git diff --check` + ## [Snapshot] 2026-08-03 — Stabilize recipe detail navigation lifecycle **Implemented** diff --git a/.agents/logs/ai_lessons.md b/.agents/logs/ai_lessons.md index 7748a27..ac87670 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 --- +## 60. Async UI Actions Need One Ownership Boundary + +**The Problem**: Card actions each implemented their own `try/catch`, disabled state, or no state at all. A failed request could leave a control stuck, while a double click could start two recipe mutations. + +**The Solution**: Put busy-state ownership, duplicate-click prevention, error reporting, and `finally` restoration in one small action runner. Individual actions should only describe their domain operation and return/throw normally. + ## 59. Cross-Panel Navigation Needs an Explicit Return Token **The Problem**: A recipe detail panel can launch a model detail panel owned by the main browser. Without a return token, the model Back button correctly returned to the model grid but lost the recipe tab, scroll position, and active view. Separately, Open/Append actions performed their work but left the detail Promise unresolved. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 72e04fc..b39a48f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,7 +107,7 @@ Instead of fragmenting the class scope and losing context, we extracted all UI p * **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). 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 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. Card and detail actions use a shared busy/restore discipline so a network or canvas failure cannot leave a button permanently disabled or trigger duplicate mutations. 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/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index e66d9bf..54663fa 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -24,6 +24,21 @@ function formatRecipeText(key, values = {}) { return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key)); } +async function runRecipeCardAction(actionButton, action, errorKey) { + if (!actionButton || actionButton.disabled) return; + actionButton.disabled = true; + actionButton.classList.add('is-busy'); + try { + await action(); + } catch (error) { + console.error('Workflow Recipe action failed:', error); + alert(t(errorKey)); + } finally { + actionButton.disabled = false; + actionButton.classList.remove('is-busy'); + } +} + function appendText(parent, tagName, text, className = '') { const element = document.createElement(tagName); if (className) element.className = className; @@ -1040,8 +1055,7 @@ export function renderRecipeList(recipes) { actions.className = 'anomalous-recipe-actions'; const details = appendText(actions, 'button', t('recipeViewDetails'), 'anomalous-btn-primary'); details.type = 'button'; - details.onclick = async () => { - try { + details.onclick = () => runRecipeCardAction(details, async () => { const bundle = await fetchRecipeBundle(recipe.filename); const result = await showRecipeDetail(this, { recipe: bundle.data, @@ -1049,63 +1063,41 @@ export function renderRecipeList(recipes) { history: bundle.history, }); if (result?.mode === 'edit') await editRecipe(this, bundle.data, recipe.filename, bundle.history); - } catch (error) { - console.error('Could not open Workflow Recipe details:', error); - alert(t('recipeLoadError')); - } - }; + }, 'recipeLoadError'); const edit = appendText(actions, 'button', t('recipeEdit'), 'anomalous-btn-success'); edit.type = 'button'; - edit.onclick = () => editRecipe(this, recipe?.data || {}, recipe.filename); + edit.onclick = () => runRecipeCardAction(edit, () => editRecipe(this, recipe?.data || {}, recipe.filename), 'recipeUpdateError'); const exportButton = appendText(actions, 'button', t('recipeExport'), 'anomalous-btn-primary'); exportButton.type = 'button'; - exportButton.onclick = async () => { - try { - await exportRecipePackage(recipe.filename); - } catch (error) { - console.error('Could not export Workflow Recipe package:', error); - alert(t('recipeExportError')); - } - }; + exportButton.onclick = () => runRecipeCardAction( + exportButton, + () => exportRecipePackage(recipe.filename), + 'recipeExportError', + ); const restore = appendText(actions, 'button', t('recipeOpenCanvas'), 'anomalous-btn-primary'); restore.type = 'button'; - restore.onclick = async () => { - try { - const data = await fetchRecipeData(recipe.filename); - await openRecipeOnCanvas(this, data); - } catch (error) { - console.error('Could not restore Workflow Recipe:', error); - alert(t('recipeRestoreError')); - } - }; + restore.onclick = () => runRecipeCardAction(restore, async () => { + const data = await fetchRecipeData(recipe.filename); + await openRecipeOnCanvas(this, data); + }, 'recipeRestoreError'); const append = appendText(actions, 'button', t('recipeAppendCanvas'), 'anomalous-btn-primary'); append.type = 'button'; - append.onclick = async () => { - try { - const data = await fetchRecipeData(recipe.filename); - appendRecipeOnCanvas(this, data); - } catch (error) { - console.error('Could not append Workflow Recipe:', error); - alert(t('recipeAppendError')); - } - }; + append.onclick = () => runRecipeCardAction(append, async () => { + const data = await fetchRecipeData(recipe.filename); + if (!appendRecipeOnCanvas(this, data)) throw new Error('recipe append failed'); + }, 'recipeAppendError'); const remove = appendText(actions, 'button', t('recipeDelete'), 'anomalous-btn-danger'); remove.type = 'button'; - remove.onclick = async () => { + remove.onclick = () => runRecipeCardAction(remove, async () => { if (!confirm(t('recipeDeleteConfirm'))) return; - try { - const response = await fetch('/anomalous/delete_recipe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename: recipe.filename }), - }); - if (!response.ok) throw new Error('recipe deletion failed'); - await this.refreshRecipes(); - } catch (error) { - console.error('Could not delete Workflow Recipe:', error); - alert(t('recipeDeleteError')); - } - }; + const response = await fetch('/anomalous/delete_recipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: recipe.filename }), + }); + if (!response.ok) throw new Error('recipe deletion failed'); + await this.refreshRecipes(); + }, 'recipeDeleteError'); card.appendChild(actions); this.recipeListContainer.appendChild(card); }