diff --git a/.agents/logs/ai_changelog.md b/.agents/logs/ai_changelog.md index f5a8969..d657893 100644 --- a/.agents/logs/ai_changelog.md +++ b/.agents/logs/ai_changelog.md @@ -60,3 +60,20 @@ - `node --check web/modules/ui_notebooks.js` - `node --check web/modules/ui_sidebar.js` - `node --check web/modules/locales.js` + +## [Snapshot] 2026-08-02 — Recipe discovery and semantic comparison + +**Implemented** + +- Added lightweight recipe search across name, notes, and tags with Unicode-normalized term matching and clickable tag filters. +- Added localized empty/no-match states, active filter controls, result counts, and keyboard-capable tag buttons. +- Added `GET /anomalous/recipe_version` with validated recipe/version filenames for one bounded historical read. +- Added pure `recipe_diff.js` comparison for pinned parameters, prompts, model references, safe parameter summaries, graph counts/fingerprint, and presentation metadata. +- Added lazy version comparison UI. History restore remains a separate action and still archives the current recipe first. + +**Validation** + +- `python_embeded/python.exe -m py_compile api/__init__.py api/models.py api/recipes.py` +- `node --check` for every JavaScript file under `web/` +- `recipe_diff.js` smoke comparison passed +- `git diff --check` diff --git a/.agents/logs/ai_lessons.md b/.agents/logs/ai_lessons.md index 9ac7078..dc78c05 100644 --- a/.agents/logs/ai_lessons.md +++ b/.agents/logs/ai_lessons.md @@ -291,3 +291,9 @@ This document serves as an architectural retrospective and UX diagnostic log for **The Solution (方案)**: Give users one understandable Model Settings panel. The balanced image mode serves a cached 512px WebP only to grid cards, while original mode remains available and detail views always retain the source cover. Limit thumbnail generation concurrency, store derivatives only in a bounded temporary cache, activate autoplay only near the viewport, release media immediately on close, and release warm card state after a short idle period. Never resize or rewrite the user's cover in place. (把选择集中在一个普通用户能理解的“模型设置”面板:流畅模式只给卡片提供缓存的 512px WebP,仍允许用户选择原始封面,且详情页始终保留原图。缩略图生成限制并发,派生文件只进入有上限的临时缓存;自动播放只在视野附近激活,关闭时立即释放媒体,短暂保留卡片热状态后再自动回收。任何时候都不能原地压缩或改写用户封面。) + +## 48. Semantic Diff Must Not Become a Graph Diff (语义 Diff 不能偷偷膨胀成整图 Diff) +**The Problem (问题)**: Recipe history contains complete serialized graphs, but showing a raw JSON or visual node/link diff would expose noisy internal details, overwhelm the detail panel, and risk surfacing sensitive widget values that the normal parameter view intentionally keeps opaque. +(配方历史保存的是完整序列化图,但直接展示 JSON 或可视化节点/连线差异会制造大量噪声、压垮详情面板,还可能暴露参数页刻意隐藏的敏感控件。) +**The Solution (方案)**: Compare bounded semantic summaries only: prompts, pinned primitives, known model references, safe sampler/resolution fields, graph counts/fingerprint, and presentation metadata. Keep comparison pure and read-only; history restoration remains a separate destructive action with its own confirmation. +(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bd575b4..104fe08 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,9 +38,11 @@ Anomalous_Model_Browser/ │ ├── ui_detail.js # Model detail panel │ ├── ui_doctor.js # Model Doctor and Assistant panel │ ├── ui_notebooks.js # Notebooks editor -│ ├── ui_recipes.js # Workflow Recipe cards, save dialog, and restore actions +│ ├── ui_recipes.js # Workflow Recipe cards, search/filter, save dialog, and actions │ ├── recipe_parser.js # Read-only LiteGraph recipe metadata extraction │ ├── recipe_identity.js # Pure recipe model-reference and identity normalization +│ ├── recipe_diff.js # Pure bounded semantic version comparison +│ ├── recipe_actions.js # Transactional append and ephemeral Quick Queue actions │ ├── ui_recipe_detail.js # Demand-loaded recipe detail panel and tab rendering │ ├── ui_gallery.js # Fullscreen Gallery Viewer │ ├── ui_grid.js # Main grid and model loading @@ -104,6 +106,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). 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. +* **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. Quick Queue applies safe pinned primitive overrides to an in-memory clone and converts it through the host `app.graphToPrompt` path on a temporary LiteGraph graph before using the normal ComfyUI API; it never loads the recipe into the current canvas. * **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality. ### NON-NEGOTIABLE: Recipe Presentation & Model Preview Boundaries / 配方展示与模型预览边界 @@ -114,6 +117,13 @@ Instead of fragmenting the class scope and losing context, we extracted all UI p * Frozen preview snapshots are schema-v4, opt-in runtime behavior. Assets live in a contained recipe-owned `.assets//` directory, are immutable/content-addressed, and are served only through `GET /anomalous/recipe_asset`. Snapshot generation accepts static local preview files only, writes WebP thumbnails no larger than 320 px or 96 KiB each, is capped at 12 images and 1.25 MiB per save/update, and never copies videos or original full-resolution media. Deleting a recipe removes only its contained assets after the recipe file is deleted; retained history may share an asset ID. * Preview hashes, filenames, paths, images, and visual similarity MUST NEVER create a Verified identity badge or participate in Model Doctor matching. Workflow fingerprints also exclude covers and preview presentation. +### NON-NEGOTIABLE: Recipe Action Boundaries / 配方动作边界 +* `Open in Canvas` replaces the live graph only after an explicit dirty-canvas confirmation and never queues automatically. `Append to Canvas` clones saved nodes, remaps IDs/links, validates the complete insertion, and rolls back all inserted nodes on failure. +* Append must not call `loadGraphData` on the live graph. It must not mutate the saved recipe, existing nodes, existing links, or external model files. Unsupported/missing node types produce a visible error instead of a destructive fallback. +* `Quick Queue` edits an in-memory clone of safe pinned primitive values and converts the serialized workflow with the host `app.graphToPrompt` path on a temporary `LiteGraph.LGraph`. It queues through the normal ComfyUI API and never loads the recipe into the current canvas. +* Quick Queue is not proof of validity: missing node types, invalid values, conversion failures, and queue errors remain actionable disabled/error states. Imported recipes must not silently enable execution. +* `recipe_diff.js` compares bounded semantic records only. It must not expose newly-unfiltered sensitive widget values, render a full graph diff, instantiate saved nodes for display, or mutate either recipe. + ### Graph Splicing and Picker Context (`graph_splice.js`, `model_picker.js`) Manual LoRA insertion is an explicit canvas mutation and is isolated from Model Doctor identity recovery. The graph helper analyzes ports by their declared `MODEL` and `CLIP` types, never by fixed slot indexes or display names. It supports inserting a compatible loader before a node with connected MODEL/CLIP inputs, or after a node with MODEL/CLIP outputs. Downstream fan-out is rejected as ambiguous until the user can choose a concrete branch. diff --git a/api/__init__.py b/api/__init__.py index c302e3f..69a5fa5 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -32,6 +32,7 @@ def setup_routes(app): 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_get('/anomalous/recipe_version', api_get_recipe_version) app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version) app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity) diff --git a/api/recipes.py b/api/recipes.py index efa5093..e4ab56e 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -628,6 +628,30 @@ async def api_get_recipe_history(request): return web.json_response({"status": "success", "versions": versions}) +async def api_get_recipe_version(request): + """Return one bounded historical recipe for semantic comparison only.""" + try: + filename = require_filename(request.query.get("filename", "")) + version = require_filename(request.query.get("version", "")) + if not filename.endswith(".json") or not version.endswith(".json"): + raise ValueError("Invalid recipe version") + recipes_dir = get_recipes_dir() + await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename)) + data = await asyncio.to_thread( + _read_recipe, + resolve_within(_history_dir(recipes_dir, filename), version), + ) + if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict): + raise ValueError("Invalid recipe version") + except (ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Invalid recipe version"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "Recipe version not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not read recipe version"}, status=500) + return web.json_response({"status": "success", "data": data}) + + async def api_restore_recipe_version(request): try: payload = await request.json() diff --git a/web/modules/locales.js b/web/modules/locales.js index 0ea4e45..b2e67fa 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -102,12 +102,20 @@ export const i18n = { recipeEmpty: '还没有工作流配方。保存一个已经跑通的工作流吧。', recipeLoadError: '无法加载工作流配方。', recipeUntitled: '未命名配方', + recipeSearchPlaceholder: '搜索名称、笔记或标签…', + recipeClearFilters: '清除筛选', + recipeNoTags: '暂无标签', + recipeNoMatches: '没有匹配的配方。请清除搜索或标签筛选。', + recipeFilterByTag: '按此标签筛选', recipeModel: '模型', recipeLoras: 'LoRA', recipePrompt: '正向提示词', recipeSteps: '步数', - recipeRestore: '恢复到画布', - recipeRestoreConfirm: '恢复会替换当前画布。确定继续吗?', + recipeRestore: '恢复版本', + recipeOpenCanvas: '打开到画布', + recipeAppendCanvas: '追加到画布', + recipeOpenCanvasConfirm: '打开配方会替换当前画布。确定继续吗?', + recipeAppendError: '无法追加此配方到当前画布。', recipeRestoreError: '恢复工作流配方失败。', recipeDelete: '删除', recipeDeleteConfirm: '确定删除这个工作流配方吗?', @@ -178,6 +186,18 @@ export const i18n = { recipeDetailWidget: '控件', recipeDetailNoParameters: '没有匹配的安全参数。', recipeDetailCurrentVersion: '当前版本', + recipeCompareVersion: '对比此版本', + recipeDiffLoading: '正在比较…', + recipeDiffSummary: '变更摘要', + recipeDiffNoChanges: '两个版本没有检测到有意义的变更。', + recipeDiffError: '无法读取此历史版本。', + recipeDiffPinned: '钉选参数', + recipeDiffPrompts: '提示词', + recipeDiffModels: '模型引用', + recipeDiffParameters: '参数摘要', + recipeDiffWorkflow: '工作流结构', + recipeDiffPresentation: '展示信息', + recipeDiffOther: '其他', recipeDetailUnknownTime: '未知时间', recipeDetailLoadingPreviews: '正在加载本机模型预览…', recipeDetailCurrentPreview: '当前本机预览', @@ -185,6 +205,14 @@ export const i18n = { recipeSaveModelPreviewSnapshots: '保存模型预览快照', recipeSaveModelPreviewSnapshotsHint: '为当前版本保存小尺寸静态缩略图;不会复制视频,也不会写入工作流图。', recipeDetailSavedSnapshot: '已保存快照', + recipeQuickQueueTitle: '快速运行', + recipeQuickQueueHint: '只修改本次排队的临时参数,不会改变当前画布或已保存配方。', + recipeQuickQueueEnable: '我确认要启用此配方的快速运行', + recipeQuickQueue: '一键运行', + recipeQuickQueueRunning: '正在排队…', + recipeQuickQueueInvalid: '请检查参数值。', + recipeQuickQueueSuccess: '已加入队列', + recipeQuickQueueError: '无法运行此配方;请检查节点、模型和参数。', }, en: { @@ -290,12 +318,20 @@ export const i18n = { recipeEmpty: 'No Workflow Recipes yet. Save a workflow that is working well.', recipeLoadError: 'Could not load Workflow Recipes.', recipeUntitled: 'Untitled Recipe', + recipeSearchPlaceholder: 'Search names, notes, or tags…', + recipeClearFilters: 'Clear filters', + recipeNoTags: 'No tags yet', + recipeNoMatches: 'No recipes match. Clear the search or tag filters.', + recipeFilterByTag: 'Filter by this tag', recipeModel: 'Model', recipeLoras: 'LoRAs', recipePrompt: 'Positive prompt', recipeSteps: 'Steps', - recipeRestore: 'Restore to Canvas', - recipeRestoreConfirm: 'Restoring replaces the current canvas. Continue?', + recipeRestore: 'Restore version', + recipeOpenCanvas: 'Open in Canvas', + recipeAppendCanvas: 'Append to Canvas', + recipeOpenCanvasConfirm: 'Opening this recipe replaces the current canvas. Continue?', + recipeAppendError: 'Could not append this recipe to the current canvas.', recipeRestoreError: 'Could not restore this Workflow Recipe.', recipeDelete: 'Delete', recipeDeleteConfirm: 'Delete this Workflow Recipe?', @@ -366,6 +402,18 @@ export const i18n = { recipeDetailWidget: 'Widget', recipeDetailNoParameters: 'No safe parameters match the search.', recipeDetailCurrentVersion: 'Current version', + recipeCompareVersion: 'Compare version', + recipeDiffLoading: 'Comparing…', + recipeDiffSummary: 'Change summary', + recipeDiffNoChanges: 'No meaningful changes were detected between these versions.', + recipeDiffError: 'Could not read this historical version.', + recipeDiffPinned: 'Pinned parameters', + recipeDiffPrompts: 'Prompts', + recipeDiffModels: 'Model references', + recipeDiffParameters: 'Parameter summary', + recipeDiffWorkflow: 'Workflow structure', + recipeDiffPresentation: 'Presentation', + recipeDiffOther: 'Other', recipeDetailUnknownTime: 'Unknown time', recipeDetailLoadingPreviews: 'Loading local model previews…', recipeDetailCurrentPreview: 'Current local preview', @@ -373,5 +421,13 @@ export const i18n = { recipeSaveModelPreviewSnapshots: 'Save model preview snapshots', recipeSaveModelPreviewSnapshotsHint: 'Keeps small static thumbnails with this version. Videos and the workflow graph are not copied.', recipeDetailSavedSnapshot: 'Saved snapshot', + recipeQuickQueueTitle: 'Quick Queue', + recipeQuickQueueHint: 'Only temporary queue parameters change; the current canvas and saved recipe stay untouched.', + recipeQuickQueueEnable: 'I confirm that I want to enable Quick Queue for this recipe', + recipeQuickQueue: 'Quick Queue', + recipeQuickQueueRunning: 'Queueing…', + recipeQuickQueueInvalid: 'Check the parameter values.', + recipeQuickQueueSuccess: 'Queued', + recipeQuickQueueError: 'Could not run this recipe; check its nodes, models, and parameters.', } }; diff --git a/web/modules/recipe_actions.js b/web/modules/recipe_actions.js new file mode 100644 index 0000000..b684488 --- /dev/null +++ b/web/modules/recipe_actions.js @@ -0,0 +1,202 @@ +import { api } from '../../../scripts/api.js'; +import { app } from '../../../scripts/app.js'; +import { applyRecipeWidgetChanges } from './recipe_parser.js'; + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function nodeType(node) { + return String(node?.type || node?.class_type || '').trim(); +} + +function savedNodeRecords(workflow) { + return Array.isArray(workflow?.nodes) ? workflow.nodes.filter((node) => node && node.id !== undefined) : []; +} + +function linkRecords(workflow) { + if (Array.isArray(workflow?.links)) return workflow.links.filter(Array.isArray); + if (workflow?.links && typeof workflow.links === 'object') return Object.values(workflow.links).filter(Boolean); + return []; +} + +function graphNode(graph, id) { + return graph?.getNodeById?.(id) + || graph?._nodes?.find((node) => String(node?.id) === String(id)) + || null; +} + +function nextNodeId(graph, usedIds) { + let max = 0; + for (const node of graph?._nodes || []) max = Math.max(max, Number(node?.id) || 0); + for (const id of usedIds) max = Math.max(max, Number(id) || 0); + return max + 1; +} + +function liveBounds(graph) { + const nodes = (graph?._nodes || []).filter(Boolean); + if (!nodes.length) return null; + return nodes.reduce((bounds, node) => { + const x = Number(node.pos?.[0]) || 0; + const y = Number(node.pos?.[1]) || 0; + const width = Number(node.size?.[0]) || 220; + const height = Number(node.size?.[1]) || 120; + bounds.minX = Math.min(bounds.minX, x); + bounds.minY = Math.min(bounds.minY, y); + bounds.maxX = Math.max(bounds.maxX, x + width); + bounds.maxY = Math.max(bounds.maxY, y + height); + return bounds; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); +} + +function savedBounds(nodes) { + if (!nodes.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + return nodes.reduce((bounds, node) => { + const x = Number(node.pos?.[0]) || 0; + const y = Number(node.pos?.[1]) || 0; + const width = Number(node.size?.[0]) || 220; + const height = Number(node.size?.[1]) || 120; + bounds.minX = Math.min(bounds.minX, x); + bounds.minY = Math.min(bounds.minY, y); + bounds.maxX = Math.max(bounds.maxX, x + width); + bounds.maxY = Math.max(bounds.maxY, y + height); + return bounds; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); +} + +function instantiateNode(nodeData) { + const type = nodeType(nodeData); + const creator = globalThis.LiteGraph?.createNode; + if (typeof creator !== 'function' || !type) return null; + const node = creator.call(globalThis.LiteGraph, type); + if (!node) return null; + node.configure?.(cloneJson(nodeData)); + return node; +} + +function connectLink(link, idMap, graph) { + if (!Array.isArray(link) || link.length < 5) return false; + const origin = graphNode(graph, idMap.get(String(link[1]))); + const target = graphNode(graph, idMap.get(String(link[3]))); + if (!origin || !target) return false; + const result = origin.connect?.(Number(link[2]), target, Number(link[4])); + return Boolean(result); +} + +/** Merge one serialized workflow into the live graph as one rollback-able action. */ +export function appendRecipeToCanvas(recipe) { + const graph = app.graph; + const savedNodes = savedNodeRecords(recipe?.workflow); + const savedLinks = linkRecords(recipe?.workflow); + if (!graph || !savedNodes.length) throw new Error('recipe_append_empty'); + if (typeof globalThis.LiteGraph?.createNode !== 'function') throw new Error('recipe_append_unsupported'); + + const idMap = new Map(); + const inserted = []; + const existingIds = new Set((graph._nodes || []).map((node) => String(node?.id))); + let id = nextNodeId(graph, savedNodes.map((node) => node.id)); + const sourceBounds = savedBounds(savedNodes); + const targetBounds = liveBounds(graph); + const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX; + const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY; + + graph.beforeChange?.(); + try { + for (const nodeData of savedNodes) { + const node = instantiateNode(nodeData); + if (!node) { + const error = new Error(`Missing node type: ${nodeType(nodeData)}`); + error.code = 'recipe_append_missing_node'; + throw error; + } + while (existingIds.has(String(id))) id += 1; + const oldId = String(nodeData.id); + idMap.set(oldId, id); + node.id = id; + existingIds.add(String(id)); + const x = Number(nodeData.pos?.[0]) || 0; + const y = Number(nodeData.pos?.[1]) || 0; + node.pos = [x + offsetX, y + offsetY]; + graph.add(node); + inserted.push(node); + id += 1; + } + + for (const link of savedLinks) { + if (!connectLink(link, idMap, graph)) { + const error = new Error('Invalid link in recipe'); + error.code = 'recipe_append_invalid_link'; + throw error; + } + } + graph.change?.(); + graph.setDirtyCanvas?.(true, true); + app.canvas?.setDirty?.(true, true); + app.canvas?.selectNodes?.(inserted); + app.canvas?.selectItems?.(inserted); + return { nodes: inserted.length, links: savedLinks.length }; + } catch (error) { + for (const node of inserted) graph.remove?.(node); + graph.change?.(); + graph.setDirtyCanvas?.(true, true); + throw error; + } finally { + graph.afterChange?.(); + } +} + +function missingNodeTypes(recipe) { + const registry = globalThis.LiteGraph?.registered_node_types; + if (!registry) return []; + return [...new Set(savedNodeRecords(recipe?.workflow) + .map((node) => nodeType(node)) + .filter((type) => type && !registry[type]))]; +} + +function primitive(value) { + return value === null || ['string', 'number', 'boolean'].includes(typeof value); +} + +function validateOverrides(recipe, changes) { + const pinned = new Map((recipe?.params?.pinned || []).map((item) => [item?.key, item])); + for (const change of changes || []) { + const allowed = pinned.get(change?.key); + if (!allowed || !primitive(change.value) || !primitive(allowed.value)) throw new Error('recipe_quick_queue_invalid_override'); + if (typeof change.value !== typeof allowed.value && !(typeof change.value === 'number' && typeof allowed.value === 'number')) { + throw new Error('recipe_quick_queue_invalid_override'); + } + } +} + +/** Convert a cloned workflow through the host's normal API-prompt path. */ +export async function buildRecipePrompt(recipe, changes = []) { + const missing = missingNodeTypes(recipe); + if (missing.length) { + const error = new Error(`Missing node types: ${missing.slice(0, 12).join(', ')}`); + error.code = 'recipe_quick_queue_missing_nodes'; + throw error; + } + validateOverrides(recipe, changes); + if (typeof app.graphToPrompt !== 'function') throw new Error('recipe_quick_queue_unsupported'); + const workflow = cloneJson(recipe.workflow); + const params = cloneJson(recipe.params || {}); + applyRecipeWidgetChanges(params, workflow, changes); + const GraphClass = globalThis.LiteGraph?.LGraph || app.graph?.constructor; + if (typeof GraphClass !== 'function') throw new Error('recipe_quick_queue_unsupported'); + const temporaryGraph = new GraphClass(); + try { + temporaryGraph.configure(workflow); + const prompt = await app.graphToPrompt(temporaryGraph); + if (!prompt || typeof prompt !== 'object' || !Object.keys(prompt).length) throw new Error('recipe_quick_queue_empty_prompt'); + return prompt; + } finally { + temporaryGraph.clear?.(); + } +} + +/** Queue an ephemeral recipe prompt without loading or modifying the current canvas. */ +export async function quickQueueRecipe(recipe, changes = []) { + const prompt = await buildRecipePrompt(recipe, changes); + if (typeof api?.queuePrompt !== 'function') throw new Error('recipe_quick_queue_unsupported'); + return api.queuePrompt(0, prompt, { trigger_source: 'anomalous_recipe' }); +} diff --git a/web/modules/recipe_diff.js b/web/modules/recipe_diff.js new file mode 100644 index 0000000..e3e04dc --- /dev/null +++ b/web/modules/recipe_diff.js @@ -0,0 +1,202 @@ +/** Pure, bounded semantic comparison for saved Workflow Recipes. */ + +const MAX_VALUE_LENGTH = 320; +const MAX_CHANGES = 160; + +function cloneSafe(value) { + if (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value)) return value; + try { + const encoded = JSON.stringify(value); + if (!encoded || encoded.length > MAX_VALUE_LENGTH * 8) return '[complex value]'; + return JSON.parse(encoded); + } catch (error) { + return '[complex value]'; + } +} + +function stable(value) { + const cloned = cloneSafe(value); + try { return JSON.stringify(cloned); } catch (error) { return String(cloned); } +} + +export function formatDiffValue(value) { + if (value === null || value === undefined || value === '') return '—'; + let text; + if (typeof value === 'string') text = value; + else { + try { text = JSON.stringify(value); } catch (error) { text = String(value); } + } + text = String(text).replace(/\s+/g, ' ').trim(); + return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` : text; +} + +function itemKey(item, fallback) { + if (item?.key) return String(item.key); + const node = item?.nodeId ?? item?.node_id ?? ''; + const index = item?.widgetIndex ?? item?.widget_index ?? ''; + const name = item?.widgetName ?? item?.widget_name ?? ''; + return `${node}:${index}:${name}:${fallback}`; +} + +function addMapChanges(changes, category, before, after, getKey, getValue, getLabel) { + const left = new Map((before || []).map((item, index) => [getKey(item, index), item])); + const right = new Map((after || []).map((item, index) => [getKey(item, index), item])); + const keys = [...new Set([...left.keys(), ...right.keys()])].sort(); + for (const key of keys) { + const leftItem = left.get(key); + const rightItem = right.get(key); + const leftValue = leftItem === undefined ? undefined : getValue(leftItem); + const rightValue = rightItem === undefined ? undefined : getValue(rightItem); + const same = leftItem !== undefined && rightItem !== undefined && stable(leftValue) === stable(rightValue); + if (same) continue; + changes.push({ + kind: leftItem === undefined ? 'added' : rightItem === undefined ? 'removed' : 'changed', + category, + key, + label: getLabel(rightItem || leftItem), + before: cloneSafe(leftValue), + after: cloneSafe(rightValue), + }); + } +} + +function valueAt(recipe, path, fallback = null) { + const parts = path.split('.'); + let value = recipe; + for (const part of parts) { + if (!value || typeof value !== 'object') return fallback; + value = value[part]; + } + return value === undefined ? fallback : value; +} + +function summaryValue(recipe, key) { + const params = recipe?.params || {}; + const values = { + baseModel: params.baseModel, + steps: params.steps, + cfg: params.cfg, + sampler_name: params.sampler_name, + scheduler: params.scheduler, + denoise: params.denoise, + seed: params.seed, + resolution: params.resolution, + nodeCount: params.nodeCount, + loras: (params.loras || []).map((item) => ({ + name: item?.name, + strength_model: item?.strength_model, + strength_clip: item?.strength_clip, + })), + }; + return values[key]; +} + +function addScalarChanges(changes, category, entries) { + for (const entry of entries) { + const before = cloneSafe(entry.before); + const after = cloneSafe(entry.after); + if (stable(before) === stable(after)) continue; + changes.push({ + kind: before === undefined || before === null ? 'added' : after === undefined || after === null ? 'removed' : 'changed', + category, + key: entry.key, + label: entry.label, + before, + after, + }); + } +} + +function graphSummary(recipe) { + const graph = recipe?.workflow || {}; + return { + nodes: Array.isArray(graph.nodes) ? graph.nodes.length : null, + links: Array.isArray(graph.links) ? graph.links.length : graph.links && typeof graph.links === 'object' ? Object.keys(graph.links).length : null, + fingerprint: recipe?.workflow_fingerprint?.value || null, + }; +} + +/** Return deterministic UI-neutral changes between two recipe records. */ +export function buildRecipeDiff(beforeRecipe, afterRecipe) { + const before = beforeRecipe || {}; + const after = afterRecipe || {}; + const changes = []; + + addScalarChanges(changes, 'pinned', [ + { + key: 'pinned', + label: 'Pinned parameters', + before: (before.params?.pinned || []).map((item) => ({ + key: itemKey(item, 'pinned'), + value: item?.value, + })), + after: (after.params?.pinned || []).map((item) => ({ + key: itemKey(item, 'pinned'), + value: item?.value, + })), + }, + ]); + + addMapChanges( + changes, + 'prompts', + before.params?.promptPositive || [], + after.params?.promptPositive || [], + (_item, index) => `positive:${index}`, + (item) => item, + () => 'Positive prompt', + ); + addMapChanges( + changes, + 'prompts', + before.params?.promptNegative || [], + after.params?.promptNegative || [], + (_item, index) => `negative:${index}`, + (item) => item, + () => 'Negative prompt', + ); + addMapChanges( + changes, + 'models', + before.params?.model_references || [], + after.params?.model_references || [], + (item, index) => itemKey(item, `model:${index}`), + (item) => item?.saved_value, + (item) => `${item?.category || 'model'} · ${item?.node_title || item?.node_type || 'loader'}`, + ); + + const summaryKeys = ['baseModel', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise', 'seed', 'resolution', 'nodeCount', 'loras']; + addScalarChanges(changes, 'parameters', summaryKeys.map((key) => ({ + key, + label: key, + before: summaryValue(before, key), + after: summaryValue(after, key), + }))); + + const beforeGraph = graphSummary(before); + const afterGraph = graphSummary(after); + addScalarChanges(changes, 'workflow', [ + { key: 'fingerprint', label: 'Workflow fingerprint', before: beforeGraph.fingerprint, after: afterGraph.fingerprint }, + { key: 'node_count', label: 'Node count', before: beforeGraph.nodes, after: afterGraph.nodes }, + { key: 'link_count', label: 'Link count', before: beforeGraph.links, after: afterGraph.links }, + ]); + + addScalarChanges(changes, 'presentation', [ + { key: 'name', label: 'Recipe name', before: before.name, after: after.name }, + { key: 'notes', label: 'Notes', before: before.notes, after: after.notes }, + { key: 'tags', label: 'Tags', before: before.tags || [], after: after.tags || [] }, + { key: 'cover', label: 'Cover', before: Boolean(before.thumbnail || before.source_image), after: Boolean(after.thumbnail || after.source_image) }, + { + key: 'preview_snapshots', + label: 'Model preview snapshots', + before: (before.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length, + after: (after.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length, + }, + ]); + + return changes.slice(0, MAX_CHANGES); +} + +export function diffIsEmpty(changes) { + return !Array.isArray(changes) || changes.length === 0; +} diff --git a/web/modules/ui_recipe_detail.js b/web/modules/ui_recipe_detail.js index 4c0c7d4..f1681f4 100644 --- a/web/modules/ui_recipe_detail.js +++ b/web/modules/ui_recipe_detail.js @@ -7,6 +7,8 @@ import { recipeReferenceKey, shortHash, } from './recipe_identity.js'; +import { buildRecipeDiff, diffIsEmpty, formatDiffValue } from './recipe_diff.js'; +import { appendRecipeToCanvas, quickQueueRecipe } from './recipe_actions.js'; const t = (key) => { let lang = window.anomalous_browser_lang || 'zh'; @@ -28,6 +30,37 @@ function button(parent, label, className = '') { return element; } +function valueEqual(left, right) { + if (left === right) return true; + try { return JSON.stringify(left) === JSON.stringify(right); } catch (error) { return false; } +} + +function canvasMayContainUserWork() { + if (!app.graph?._nodes?.length) return false; + if (app.canvas?.dirty_canvas === false || app.graph?.dirty === false) return false; + return true; +} + +function openRecipeOnCanvas(recipe) { + if (canvasMayContainUserWork() && !confirm(t('recipeOpenCanvasConfirm'))) return false; + app.loadGraphData(recipe.workflow); + app.canvas?.setDirty?.(true, true); + return true; +} + +function appendRecipeOnCanvas(recipe) { + try { + appendRecipeToCanvas(recipe); + return true; + } catch (error) { + console.error('Could not append Workflow Recipe:', error); + alert(error.code === 'recipe_append_missing_node' + ? `${t('recipeAppendError')}\n${error.message}` + : t('recipeAppendError')); + return false; + } +} + function displayValue(value) { if (typeof value === 'string') return value; try { return JSON.stringify(value); } catch (error) { return String(value); } @@ -159,7 +192,81 @@ function renderStat(parent, label, value, kind = '') { parent.appendChild(stat); } -function renderOverview(content, recipe, references, finish) { +function renderQuickQueue(parent, owner, recipe) { + const section = document.createElement('section'); + section.className = 'anomalous-recipe-detail-section anomalous-recipe-quick-queue'; + appendText(section, 'h4', t('recipeQuickQueueTitle')); + appendText(section, 'p', t('recipeQuickQueueHint'), 'anomalous-recipe-detail-muted'); + if (!owner.recipeQuickQueueEnabled) owner.recipeQuickQueueEnabled = new Set(); + const enabled = owner.recipeQuickQueueEnabled.has(owner.recipeDetailFilename); + const enableLabel = document.createElement('label'); + enableLabel.className = 'anomalous-recipe-quick-queue-enable'; + const enable = document.createElement('input'); + enable.type = 'checkbox'; + enable.checked = enabled; + appendText(enableLabel, 'span', t('recipeQuickQueueEnable')); + enableLabel.prepend(enable); + section.appendChild(enableLabel); + + const form = document.createElement('div'); + form.className = 'anomalous-recipe-quick-queue-form'; + const controls = []; + for (const pin of recipe.params?.pinned || []) { + if (!['string', 'number', 'boolean'].includes(typeof pin?.value)) continue; + const row = document.createElement('label'); + row.className = 'anomalous-recipe-quick-queue-field'; + appendText(row, 'span', `${pin.nodeTitle || pin.nodeType || t('recipeUnknownNode')} · ${pin.widgetName}`); + const input = document.createElement('input'); + input.type = typeof pin.value === 'number' ? 'number' : typeof pin.value === 'boolean' ? 'checkbox' : 'text'; + if (input.type === 'checkbox') input.checked = pin.value; + else input.value = String(pin.value); + if (typeof pin.value === 'number') input.step = 'any'; + controls.push({ pin, input }); + row.appendChild(input); + form.appendChild(row); + } + const status = appendText(form, 'small', '', 'anomalous-recipe-detail-muted'); + const queue = button(form, t('recipeQuickQueue'), 'anomalous-btn-primary'); + queue.disabled = !enabled; + queue.onclick = async () => { + queue.disabled = true; + status.textContent = t('recipeQuickQueueRunning'); + const changes = []; + for (const { pin, input } of controls) { + let value; + if (input.type === 'checkbox') value = input.checked; + else if (input.type === 'number') value = Number(input.value); + else value = input.value; + if (!Number.isFinite(value) && input.type === 'number') { + status.textContent = t('recipeQuickQueueInvalid'); + queue.disabled = false; + return; + } + if (!valueEqual(value, pin.value)) changes.push({ ...pin, previousValue: pin.value, value }); + } + try { + const result = await quickQueueRecipe(recipe, changes); + status.textContent = result?.prompt_id + ? `${t('recipeQuickQueueSuccess')}: ${result.prompt_id}` + : t('recipeQuickQueueSuccess'); + } catch (error) { + console.error('Could not Quick Queue Workflow Recipe:', error); + status.textContent = t('recipeQuickQueueError'); + } finally { + queue.disabled = false; + } + }; + enable.onchange = () => { + if (enable.checked) owner.recipeQuickQueueEnabled.add(owner.recipeDetailFilename); + else owner.recipeQuickQueueEnabled.delete(owner.recipeDetailFilename); + queue.disabled = !enable.checked; + status.textContent = ''; + }; + section.appendChild(form); + parent.appendChild(section); +} + +function renderOverview(content, owner, recipe, references, finish) { const overview = document.createElement('div'); overview.className = 'anomalous-recipe-detail-overview'; const hero = document.createElement('div'); @@ -222,17 +329,17 @@ function renderOverview(content, recipe, references, finish) { actions.className = 'anomalous-recipe-actions anomalous-recipe-detail-actions'; const edit = button(actions, t('recipeEdit'), 'anomalous-btn-success'); edit.onclick = () => finish('edit'); - const load = button(actions, t('recipeRestore'), 'anomalous-btn-primary'); - load.onclick = () => { - app.loadGraphData(recipe.workflow); - app.canvas?.setDirty?.(true, true); - }; + const load = button(actions, t('recipeOpenCanvas'), 'anomalous-btn-primary'); + load.onclick = () => openRecipeOnCanvas(recipe); + const append = button(actions, t('recipeAppendCanvas'), 'anomalous-btn-primary'); + append.onclick = () => appendRecipeOnCanvas(recipe); const copyPrompt = button(actions, t('recipeDetailCopyPrompt'), 'anomalous-btn-primary'); copyPrompt.onclick = () => copyText(positive); const copyFingerprint = button(actions, t('recipeDetailCopyFingerprint'), 'anomalous-btn-primary'); copyFingerprint.disabled = !fingerprintText(recipe); copyFingerprint.onclick = () => copyText(fingerprintText(recipe)); overview.appendChild(actions); + renderQuickQueue(overview, owner, recipe); content.appendChild(overview); } @@ -368,6 +475,71 @@ function renderParameters(content, recipe) { content.appendChild(section); } +function diffCategoryLabel(category) { + return t({ + pinned: 'recipeDiffPinned', + prompts: 'recipeDiffPrompts', + models: 'recipeDiffModels', + parameters: 'recipeDiffParameters', + workflow: 'recipeDiffWorkflow', + presentation: 'recipeDiffPresentation', + }[category] || 'recipeDiffOther'); +} + +function renderDiffPanel(parent, owner, recipe, version, trigger) { + const panel = document.createElement('div'); + panel.className = 'anomalous-recipe-version-diff'; + appendText(panel, 'strong', t('recipeDiffLoading')); + parent.appendChild(panel); + trigger.disabled = true; + fetch(`/anomalous/recipe_version?filename=${encodeURIComponent(owner.recipeDetailFilename)}&version=${encodeURIComponent(version.version)}`) + .then(async (response) => { + const payload = await response.json(); + if (!response.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('version diff request failed'); + return payload.data; + }) + .then((historical) => { + panel.replaceChildren(); + const changes = buildRecipeDiff(historical, recipe); + if (diffIsEmpty(changes)) { + appendText(panel, 'p', t('recipeDiffNoChanges'), 'anomalous-recipe-detail-muted'); + return; + } + appendText(panel, 'strong', `${t('recipeDiffSummary')} (${changes.length})`); + const groups = new Map(); + for (const change of changes) { + if (!groups.has(change.category)) groups.set(change.category, []); + groups.get(change.category).push(change); + } + for (const [category, categoryChanges] of groups) { + const group = document.createElement('section'); + group.className = 'anomalous-recipe-diff-group'; + appendText(group, 'h5', diffCategoryLabel(category)); + for (const change of categoryChanges) { + const row = document.createElement('div'); + row.className = `anomalous-recipe-diff-row anomalous-recipe-diff-${change.kind}`; + const marker = change.kind === 'added' ? '+' : change.kind === 'removed' ? '−' : '→'; + appendText(row, 'span', marker, 'anomalous-recipe-diff-marker'); + appendText(row, 'strong', change.label || change.key, 'anomalous-recipe-diff-label'); + if (change.kind !== 'added') appendText(row, 'code', formatDiffValue(change.before), 'anomalous-recipe-diff-before'); + if (change.kind === 'changed') appendText(row, 'span', '→', 'anomalous-recipe-diff-arrow'); + if (change.kind !== 'removed') appendText(row, 'code', formatDiffValue(change.after), 'anomalous-recipe-diff-after'); + group.appendChild(row); + } + panel.appendChild(group); + } + }) + .catch((error) => { + console.error('Could not compare Workflow Recipe version:', error); + panel.replaceChildren(); + appendText(panel, 'p', t('recipeDiffError'), 'anomalous-recipe-dialog-error'); + }) + .finally(() => { + trigger.disabled = false; + trigger.textContent = t('recipeCompareVersion'); + }); +} + function renderVersions(content, owner, recipe, history, finish) { const section = document.createElement('section'); section.className = 'anomalous-recipe-detail-section'; @@ -388,6 +560,17 @@ function renderVersions(content, owner, recipe, history, finish) { appendText(copy, 'span', dateText(version.timestamp)); row.appendChild(copy); appendText(row, 'code', shortHash(version.workflow_fingerprint?.value) || t('recipeDetailNotIndexed')); + const compare = button(row, t('recipeCompareVersion'), 'anomalous-btn-primary'); + compare.onclick = () => { + const existing = row.querySelector('.anomalous-recipe-version-diff'); + if (existing) { + existing.remove(); + compare.textContent = t('recipeCompareVersion'); + return; + } + compare.textContent = t('recipeDiffLoading'); + renderDiffPanel(row, owner, recipe, version, compare); + }; const restore = button(row, t('recipeRestoreVersion'), 'anomalous-btn-danger'); restore.onclick = async () => { if (!confirm(t('recipeRestoreVersionConfirm'))) return; @@ -441,8 +624,10 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) { const headerActions = document.createElement('div'); const edit = button(headerActions, t('recipeEdit'), 'anomalous-btn-success'); edit.onclick = () => finish('edit'); - const load = button(headerActions, t('recipeRestore'), 'anomalous-btn-primary'); - load.onclick = () => { app.loadGraphData(recipe.workflow); app.canvas?.setDirty?.(true, true); }; + const load = button(headerActions, t('recipeOpenCanvas'), 'anomalous-btn-primary'); + load.onclick = () => openRecipeOnCanvas(recipe); + const append = button(headerActions, t('recipeAppendCanvas'), 'anomalous-btn-primary'); + append.onclick = () => appendRecipeOnCanvas(recipe); header.appendChild(headerActions); view.appendChild(header); @@ -451,7 +636,7 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) { const content = document.createElement('div'); content.className = 'anomalous-recipe-detail-content'; const tabDefinitions = [ - ['overview', t('recipeDetailOverview'), () => renderOverview(content, recipe, references, finish)], + ['overview', t('recipeDetailOverview'), () => renderOverview(content, owner, recipe, references, finish)], ['models', t('recipeDetailModels'), () => renderModels(content, owner, recipe, references)], ['parameters', t('recipeDetailParameters'), () => renderParameters(content, recipe)], ['versions', t('recipeDetailVersions'), () => renderVersions(content, owner, recipe, history, finish)], diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index 462e3a7..be11909 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -64,6 +64,44 @@ function compactText(value, limit = 110) { return text.length > limit ? `${text.slice(0, limit - 1)}…` : text; } +function normaliseSearchText(value) { + const text = String(value || '').trim().toLocaleLowerCase(); + try { return text.normalize('NFKC'); } catch (error) { return text; } +} + +function recipeMatchesFilter(data, query, selectedTags) { + const terms = normaliseSearchText(query).split(/\s+/).filter(Boolean); + const haystack = normaliseSearchText([ + data?.name || '', + data?.notes || '', + ...(Array.isArray(data?.tags) ? data.tags : []), + ].join(' ')); + if (terms.some((term) => !haystack.includes(term))) return false; + const tags = new Set((Array.isArray(data?.tags) ? data.tags : []).map(normaliseSearchText)); + for (const tag of selectedTags || []) if (!tags.has(normaliseSearchText(tag))) return false; + return true; +} + +function updateRecipeFilterControls(owner, recipes) { + if (!owner.recipeTagBar) return; + owner.recipeTagBar.replaceChildren(); + const tags = [...new Set((recipes || []).flatMap((item) => item?.data?.tags || []))] + .filter(Boolean) + .sort((left, right) => String(left).localeCompare(String(right))); + for (const tag of tags) { + const chip = appendText(owner.recipeTagBar, 'button', tag, 'anomalous-recipe-filter-tag'); + chip.type = 'button'; + chip.classList.toggle('active', owner.recipeSelectedTags?.has(tag)); + chip.onclick = () => { + if (!owner.recipeSelectedTags) owner.recipeSelectedTags = new Set(); + if (owner.recipeSelectedTags.has(tag)) owner.recipeSelectedTags.delete(tag); + else owner.recipeSelectedTags.add(tag); + owner.renderRecipeList(owner.recipeRecords || []); + }; + } + if (!tags.length) appendText(owner.recipeTagBar, 'small', t('recipeNoTags'), 'anomalous-recipe-detail-muted'); +} + function summaryValue(value, fallback = '—') { return value === null || value === undefined || value === '' ? fallback : String(value); } @@ -221,7 +259,7 @@ async function editRecipe(owner, recipe, filename, history = null) { app.loadGraphData(editable.workflow); app.canvas?.setDirty?.(true, true); owner.recipeEditing = { filename, data: editable }; - const saveButton = owner.recipeView?.querySelector('.anomalous-recipe-actionbar button'); + const saveButton = owner.recipeView?.querySelector('[data-recipe-save-current]'); if (saveButton) saveButton.textContent = t('recipeUpdateCurrent'); return; } @@ -703,12 +741,43 @@ export async function showRecipes() { return; } this.recipesInitialized = true; + this.recipeSelectedTags = this.recipeSelectedTags || new Set(); + this.recipeSearchQuery = this.recipeSearchQuery || ''; this.recipeView = document.createElement('div'); this.recipeView.className = 'anomalous-recipe-body'; const actionBar = document.createElement('div'); actionBar.className = 'anomalous-recipe-actionbar'; + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'anomalous-recipe-search'; + search.placeholder = t('recipeSearchPlaceholder'); + search.value = this.recipeSearchQuery || ''; + search.oninput = () => { + this.recipeSearchQuery = search.value; + this.renderRecipeList(this.recipeRecords || []); + }; + this.recipeSearchInput = search; + actionBar.appendChild(search); + + const clearFilters = appendText(actionBar, 'button', t('recipeClearFilters'), 'anomalous-btn-danger'); + clearFilters.type = 'button'; + clearFilters.onclick = () => { + this.recipeSearchQuery = ''; + this.recipeSelectedTags = new Set(); + search.value = ''; + updateRecipeFilterControls(this, this.recipeRecords || []); + this.renderRecipeList(this.recipeRecords || []); + }; + + this.recipeTagBar = document.createElement('div'); + this.recipeTagBar.className = 'anomalous-recipe-filter-tags'; + actionBar.appendChild(this.recipeTagBar); + + this.recipeFilterSummary = appendText(actionBar, 'small', '0/0', 'anomalous-recipe-filter-summary'); + const save = appendText(actionBar, 'button', t('recipeSaveCurrent'), 'anomalous-btn-primary'); + save.dataset.recipeSaveCurrent = 'true'; save.type = 'button'; save.onclick = () => this.handleSaveRecipe(); this.recipeView.appendChild(actionBar); @@ -726,7 +795,9 @@ export async function refreshRecipes() { const response = await fetch('/anomalous/recipes'); if (!response.ok) throw new Error('recipe list request failed'); const payload = await response.json(); - this.renderRecipeList(payload.recipes || []); + this.recipeRecords = payload.recipes || []; + updateRecipeFilterControls(this, this.recipeRecords); + this.renderRecipeList(this.recipeRecords); } catch (error) { console.error('Could not load Workflow Recipes:', error); this.recipeListContainer.replaceChildren(); @@ -736,12 +807,23 @@ export async function refreshRecipes() { export function renderRecipeList(recipes) { this.recipeListContainer.replaceChildren(); - if (!recipes.length) { + const records = Array.isArray(recipes) ? recipes : []; + const selectedTags = this.recipeSelectedTags || new Set(); + const filtered = records.filter((recipe) => recipeMatchesFilter( + recipe?.data || {}, + this.recipeSearchQuery || '', + selectedTags, + )); + if (this.recipeFilterSummary) this.recipeFilterSummary.textContent = `${filtered.length}/${records.length}`; + if (!records.length) { appendText(this.recipeListContainer, 'p', t('recipeEmpty'), 'anomalous-recipe-empty'); return; } - - for (const recipe of recipes) { + if (!filtered.length) { + appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty'); + return; + } + for (const recipe of filtered) { const data = recipe?.data || {}; const card = document.createElement('article'); card.className = 'anomalous-recipe-card'; @@ -760,7 +842,18 @@ export function renderRecipeList(recipes) { if (Array.isArray(data.tags) && data.tags.length) { const tags = document.createElement('div'); tags.className = 'anomalous-recipe-tags'; - for (const tag of data.tags.slice(0, 8)) tags.appendChild(createBadge(compactText(tag, 32), 'tag')); + for (const tag of data.tags.slice(0, 8)) { + const tagButton = appendText(tags, 'button', compactText(tag, 32), 'anomalous-recipe-badge anomalous-recipe-badge-tag'); + tagButton.type = 'button'; + tagButton.title = t('recipeFilterByTag'); + tagButton.onclick = (event) => { + event.stopPropagation(); + if (!this.recipeSelectedTags) this.recipeSelectedTags = new Set(); + this.recipeSelectedTags.add(tag); + this.renderRecipeList(this.recipeRecords || []); + updateRecipeFilterControls(this, this.recipeRecords || []); + }; + } card.appendChild(tags); } if (data.notes) appendText(card, 'p', compactText(data.notes, 180), 'anomalous-recipe-notes'); @@ -786,7 +879,7 @@ export function renderRecipeList(recipes) { const edit = appendText(actions, 'button', t('recipeEdit'), 'anomalous-btn-success'); edit.type = 'button'; edit.onclick = () => editRecipe(this, recipe?.data || {}, recipe.filename); - const restore = appendText(actions, 'button', t('recipeRestore'), 'anomalous-btn-primary'); + const restore = appendText(actions, 'button', t('recipeOpenCanvas'), 'anomalous-btn-primary'); restore.type = 'button'; restore.onclick = async () => { try { @@ -801,7 +894,7 @@ export function renderRecipeList(recipes) { const missingWarning = missingTypes.length ? `\n\n${t('recipeMissingNodes')}:\n${missingTypes.slice(0, 12).join('\n')}` : ''; - if (!confirm(`${t('recipeRestoreConfirm')}${missingWarning}`)) return; + if (!confirm(`${t('recipeOpenCanvasConfirm')}${missingWarning}`)) return; app.loadGraphData(payload.data.workflow); app.canvas?.setDirty?.(true, true); } catch (error) { @@ -843,7 +936,7 @@ export async function handleSaveRecipe() { const details = await showRecipeSaveDialog(this, canvasThumbnail, parameterChoices, editing?.data || null); if (!details) return; - const saveButton = this.recipeView?.querySelector('.anomalous-recipe-actionbar button'); + const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]'); if (saveButton) { saveButton.disabled = true; saveButton.textContent = t('recipeSaving'); diff --git a/web/styles.css b/web/styles.css index 94f9b3d..01e1a69 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1886,3 +1886,128 @@ max-width: none; } } + +/* Recipe library discovery and semantic version diff. */ +.anomalous-recipe-actionbar { + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.anomalous-recipe-search { + min-width: 220px; + flex: 1 1 240px; +} + +.anomalous-recipe-filter-tags { + display: flex; + flex: 1 1 100%; + flex-wrap: wrap; + gap: 5px; + align-items: center; +} + +.anomalous-recipe-filter-tag { + border: 1px solid rgba(138, 180, 248, 0.42); + border-radius: 999px; + background: rgba(138, 180, 248, 0.08); + color: #cfe1ff; + padding: 3px 9px; + cursor: pointer; +} + +.anomalous-recipe-filter-tag:hover, +.anomalous-recipe-filter-tag.active { + background: rgba(138, 180, 248, 0.28); + color: #fff; +} + +.anomalous-recipe-filter-summary { + color: #9caec4; + white-space: nowrap; +} + +.anomalous-recipe-badge-tag { + border: 0; + cursor: pointer; +} + +.anomalous-recipe-version-diff { + flex: 1 1 100%; + margin-top: 8px; + padding: 9px; + border-left: 2px solid rgba(138, 180, 248, 0.42); + background: rgba(0, 0, 0, 0.14); +} + +.anomalous-recipe-diff-group { + margin-top: 8px; +} + +.anomalous-recipe-diff-group h5 { + margin: 4px 0; + color: #cfe1ff; +} + +.anomalous-recipe-diff-row { + display: flex; + align-items: baseline; + gap: 7px; + padding: 3px 0; + min-width: 0; +} + +.anomalous-recipe-diff-marker { + width: 16px; + text-align: center; + font-weight: 700; +} + +.anomalous-recipe-diff-added .anomalous-recipe-diff-marker, +.anomalous-recipe-diff-added .anomalous-recipe-diff-after { color: #6ee7b7; } +.anomalous-recipe-diff-removed .anomalous-recipe-diff-marker, +.anomalous-recipe-diff-removed .anomalous-recipe-diff-before { color: #ff9a9a; } +.anomalous-recipe-diff-changed .anomalous-recipe-diff-marker { color: #ffd493; } + +.anomalous-recipe-diff-label { + min-width: 130px; +} + +.anomalous-recipe-diff-before, +.anomalous-recipe-diff-after { + max-width: min(36vw, 420px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 720px) { + .anomalous-recipe-diff-row { flex-wrap: wrap; } + .anomalous-recipe-diff-label { min-width: 100px; } + .anomalous-recipe-diff-before, + .anomalous-recipe-diff-after { max-width: 100%; } +} + +.anomalous-recipe-quick-queue-enable { + display: flex; + align-items: center; + gap: 7px; + color: #ffd493; +} + +.anomalous-recipe-quick-queue-form { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.anomalous-recipe-quick-queue-field { + display: grid; + grid-template-columns: minmax(140px, 0.6fr) minmax(160px, 1fr); + align-items: center; + gap: 8px; +} + +.anomalous-recipe-quick-queue-field input { + min-width: 0; +}