Add workflow recipe detail foundation

This commit is contained in:
DemonGatanjieu
2026-08-02 19:09:37 +08:00
parent 2d0eebcf77
commit a9d767e72f
9 changed files with 1138 additions and 47 deletions
+25 -3
View File
@@ -36,6 +36,8 @@ Anomalous_Model_Browser/
│ ├── ui_notebooks.js # Notebooks editor
│ ├── ui_recipes.js # Workflow Recipe cards, save dialog, and restore actions
│ ├── recipe_parser.js # Read-only LiteGraph recipe metadata extraction
│ ├── recipe_identity.js # Pure recipe model-reference and identity normalization
│ ├── 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
│ ├── graph_splice.js # Transactional MODEL/CLIP chain insertion and rollback
@@ -59,7 +61,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.
9. **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.
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.
### 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.*`.
@@ -96,10 +98,18 @@ Instead of fragmenting the class scope and losing context, we extracted all UI p
* **Grid (`anomalous-grid`)**: The main model display area. Emptied (`innerHTML = ''`) and repopulated on every folder click.
* **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked.
* **Gallery Viewer (`ui_gallery.js`)**: A fullscreen modal for viewing images.
* **Notebook (`ui_notebooks.js`)**: A specialized editor for composing prompts and drag-dropping Lora/Model nodes.
* **Workflow Recipes (`ui_recipes.js`)**: A second section inside the existing Notebook modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). The save dialog may pin any safe JSON-serializable widget as a prominent key parameter and bind no image, the current canvas, or a recent generated output. The edit dialog can update recipe metadata, pins, and safe primitive widget values without loading a graph; it applies changes only to the corresponding serialized widget slots and leaves complex/sensitive values opaque. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions) under the user recipe directory, never in the extension repository. `recipe_parser.js` never mutates the live canvas; an unfamiliar custom node may have a less detailed card but must never block saving. Restoring a graph or historical version requires explicit confirmation because it replaces user state.
* **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. The shared user-facing surface is being renamed from **Notebook** to **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This first-pass rename is presentation-only: notebook routes, storage, and internal property names remain 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 adds a canonical SHA-256 workflow fingerprint and explicit model-reference identity records. 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. Its availability refresh accepts only bounded exact references, never launches a recursive model scan, and never computes a full-file hash. The save dialog may pin any safe JSON-serializable widget as a prominent key parameter and bind no image, the current canvas, or a recent generated output. The edit dialog can update recipe metadata, pins, and safe primitive widget values without loading a graph; it applies changes only to 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.
* **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 / 配方展示与模型预览边界
* Recipe cover, current local model preview, and optional frozen model-preview snapshot are three different concepts. None is model identity evidence.
* The authoritative `workflow` field contains only the serialized ComfyUI graph. Recipe covers, preview descriptors, and future snapshot asset IDs belong to additive recipe presentation data; never inject preview bytes or local preview URLs into the graph.
* Recipe cards perform no per-model preview lookup. Current previews are demand-loaded only after the Models & reproducibility tab opens and must use a bounded exact-reference/category lookup. Detail opening must not recursively walk model folders.
* Do not persist preview URLs as truth: they are current-machine cache locators and may change after rename or cover replacement. Display precedence is future frozen snapshot, then current local preview, then category placeholder.
* Frozen preview snapshots are a schema-v4 proposal, not current runtime behavior. If implemented, assets live in a contained recipe-owned `.assets/<recipe-stem>/` directory, are immutable and bounded, never copy original full-resolution media or videos, and are removed only through recipe-owned lifecycle logic. Historical versions 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.
### 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.
@@ -169,3 +179,15 @@ Scans all nodes in `app.graph._nodes` that are colored red.
* **Data Scaling & Payload Limits**: When passing arrays/lists (e.g. hundreds of selected files), NEVER append them to URL query parameters (`GET` requests), or you will trigger `414 URI Too Long`. Use `POST` with a `JSON Body`. Similarly, NEVER pass massive arrays directly to `subprocess.run` via CLI arguments, as it will crash silently due to OS character limits (8191 on Windows). Always dump massive data to an intermediate `.json` file for the subprocess to read.
> For a complete list of past mistakes and detailed post-mortems, refer to `error_and_experience_summary.md` and `.agents/logs/ai_lessons.md` in the root directory.
## 7. Mandatory Change Snapshot Protocol (强制变更快照流程)
Every product-code change in this plugin must finish as one coherent local Git snapshot:
1. Run checks proportional to the touched behavior before committing.
2. Update `CHANGELOG.md` in the same change with the visible behavior, fix, or internal safety/performance boundary.
3. Update this `ARCHITECTURE.md` in the same change so directory ownership, data flow, API contracts, naming boundaries, and non-negotiable rules remain accurate. If the code does not alter an architectural boundary, record that the existing boundary is intentionally unchanged rather than inventing a new abstraction.
4. Create a local Git commit after verification. Keep unrelated work out of the snapshot and do not push unless the user explicitly requests it.
5. Leave a clean worktree for the next agent, or document every intentional uncommitted file in the handoff.
Planning-only documentation may be committed separately, but proposals must be labeled clearly and must not be described as implemented runtime behavior.
+15 -3
View File
@@ -1,6 +1,18 @@
# 📈 Anomalous Model Browser Changelog
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
# 📈 Anomalous Model Browser Changelog
## Unreleased
### Workflow Recipe details
- Added the demand-loaded Workflow Recipe detail panel with Overview, Models & reproducibility, Parameters, and Versions tabs while keeping recipe cards compact.
- Added schema-v3 canonical workflow fingerprints, explicit model-reference identity states, bounded exact-reference local availability refresh, parameter search, and history fingerprint summaries.
- Preserved the identity boundary: filenames, paths, previews, and fuzzy matches do not become cryptographic model identity evidence; recipe browsing performs no full-model hashing or recursive availability scan.
### Design handoff
- Chose **Workspace / 创作工作台** as the future user-facing umbrella name, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as tabs. Existing notebook API, storage, and internal names remain unchanged in the first rename pass.
- Defined model-preview behavior for recipes: demand-loaded current previews first, plus a future explicit and bounded frozen-snapshot option stored as recipe-owned assets rather than preview bytes or URLs inside the authoritative workflow graph.
- Added the mandatory development protocol: every future product-code snapshot updates this changelog and `ARCHITECTURE.md`, runs relevant checks, and ends in a coherent local Git commit.
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
### 🚀 Major Features
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
+1
View File
@@ -32,6 +32,7 @@ def setup_routes(app):
app.router.add_post('/anomalous/delete_recipe', api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', api_get_recipe_history)
app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity)
app.router.add_post('/anomalous/translate', api_translate)
app.router.add_get('/anomalous/base_models', api_base_models)
+192 -3
View File
@@ -5,8 +5,10 @@ extension repository, so personal prompts and graphs are never Git content.
"""
import asyncio
import hashlib
import json
import os
import re
import shutil
import tempfile
import time
@@ -15,6 +17,7 @@ import uuid
from aiohttp import web
import folder_paths
from .metadata import get_metadata
from .utils import require_filename, resolve_within
@@ -31,6 +34,153 @@ SAFE_THUMBNAIL_PREFIXES = (
"data:image/png;base64,",
"data:image/webp;base64,",
)
MODEL_FILE_SUFFIXES = (".safetensors", ".ckpt", ".pt", ".bin", ".sft")
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
def _workflow_fingerprint(workflow):
"""Hash only the canonical serialized graph, never recipe presentation data."""
canonical = json.dumps(
workflow,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _node_type(node):
return str(node.get("type") or node.get("class_type") or "").strip()
def _node_title(node):
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
return str(meta.get("title") or node.get("title") or _node_type(node) or "Unknown node").strip()
def _widget_values(node):
values = node.get("widgets_values")
return values if isinstance(values, list) else []
def _model_reference_specs(node):
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
node_type = _node_type(node)
lowered = node_type.lower()
specs = []
if re.search(r"checkpointloader(simple)?$", lowered):
specs.append((0, "checkpoint", "checkpoint"))
elif lowered.endswith("unetloader"):
specs.append((0, "unet", "unet"))
elif re.search(r"loraloader", lowered):
specs.append((0, "lora", "lora"))
elif lowered.endswith("vaeloader"):
specs.append((0, "vae", "vae"))
elif lowered.endswith("clipvisionloader"):
specs.append((0, "clip_vision", "clip_vision"))
elif lowered.endswith("controlnetloader"):
specs.append((0, "controlnet", "controlnet"))
elif re.search(r"(?:^|[^a-z])(?:dual|triple)?cliploader$", lowered):
specs.append((0, "text_encoder", "clip"))
if "dualclip" in lowered or "tripleclip" in lowered:
specs.append((1, "text_encoder", "clip"))
if "tripleclip" in lowered:
specs.append((2, "text_encoder", "clip"))
return specs
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def _identity_for_reference(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}
identity = {"status": "unverified", "provenance": "local cached metadata"}
try:
identity["size"] = os.path.getsize(resolved["path"])
except OSError:
pass
try:
metadata = get_metadata(resolved["path"])
candidate_hash = str(metadata.get("hash") or "").strip()
if SHA256_PATTERN.fullmatch(candidate_hash):
identity["status"] = "verified"
identity["sha256"] = candidate_hash.lower()
except Exception:
pass
return identity
def _build_model_references(recipe):
workflow = recipe.get("workflow") if isinstance(recipe, dict) else None
params = recipe.get("params") if isinstance(recipe, dict) else None
base_model = params.get("baseModel") if isinstance(params, dict) else None
references = []
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
if not isinstance(node, dict):
continue
values = _widget_values(node)
for widget_index, category, widget_name in _model_reference_specs(node):
saved_value = values[widget_index] if widget_index < len(values) else None
if not isinstance(saved_value, str) or not saved_value.strip():
continue
references.append({
"node_id": node.get("id"),
"node_type": _node_type(node) or "Unknown",
"node_title": _node_title(node),
"widget_index": widget_index,
"widget_name": widget_name,
"saved_value": saved_value,
"category": category,
"base_model": base_model,
"identity": _identity_for_reference(saved_value),
})
return references
def _enrich_recipe(recipe):
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
params = dict(recipe.get("params") or {})
params["model_references"] = _build_model_references(recipe)
recipe["params"] = params
recipe["schema_version"] = 3
return recipe
def get_recipes_dir():
@@ -211,6 +361,12 @@ def _list_recipe_history(recipes_dir, filename):
"version": version,
"timestamp": data.get("timestamp", 0),
"name": data.get("name", ""),
"workflow_fingerprint": data.get("workflow_fingerprint"),
"model_reference_count": len(
(data.get("params") or {}).get("model_references", [])
if isinstance(data.get("params"), dict)
else []
),
})
except (OSError, ValueError, json.JSONDecodeError):
continue
@@ -226,8 +382,7 @@ def _updated_recipe(payload, existing):
if isinstance(created_timestamp, int):
recipe["created_timestamp"] = created_timestamp
recipe["updated_timestamp"] = recipe["timestamp"]
recipe["schema_version"] = 2
return recipe
return _enrich_recipe(recipe)
def _delete_recipe_with_history(recipes_dir, filename):
@@ -254,7 +409,7 @@ async def api_save_recipe(request):
recipe["created_timestamp"] = recipe["timestamp"]
recipe["updated_timestamp"] = recipe["timestamp"]
recipe["schema_version"] = 2
_enrich_recipe(recipe)
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
try:
recipes_dir = get_recipes_dir()
@@ -384,3 +539,37 @@ async def api_restore_recipe_version(request):
except OSError:
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
return web.json_response({"status": "success", "filename": filename})
async def api_refresh_recipe_identity(request):
"""Check exact saved model references using cached metadata only.
This deliberately does not walk model folders and never computes a full-file
hash. The response is transient current-machine availability, separate from
the historical identity stored in the recipe.
"""
try:
payload = await request.json()
references = payload.get("references", [])
if not isinstance(references, list):
raise ValueError("Invalid references")
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid references"}, status=400)
results = []
for reference in references[:128]:
if not isinstance(reference, dict):
continue
saved_value = reference.get("saved_value")
resolved = _resolve_exact_model_reference(saved_value)
result = {
"node_id": reference.get("node_id"),
"widget_index": reference.get("widget_index"),
"saved_value": saved_value,
"availability": "available" if resolved else "missing",
}
if resolved:
result["local_path"] = resolved["path"]
result["identity"] = _identity_for_reference(saved_value)
results.append(result)
return web.json_response({"status": "success", "results": results})
+74
View File
@@ -139,6 +139,43 @@ export const i18n = {
recipeUpdate: '保存更新',
recipeUpdateCurrent: '更新当前配方',
recipeUpdateError: '更新工作流配方失败。',
recipeViewDetails: '查看详情',
recipeDetailBack: '返回配方列表',
recipeDetailOverview: '概览',
recipeDetailModels: '模型与复现',
recipeDetailParameters: '参数',
recipeDetailVersions: '版本',
recipeDetailSummary: '摘要',
recipeDetailIdentity: '已验证身份',
recipeDetailUnverified: '未验证模型',
recipeDetailMissingNodes: '缺失节点类型',
recipeDetailFingerprint: '工作流指纹',
recipeDetailNotIndexed: '尚未索引',
recipeDetailLoraSummary: 'LoRA',
recipeDetailSampler: '采样器',
recipeDetailResolution: '分辨率',
recipeDetailUnavailable: '不可用',
recipeDetailNoNotes: '没有备注。',
recipeDetailUpdated: '更新时间',
recipeDetailCopyPrompt: '复制提示词',
recipeDetailCopyFingerprint: '复制工作流指纹',
recipeDetailCopyHash: '复制完整哈希',
recipeIdentityVerified: '身份已验证',
recipeIdentityUnverified: '已知本地文件,身份未验证',
recipeIdentityUnavailable: '不可用',
recipeDetailRefreshAvailability: '检查本机可用性',
recipeDetailRefreshing: '检查中…',
recipeDetailRefreshError: '检查本机可用性失败',
recipeDetailAvailable: '本机可用',
recipeDetailMissing: '本机缺失',
recipeDetailAvailabilityNotChecked: '尚未检查本机',
recipeDetailNoModelReferences: '没有识别到受支持的模型加载器。其他自定义控件保留在参数和完整工作流中。',
recipeDetailModel: '模型',
recipeDetailSearchParameters: '按节点、类型或控件名称搜索…',
recipeDetailWidget: '控件',
recipeDetailNoParameters: '没有匹配的安全参数。',
recipeDetailCurrentVersion: '当前版本',
recipeDetailUnknownTime: '未知时间',
},
en: {
@@ -281,5 +318,42 @@ export const i18n = {
recipeUpdate: 'Save updates',
recipeUpdateCurrent: 'Update Current Recipe',
recipeUpdateError: 'Could not update this Workflow Recipe.',
recipeViewDetails: 'View details',
recipeDetailBack: 'Back to recipes',
recipeDetailOverview: 'Overview',
recipeDetailModels: 'Models & reproducibility',
recipeDetailParameters: 'Parameters',
recipeDetailVersions: 'Versions',
recipeDetailSummary: 'Summary',
recipeDetailIdentity: 'Verified identities',
recipeDetailUnverified: 'Unverified models',
recipeDetailMissingNodes: 'Missing node types',
recipeDetailFingerprint: 'Workflow fingerprint',
recipeDetailNotIndexed: 'Not indexed yet',
recipeDetailLoraSummary: 'LoRA',
recipeDetailSampler: 'Sampler',
recipeDetailResolution: 'Resolution',
recipeDetailUnavailable: 'Unavailable',
recipeDetailNoNotes: 'No notes.',
recipeDetailUpdated: 'Updated',
recipeDetailCopyPrompt: 'Copy prompt',
recipeDetailCopyFingerprint: 'Copy workflow fingerprint',
recipeDetailCopyHash: 'Copy full hash',
recipeIdentityVerified: 'Verified identity',
recipeIdentityUnverified: 'Known locally, identity unverified',
recipeIdentityUnavailable: 'Unavailable',
recipeDetailRefreshAvailability: 'Check local availability',
recipeDetailRefreshing: 'Checking…',
recipeDetailRefreshError: 'Could not check local availability.',
recipeDetailAvailable: 'Available locally',
recipeDetailMissing: 'Missing locally',
recipeDetailAvailabilityNotChecked: 'Not checked locally',
recipeDetailNoModelReferences: 'No supported model loaders were detected. Other custom widgets remain in Parameters and the full workflow.',
recipeDetailModel: 'Model',
recipeDetailSearchParameters: 'Search node, type, or widget name…',
recipeDetailWidget: 'Widget',
recipeDetailNoParameters: 'No safe parameters match the search.',
recipeDetailCurrentVersion: 'Current version',
recipeDetailUnknownTime: 'Unknown time',
}
};
+91
View File
@@ -0,0 +1,91 @@
/** Pure helpers for recipe model references and provenance labels. */
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
function nodeType(node) {
return String(node?.type || node?.class_type || '').trim();
}
function nodeTitle(node) {
return String(node?._meta?.title || node?.title || nodeType(node) || 'Unknown node').trim();
}
function modelSpecs(type) {
const lowered = type.toLowerCase();
if (/checkpointloader(simple)?$/.test(lowered)) return [[0, 'checkpoint', 'checkpoint']];
if (lowered.endsWith('unetloader')) return [[0, 'unet', 'unet']];
if (/loraloader/.test(lowered)) return [[0, 'lora', 'lora']];
if (lowered.endsWith('vaeloader')) return [[0, 'vae', 'vae']];
if (lowered.endsWith('clipvisionloader')) return [[0, 'clip_vision', 'clip_vision']];
if (lowered.endsWith('controlnetloader')) return [[0, 'controlnet', 'controlnet']];
if (/(^|[^a-z])(?:dual|triple)?cliploader$/.test(lowered)) {
const specs = [[0, 'text_encoder', 'clip']];
if (lowered.includes('dualclip') || lowered.includes('tripleclip')) specs.push([1, 'text_encoder', 'clip']);
if (lowered.includes('tripleclip')) specs.push([2, 'text_encoder', 'clip']);
return specs;
}
return [];
}
function statusFor(identity) {
const status = identity?.status;
return ['verified', 'unverified', 'unavailable'].includes(status) ? status : 'unavailable';
}
export function normaliseIdentity(identity) {
const result = { ...(identity || {}) };
result.status = statusFor(identity);
if (typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(result.sha256)) delete result.sha256;
if (!Number.isFinite(Number(result.size))) delete result.size;
return result;
}
export function deriveRecipeModelReferences(recipe) {
const stored = recipe?.params?.model_references;
if (Array.isArray(stored) && stored.length) {
return stored.map((reference) => ({
...reference,
identity: normaliseIdentity(reference.identity),
}));
}
const references = [];
for (const node of recipe?.workflow?.nodes || []) {
const type = nodeType(node);
const values = Array.isArray(node?.widgets_values) ? node.widgets_values : [];
for (const [widgetIndex, category, widgetName] of modelSpecs(type)) {
const savedValue = values[widgetIndex];
if (typeof savedValue !== 'string' || !savedValue.trim()) continue;
references.push({
node_id: node?.id ?? null,
node_type: type || 'Unknown',
node_title: nodeTitle(node),
widget_index: widgetIndex,
widget_name: widgetName,
saved_value: savedValue,
category,
base_model: recipe?.params?.baseModel || null,
identity: { status: 'unverified' },
});
}
}
return references;
}
export function shortHash(value) {
if (typeof value !== 'string' || !value) return '';
return value.length > 16 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value;
}
export function formatIdentitySize(value) {
const size = Number(value);
if (!Number.isFinite(size) || size < 0) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function isModelReference(reference) {
return Boolean(reference?.saved_value && MODEL_FILE_PATTERN.test(reference.saved_value));
}
+379
View File
@@ -0,0 +1,379 @@
import { app } from '../../../scripts/app.js';
import { i18n } from './locales.js';
import {
deriveRecipeModelReferences,
formatIdentitySize,
normaliseIdentity,
shortHash,
} from './recipe_identity.js';
const t = (key) => {
let lang = window.anomalous_browser_lang || 'zh';
if (lang.startsWith('en')) lang = 'en';
return i18n[lang]?.[key] || i18n.en?.[key] || key;
};
function appendText(parent, tagName, text, className = '') {
const element = document.createElement(tagName);
if (className) element.className = className;
element.textContent = text == null ? '' : String(text);
parent.appendChild(element);
return element;
}
function button(parent, label, className = '') {
const element = appendText(parent, 'button', label, className);
element.type = 'button';
return element;
}
function displayValue(value) {
if (typeof value === 'string') return value;
try { return JSON.stringify(value); } catch (error) { return String(value); }
}
function compact(value, limit = 180) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > limit ? `${text.slice(0, limit - 1)}...` : text;
}
function dateText(value) {
if (!value) return t('recipeDetailUnknownTime');
try { return new Date(Number(value)).toLocaleString(); } catch (error) { return t('recipeDetailUnknownTime'); }
}
async function copyText(value) {
if (!value) return;
try { await navigator.clipboard.writeText(String(value)); } catch (error) { console.warn('Could not copy recipe detail value:', error); }
}
function identityBadge(reference) {
const identity = normaliseIdentity(reference?.identity);
const badge = document.createElement('span');
badge.className = `anomalous-recipe-identity-badge anomalous-recipe-identity-${identity.status}`;
badge.textContent = t(`recipeIdentity${identity.status[0].toUpperCase()}${identity.status.slice(1)}`);
return badge;
}
function fingerprintText(recipe) {
return recipe?.workflow_fingerprint?.value || '';
}
function missingNodeTypes(recipe) {
const registry = globalThis.LiteGraph?.registered_node_types;
if (!registry) return [];
return [...new Set((recipe?.params?.nodes || [])
.map((node) => node?.type)
.filter((type) => type && !registry[type]))];
}
function renderStat(parent, label, value, kind = '') {
const stat = document.createElement('div');
stat.className = 'anomalous-recipe-detail-stat';
appendText(stat, 'span', label, 'anomalous-recipe-detail-stat-label');
appendText(stat, 'strong', value, kind ? `anomalous-recipe-detail-stat-${kind}` : '');
parent.appendChild(stat);
}
function renderOverview(content, recipe, references, finish) {
const overview = document.createElement('div');
overview.className = 'anomalous-recipe-detail-overview';
const hero = document.createElement('div');
hero.className = 'anomalous-recipe-detail-hero';
if (typeof recipe.thumbnail === 'string') {
const image = document.createElement('img');
image.src = recipe.thumbnail;
image.alt = recipe.name || t('recipeThumbnail');
hero.appendChild(image);
}
const copy = document.createElement('div');
copy.className = 'anomalous-recipe-detail-hero-copy';
appendText(copy, 'h3', recipe.name || t('recipeUntitled'));
appendText(copy, 'p', recipe.notes || t('recipeDetailNoNotes'), 'anomalous-recipe-detail-muted');
const tags = document.createElement('div');
tags.className = 'anomalous-recipe-tags';
for (const tag of recipe.tags || []) appendText(tags, 'span', tag, 'anomalous-recipe-badge anomalous-recipe-badge-tag');
copy.appendChild(tags);
appendText(copy, 'small', `${t('recipeDetailUpdated')}: ${dateText(recipe.updated_timestamp || recipe.timestamp)}`, 'anomalous-recipe-detail-muted');
hero.appendChild(copy);
overview.appendChild(hero);
const stats = document.createElement('div');
stats.className = 'anomalous-recipe-detail-stats';
const verified = references.filter((reference) => normaliseIdentity(reference.identity).status === 'verified').length;
const unverified = references.filter((reference) => normaliseIdentity(reference.identity).status === 'unverified').length;
const missing = missingNodeTypes(recipe).length;
renderStat(stats, t('recipeDetailIdentity'), `${verified}/${references.length}`, verified === references.length ? 'good' : 'warn');
renderStat(stats, t('recipeDetailUnverified'), String(unverified), unverified ? 'warn' : 'good');
renderStat(stats, t('recipeDetailMissingNodes'), String(missing), missing ? 'warn' : 'good');
renderStat(stats, t('recipeDetailFingerprint'), shortHash(fingerprintText(recipe)) || t('recipeDetailNotIndexed'));
overview.appendChild(stats);
const summary = document.createElement('section');
summary.className = 'anomalous-recipe-detail-section';
appendText(summary, 'h4', t('recipeDetailSummary'));
const summaryGrid = document.createElement('div');
summaryGrid.className = 'anomalous-recipe-detail-summary-grid';
const params = recipe.params || {};
const positive = Array.isArray(params.promptPositive) ? params.promptPositive[0] : params.promptPositive;
const values = [
[t('recipeModel'), params.baseModel],
[t('recipeDetailLoraSummary'), (params.loras || []).map((item) => item.name).join(', ')],
[t('recipeSteps'), params.steps],
['CFG', params.cfg],
[t('recipeDetailSampler'), params.sampler_name],
[t('recipeDetailResolution'), params.resolution ? `${params.resolution.width} x ${params.resolution.height}` : ''],
[t('recipePrompt'), compact(positive, 260)],
];
for (const [label, value] of values) {
const row = document.createElement('div');
appendText(row, 'span', label, 'anomalous-recipe-detail-label');
appendText(row, 'span', value || t('recipeDetailUnavailable'), 'anomalous-recipe-detail-value');
summaryGrid.appendChild(row);
}
summary.appendChild(summaryGrid);
overview.appendChild(summary);
const actions = document.createElement('div');
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 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);
content.appendChild(overview);
}
function renderModels(content, owner, recipe, references) {
const section = document.createElement('section');
section.className = 'anomalous-recipe-detail-section';
const heading = document.createElement('div');
heading.className = 'anomalous-recipe-detail-section-heading';
appendText(heading, 'h4', t('recipeDetailModels'));
const refresh = button(heading, t('recipeDetailRefreshAvailability'), 'anomalous-btn-primary');
const status = appendText(heading, 'small', '', 'anomalous-recipe-detail-muted');
refresh.onclick = async () => {
refresh.disabled = true;
status.textContent = t('recipeDetailRefreshing');
try {
const response = await fetch('/anomalous/refresh_recipe_identity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: owner.recipeDetailFilename, references }),
});
const payload = await response.json();
if (!response.ok || payload.status !== 'success') throw new Error('identity refresh failed');
const resultMap = new Map((payload.results || []).map((item) => [
`${item.node_id}:${item.widget_index}:${item.saved_value}`,
item,
]));
for (const reference of references) {
const result = resultMap.get(`${reference.node_id}:${reference.widget_index}:${reference.saved_value}`);
if (!result) continue;
reference.currentAvailability = result.availability;
if (result.identity && result.identity.status === 'verified' && !reference.identity?.sha256) {
reference.identity = result.identity;
}
}
content.replaceChildren();
renderModels(content, owner, recipe, references);
} catch (error) {
console.error('Could not refresh recipe model availability:', error);
status.textContent = t('recipeDetailRefreshError');
refresh.disabled = false;
}
};
section.appendChild(heading);
if (!references.length) {
appendText(section, 'p', t('recipeDetailNoModelReferences'), 'anomalous-recipe-detail-muted');
content.appendChild(section);
return;
}
const list = document.createElement('div');
list.className = 'anomalous-recipe-model-reference-list';
for (const reference of references) {
const card = document.createElement('article');
card.className = 'anomalous-recipe-model-reference';
const top = document.createElement('div');
top.className = 'anomalous-recipe-model-reference-top';
appendText(top, 'strong', reference.node_title || reference.node_type || t('recipeUnknownNode'));
appendText(top, 'span', reference.category || t('recipeDetailModel'), 'anomalous-recipe-detail-muted');
top.appendChild(identityBadge(reference));
card.appendChild(top);
appendText(card, 'code', reference.saved_value || t('recipeDetailUnavailable'), 'anomalous-recipe-model-reference-value');
const meta = document.createElement('div');
meta.className = 'anomalous-recipe-model-reference-meta';
const identity = normaliseIdentity(reference.identity);
if (identity.sha256) {
appendText(meta, 'span', `SHA256 ${shortHash(identity.sha256)}`);
const copy = button(meta, t('recipeDetailCopyHash'), 'anomalous-recipe-copy-param');
copy.onclick = () => copyText(identity.sha256);
}
if (formatIdentitySize(identity.size)) appendText(meta, 'span', formatIdentitySize(identity.size));
appendText(meta, 'span', reference.currentAvailability === 'available'
? t('recipeDetailAvailable')
: reference.currentAvailability === 'missing' ? t('recipeDetailMissing') : t('recipeDetailAvailabilityNotChecked'));
card.appendChild(meta);
list.appendChild(card);
}
section.appendChild(list);
content.appendChild(section);
}
function renderParameters(content, recipe) {
const section = document.createElement('section');
section.className = 'anomalous-recipe-detail-section';
const heading = document.createElement('div');
heading.className = 'anomalous-recipe-detail-section-heading';
appendText(heading, 'h4', t('recipeDetailParameters'));
const search = document.createElement('input');
search.type = 'search';
search.placeholder = t('recipeDetailSearchParameters');
search.className = 'anomalous-recipe-detail-search';
heading.appendChild(search);
section.appendChild(heading);
const list = document.createElement('div');
list.className = 'anomalous-recipe-detail-parameter-list';
const render = () => {
list.replaceChildren();
const query = search.value.trim().toLowerCase();
for (const node of recipe.params?.nodes || []) {
const widgets = (node.widgets || []).filter((widget) => {
const haystack = `${node.title || ''} ${node.type || ''} ${widget.name || ''} ${displayValue(widget.value)}`.toLowerCase();
return !query || haystack.includes(query);
});
if (!widgets.length) continue;
const block = document.createElement('article');
block.className = 'anomalous-recipe-detail-parameter-node';
appendText(block, 'strong', node.title || node.type || t('recipeUnknownNode'));
appendText(block, 'small', node.type || '', 'anomalous-recipe-detail-muted');
for (const widget of widgets) {
const row = document.createElement('div');
row.className = 'anomalous-recipe-detail-parameter-row';
appendText(row, 'span', widget.name || t('recipeDetailWidget'), 'anomalous-recipe-detail-label');
appendText(row, 'code', compact(displayValue(widget.value), 320), 'anomalous-recipe-detail-value');
block.appendChild(row);
}
list.appendChild(block);
}
if (!list.children.length) appendText(list, 'p', t('recipeDetailNoParameters'), 'anomalous-recipe-detail-muted');
};
search.oninput = render;
render();
section.appendChild(list);
content.appendChild(section);
}
function renderVersions(content, owner, recipe, history, finish) {
const section = document.createElement('section');
section.className = 'anomalous-recipe-detail-section';
appendText(section, 'h4', t('recipeHistory'));
const timeline = document.createElement('div');
timeline.className = 'anomalous-recipe-version-timeline';
const current = document.createElement('article');
current.className = 'anomalous-recipe-version-row current';
appendText(current, 'strong', t('recipeDetailCurrentVersion'));
appendText(current, 'span', dateText(recipe.updated_timestamp || recipe.timestamp));
appendText(current, 'code', shortHash(fingerprintText(recipe)) || t('recipeDetailNotIndexed'));
timeline.appendChild(current);
for (const version of history || []) {
const row = document.createElement('article');
row.className = 'anomalous-recipe-version-row';
const copy = document.createElement('div');
appendText(copy, 'strong', version.name || t('recipeUnknownVersion'));
appendText(copy, 'span', dateText(version.timestamp));
row.appendChild(copy);
appendText(row, 'code', shortHash(version.workflow_fingerprint?.value) || t('recipeDetailNotIndexed'));
const restore = button(row, t('recipeRestoreVersion'), 'anomalous-btn-danger');
restore.onclick = async () => {
if (!confirm(t('recipeRestoreVersionConfirm'))) return;
try {
const response = await fetch('/anomalous/restore_recipe_version', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: owner.recipeDetailFilename, version: version.version }),
});
if (!response.ok) throw new Error('restore failed');
await owner.refreshRecipes();
finish('restored');
} catch (error) {
console.error('Could not restore recipe version:', error);
alert(t('recipeUpdateError'));
}
};
timeline.appendChild(row);
}
if (!(history || []).length) appendText(timeline, 'p', t('recipeHistoryEmpty'), 'anomalous-recipe-detail-muted');
section.appendChild(timeline);
content.appendChild(section);
}
export function showRecipeDetail(owner, { recipe, filename, history = [] }) {
owner.recipeDetailFilename = filename;
owner.recipeListContainer.style.display = 'none';
owner.recipeView.querySelector('.anomalous-recipe-actionbar').style.display = 'none';
if (owner.recipeDetailView) owner.recipeDetailView.remove();
const view = document.createElement('div');
view.className = 'anomalous-recipe-detail-view';
owner.recipeDetailView = view;
const references = deriveRecipeModelReferences(recipe);
let resolveAction;
const result = new Promise((resolve) => { resolveAction = resolve; });
const finish = (mode) => {
view.remove();
owner.recipeDetailView = null;
owner.recipeListContainer.style.display = '';
owner.recipeView.querySelector('.anomalous-recipe-actionbar').style.display = '';
resolveAction({ mode });
};
const header = document.createElement('div');
header.className = 'anomalous-recipe-detail-header';
const back = button(header, t('recipeDetailBack'), 'anomalous-btn-primary');
back.onclick = () => finish('back');
appendText(header, 'h3', recipe.name || t('recipeUntitled'));
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); };
header.appendChild(headerActions);
view.appendChild(header);
const tabs = document.createElement('div');
tabs.className = 'anomalous-recipe-detail-tabs';
const content = document.createElement('div');
content.className = 'anomalous-recipe-detail-content';
const tabDefinitions = [
['overview', t('recipeDetailOverview'), () => renderOverview(content, 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)],
];
const selectTab = (active) => {
content.replaceChildren();
for (const [key, label, render] of tabDefinitions) {
const tab = tabs.querySelector(`[data-tab="${key}"]`);
tab?.classList.toggle('active', key === active);
}
tabDefinitions.find(([key]) => key === active)?.[2]();
};
for (const [key, label] of tabDefinitions) {
const tab = button(tabs, label, 'anomalous-recipe-detail-tab');
tab.dataset.tab = key;
tab.onclick = () => selectTab(key);
}
view.append(tabs, content);
owner.recipeView.appendChild(view);
selectTab('overview');
return result;
}
+68 -38
View File
@@ -9,6 +9,7 @@ import {
extractRecipeParameterChoices,
extractRecipeParameterChoicesFromMetadata,
} from './recipe_parser.js';
import { showRecipeDetail } from './ui_recipe_detail.js';
const t = (key) => {
let lang = window.anomalous_browser_lang || 'zh';
@@ -198,6 +199,53 @@ function renderParams(parent, params = {}) {
parent.appendChild(summary);
}
async function fetchRecipeBundle(filename) {
const [fullResponse, historyResponse] = await Promise.all([
fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`),
fetch(`/anomalous/recipe_history?filename=${encodeURIComponent(filename)}`),
]);
const payload = await fullResponse.json();
const historyPayload = historyResponse.ok ? await historyResponse.json() : { versions: [] };
if (!fullResponse.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
return { data: JSON.parse(JSON.stringify(payload.data)), history: historyPayload.versions || [] };
}
async function editRecipe(owner, recipe, filename, history = null) {
try {
const bundle = history ? { data: JSON.parse(JSON.stringify(recipe)), history } : await fetchRecipeBundle(filename);
const editable = bundle.data;
const result = await showRecipeEditDialog(owner, editable, filename, bundle.history);
if (!result || result.mode === 'restored') return;
if (result.mode === 'canvas') {
if (!confirm(t('recipeEditCanvasConfirm'))) return;
app.loadGraphData(editable.workflow);
app.canvas?.setDirty?.(true, true);
owner.recipeEditing = { filename, data: editable };
const saveButton = owner.recipeView?.querySelector('.anomalous-recipe-actionbar button');
if (saveButton) saveButton.textContent = t('recipeUpdateCurrent');
return;
}
applyRecipeWidgetChanges(editable.params, editable.workflow, result.changes);
const updatedChoices = extractRecipeParameterChoicesFromMetadata(editable.params);
editable.name = result.name;
editable.tags = result.tags;
editable.notes = result.notes;
editable.params.pinned = updatedChoices.filter((choice) => result.pinnedKeys.has(choice.key));
const response = await fetch('/anomalous/update_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, ...editable }),
});
const updatedPayload = await response.json();
if (!response.ok || updatedPayload.status !== 'success') throw new Error('recipe update failed');
await owner.refreshRecipes();
} catch (error) {
console.error('Could not edit Workflow Recipe:', error);
alert(t('recipeUpdateError'));
}
}
function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices, initial = null) {
return new Promise((resolve) => {
const selection = {
@@ -627,6 +675,12 @@ export async function showRecipes() {
this.notebookBody.style.display = 'none';
this.notebookNotesTab?.classList.remove('active');
this.notebookRecipesTab?.classList.add('active');
if (this.recipeDetailView) {
this.recipeDetailView.remove();
this.recipeDetailView = null;
this.recipeListContainer.style.display = '';
this.recipeView.querySelector('.anomalous-recipe-actionbar').style.display = '';
}
if (this.recipeView) this.recipeView.style.display = 'flex';
if (this.recipesInitialized) {
await this.refreshRecipes();
@@ -697,49 +751,25 @@ export function renderRecipeList(recipes) {
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
const edit = appendText(actions, 'button', t('recipeEdit'), 'anomalous-btn-success');
edit.type = 'button';
edit.onclick = async () => {
const details = appendText(actions, 'button', t('recipeViewDetails'), 'anomalous-btn-primary');
details.type = 'button';
details.onclick = async () => {
try {
const [fullResponse, historyResponse] = await Promise.all([
fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(recipe.filename)}`),
fetch(`/anomalous/recipe_history?filename=${encodeURIComponent(recipe.filename)}`),
]);
const payload = await fullResponse.json();
const historyPayload = historyResponse.ok ? await historyResponse.json() : { versions: [] };
if (!fullResponse.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
const editable = JSON.parse(JSON.stringify(payload.data));
const result = await showRecipeEditDialog(this, editable, recipe.filename, historyPayload.versions || []);
if (!result || result.mode === 'restored') return;
if (result.mode === 'canvas') {
if (!confirm(t('recipeEditCanvasConfirm'))) return;
app.loadGraphData(editable.workflow);
app.canvas?.setDirty?.(true, true);
this.recipeEditing = { filename: recipe.filename, data: editable };
const saveButton = this.recipeView?.querySelector('.anomalous-recipe-actionbar button');
if (saveButton) saveButton.textContent = t('recipeUpdateCurrent');
return;
}
applyRecipeWidgetChanges(editable.params, editable.workflow, result.changes);
const updatedChoices = extractRecipeParameterChoicesFromMetadata(editable.params);
editable.name = result.name;
editable.tags = result.tags;
editable.notes = result.notes;
editable.params.pinned = updatedChoices.filter((choice) => result.pinnedKeys.has(choice.key));
const response = await fetch('/anomalous/update_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: recipe.filename, ...editable }),
const bundle = await fetchRecipeBundle(recipe.filename);
const result = await showRecipeDetail(this, {
recipe: bundle.data,
filename: recipe.filename,
history: bundle.history,
});
const updatedPayload = await response.json();
if (!response.ok || updatedPayload.status !== 'success') throw new Error('recipe update failed');
await this.refreshRecipes();
if (result?.mode === 'edit') await editRecipe(this, bundle.data, recipe.filename, bundle.history);
} catch (error) {
console.error('Could not edit Workflow Recipe:', error);
alert(t('recipeUpdateError'));
console.error('Could not open Workflow Recipe details:', error);
alert(t('recipeLoadError'));
}
};
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');
restore.type = 'button';
restore.onclick = async () => {
+293
View File
@@ -1414,6 +1414,299 @@
white-space: nowrap;
}
/* Recipe detail panel: one scroll owner inside the Notebook modal. */
.anomalous-recipe-detail-view {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
background: #202020;
}
.anomalous-recipe-detail-header,
.anomalous-recipe-detail-section-heading {
display: flex;
align-items: center;
gap: 10px;
}
.anomalous-recipe-detail-header {
flex: 0 0 auto;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #3d3d3d;
}
.anomalous-recipe-detail-header h3,
.anomalous-recipe-detail-section h4 {
margin: 0;
}
.anomalous-recipe-detail-tabs {
display: flex;
flex: 0 0 auto;
gap: 4px;
overflow-x: auto;
padding: 8px 18px;
border-bottom: 1px solid #3d3d3d;
background: #252525;
}
.anomalous-recipe-detail-tab {
padding: 7px 11px;
border: 1px solid transparent;
border-radius: 7px;
color: #aaa;
background: transparent;
cursor: pointer;
white-space: nowrap;
}
.anomalous-recipe-detail-tab.active {
border-color: rgba(0, 255, 204, 0.45);
color: #00ffcc;
background: rgba(0, 255, 204, 0.08);
}
.anomalous-recipe-detail-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px;
}
.anomalous-recipe-detail-overview,
.anomalous-recipe-detail-section {
display: flex;
flex-direction: column;
gap: 14px;
}
.anomalous-recipe-detail-hero {
display: grid;
grid-template-columns: minmax(160px, 270px) minmax(0, 1fr);
gap: 18px;
align-items: stretch;
}
.anomalous-recipe-detail-hero img {
width: 100%;
max-height: 220px;
min-height: 150px;
border-radius: 9px;
object-fit: contain;
background: #111;
}
.anomalous-recipe-detail-hero-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
justify-content: center;
}
.anomalous-recipe-detail-hero-copy h3 {
margin: 0;
color: #f1f1f1;
font-size: 1.35rem;
}
.anomalous-recipe-detail-hero-copy p {
margin: 0;
white-space: pre-wrap;
}
.anomalous-recipe-detail-muted {
color: #999;
}
.anomalous-recipe-detail-stats,
.anomalous-recipe-detail-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
gap: 10px;
}
.anomalous-recipe-detail-stat,
.anomalous-recipe-detail-summary-grid > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
padding: 11px;
border: 1px solid #3e3e3e;
border-radius: 8px;
background: rgba(0, 0, 0, 0.16);
}
.anomalous-recipe-detail-stat-label,
.anomalous-recipe-detail-label {
color: #999;
font-size: 0.8rem;
}
.anomalous-recipe-detail-stat strong {
color: #eee;
}
.anomalous-recipe-detail-stat-good { color: #6ee7b7 !important; }
.anomalous-recipe-detail-stat-warn { color: #ffd493 !important; }
.anomalous-recipe-detail-summary-grid > div {
flex-direction: row;
justify-content: space-between;
gap: 12px;
}
.anomalous-recipe-detail-summary-grid .anomalous-recipe-detail-value {
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
}
.anomalous-recipe-detail-section {
margin-bottom: 18px;
padding: 14px;
border: 1px solid #3d3d3d;
border-radius: 10px;
background: #292929;
}
.anomalous-recipe-detail-section-heading {
flex-wrap: wrap;
justify-content: space-between;
}
.anomalous-recipe-detail-section-heading small {
margin-left: auto;
}
.anomalous-recipe-detail-actions { margin-top: 2px; }
.anomalous-recipe-identity-badge {
padding: 3px 7px;
border: 1px solid #555;
border-radius: 999px;
font-size: 0.75rem;
white-space: nowrap;
}
.anomalous-recipe-identity-verified {
border-color: rgba(110, 231, 183, 0.55);
color: #6ee7b7;
}
.anomalous-recipe-identity-unverified {
border-color: rgba(255, 212, 147, 0.55);
color: #ffd493;
}
.anomalous-recipe-identity-unavailable {
border-color: rgba(255, 119, 119, 0.55);
color: #ff9999;
}
.anomalous-recipe-model-reference-list,
.anomalous-recipe-detail-parameter-list,
.anomalous-recipe-version-timeline {
display: flex;
flex-direction: column;
gap: 9px;
}
.anomalous-recipe-model-reference,
.anomalous-recipe-detail-parameter-node,
.anomalous-recipe-version-row {
padding: 11px;
border: 1px solid #414141;
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.anomalous-recipe-model-reference-top,
.anomalous-recipe-model-reference-meta,
.anomalous-recipe-detail-parameter-row,
.anomalous-recipe-version-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.anomalous-recipe-model-reference-top { flex-wrap: wrap; }
.anomalous-recipe-model-reference-top strong { margin-right: auto; }
.anomalous-recipe-model-reference-value {
display: block;
margin: 8px 0;
overflow-wrap: anywhere;
color: #eee;
}
.anomalous-recipe-model-reference-meta {
flex-wrap: wrap;
color: #aaa;
font-size: 0.8rem;
}
.anomalous-recipe-detail-search {
min-width: 180px;
flex: 1;
padding: 7px 9px;
border: 1px solid #555;
border-radius: 6px;
color: #eee;
background: #161616;
}
.anomalous-recipe-detail-parameter-node {
display: flex;
flex-direction: column;
gap: 7px;
}
.anomalous-recipe-detail-parameter-row {
align-items: flex-start;
padding-top: 7px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.anomalous-recipe-detail-parameter-row .anomalous-recipe-detail-label {
flex: 0 0 26%;
}
.anomalous-recipe-detail-parameter-row code {
flex: 1;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.anomalous-recipe-version-row {
justify-content: space-between;
flex-wrap: wrap;
}
.anomalous-recipe-version-row > div {
display: flex;
min-width: 150px;
flex-direction: column;
gap: 4px;
}
.anomalous-recipe-version-row.current {
border-color: rgba(0, 255, 204, 0.45);
}
@media (max-width: 680px) {
.anomalous-recipe-detail-hero { grid-template-columns: 1fr; }
.anomalous-recipe-detail-hero img { max-height: 180px; }
.anomalous-recipe-detail-content { padding: 10px; }
.anomalous-recipe-detail-header { padding: 10px; }
.anomalous-recipe-detail-header h3 { max-width: 34vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (max-width: 560px) {
.anomalous-recipe-edit-param {
grid-template-columns: 1fr;