Add secure recipe package transfer

This commit is contained in:
DemonGatanjieu
2026-08-02 21:54:31 +08:00
parent 8a65bb9ea5
commit f26098780d
7 changed files with 598 additions and 1 deletions
+17
View File
@@ -77,3 +77,20 @@
- `node --check` for every JavaScript file under `web/`
- `recipe_diff.js` smoke comparison passed
- `git diff --check`
## [Snapshot] 2026-08-02 — Contained recipe package transfer
**Implemented**
- Added versioned ZIP export with manifest checksums and explicit inclusion controls for preview snapshots, history, and model identity fields.
- Added bounded upload inspection plus single-use short-lived inspection tokens before import commit.
- Added archive path, symlink, compression, entry-count, expanded-size, JSON-depth, checksum, and WebP signature validation.
- Added staged import with generated local recipe identity, name collision rename flow, optional replace-with-history-backup support, and contained asset/history placement.
- Added Workspace Import package action and per-card Export package action with a privacy summary.
**Validation**
- `python_embeded/python.exe -m py_compile api/__init__.py api/models.py api/recipes.py api/recipe_packages.py`
- `node --check` for every JavaScript file under `web/`
- ZIP export → inspect smoke round trip passed with a contained WebP asset
- `git diff --check`
+12
View File
@@ -297,3 +297,15 @@ This document serves as an architectural retrospective and UX diagnostic log for
(配方历史保存的是完整序列化图,但直接展示 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.
(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。)
## 49. Inspect Before Import (导入必须先检查再提交)
**The Problem (问题)**: A recipe package contains user-controlled archive names, JSON, and image bytes. Writing entries directly into the recipe directory would make traversal, symlink, decompression-bomb, dangling-asset, and silent-overwrite failures possible before the UI can explain them.
(配方包里的归档名称、JSON 和图片字节都由用户控制。若直接写进配方目录,路径穿越、符号链接、解压炸弹、悬空资源和静默覆盖都可能在界面解释前发生。)
**The Solution (方案)**: Keep the upload in a bounded single-use inspection record, validate every entry and checksum, show a dry-run summary, then stage the normalized recipe and contained assets before atomic commit. Imports never execute code or install dependencies.
(上传包先保存在有上限、一次性的检查记录中,逐项校验路径、大小、压缩比、校验和和资源引用,展示预检摘要后再把规范化配方和资产写入临时目录并原子提交。导入永远不执行代码,也不安装依赖。)
## 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.
(只比较有上限的语义摘要:提示词、钉选原始值、已知模型引用、安全的采样/分辨率字段、图数量/指纹和展示信息。比较器必须是纯只读逻辑,历史恢复仍是独立的破坏性动作并单独确认。)
+2 -1
View File
@@ -25,6 +25,7 @@ Anomalous_Model_Browser/
│ ├── models.py # Core model listing and routing (API endpoints)
│ ├── notebooks.py # Notebooks management logic
│ ├── recipes.py # Local Workflow Recipe CRUD and validation
│ ├── recipe_packages.py # Bounded ZIP inspection, export, staging, and import commit
│ ├── scanner.py # Scanning engine for hash resolution and caching
│ └── utils.py # Shared backend utilities and safe path boundary helpers
├── scraper.py # Async HTTP client logic & web scraping (Root Level)
@@ -67,7 +68,7 @@ All endpoints are prefixed with `/anomalous/`.
7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`.
8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`.
9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle.
10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, and use atomic replacement. A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser.
10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, and use atomic replacement. A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename.
### 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.*`.
+4
View File
@@ -3,6 +3,7 @@ from .scanner import *
from .config import *
from .notebooks import *
from .recipes import *
from .recipe_packages import *
from .utils import *
def setup_routes(app):
@@ -35,6 +36,9 @@ def setup_routes(app):
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)
app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit)
app.router.add_post('/anomalous/translate', api_translate)
app.router.add_get('/anomalous/base_models', api_base_models)
+461
View File
@@ -0,0 +1,461 @@
"""Bounded import/export for self-contained Workflow Recipe packages."""
import hashlib
import io
import asyncio
import json
import os
import shutil
import stat
import tempfile
import time
import uuid
import zipfile
from aiohttp import web
from . import recipes as recipe_store
from .recipes import get_recipes_dir
from .utils import require_filename, resolve_within
PACKAGE_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_ENTRY_COUNT = 256
MAX_ENTRY_BYTES = 16 * 1024 * 1024
MAX_EXPANDED_BYTES = 64 * 1024 * 1024
MAX_COMPRESSION_RATIO = 100
MAX_HISTORY_ENTRIES = 100
MAX_JSON_DEPTH = 64
MAX_INSPECTIONS = 4
INSPECTION_TTL_SECONDS = 10 * 60
ALLOWED_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
_INSPECTIONS = {}
def _json_depth(value, depth=0):
if depth > MAX_JSON_DEPTH:
return depth
if isinstance(value, dict):
return max((_json_depth(item, depth + 1) for item in value.values()), default=depth)
if isinstance(value, list):
return max((_json_depth(item, depth + 1) for item in value), default=depth)
return depth
def _sha256(data):
return hashlib.sha256(data).hexdigest()
def _safe_archive_name(name):
if not isinstance(name, str) or not name or len(name) > 240:
raise ValueError("Invalid package entry name")
if chr(92) in name or name.startswith("/") or ":" in name:
raise ValueError("Invalid package entry name")
parts = name.split("/")
if any(not part or part in (".", "..") for part in parts):
raise ValueError("Invalid package entry name")
return name
def _entry_is_symlink(info):
return stat.S_ISLNK((info.external_attr >> 16) & 0xFFFF)
def _read_zip_entry(archive, name, maximum=MAX_ENTRY_BYTES):
info = archive.getinfo(name)
if info.file_size > maximum:
raise ValueError("Package entry is too large")
data = archive.read(info)
if len(data) != info.file_size:
raise ValueError("Package entry size mismatch")
return data
def _validate_zip(raw):
if not isinstance(raw, bytes) or len(raw) > MAX_UPLOAD_BYTES:
raise ValueError("Package upload is too large")
try:
archive = zipfile.ZipFile(io.BytesIO(raw), "r")
except (OSError, zipfile.BadZipFile) as error:
raise ValueError("Invalid recipe package") from error
infos = archive.infolist()
if len(infos) > MAX_ENTRY_COUNT:
raise ValueError("Too many package entries")
names = []
expanded = 0
for info in infos:
name = _safe_archive_name(info.filename)
if name in names or info.is_dir() or _entry_is_symlink(info):
raise ValueError("Invalid or duplicate package entry")
if info.compress_type not in ALLOWED_COMPRESSION or info.flag_bits & 0x1:
raise ValueError("Unsupported package compression")
if info.file_size > MAX_ENTRY_BYTES:
raise ValueError("Package entry is too large")
if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
raise ValueError("Package compression ratio is unsafe")
expanded += info.file_size
if expanded > MAX_EXPANDED_BYTES:
raise ValueError("Package expands beyond the safety limit")
names.append(name)
if "manifest.json" not in names or "recipe.json" not in names:
raise ValueError("Package is missing manifest or recipe")
return archive, names
def _parse_json(data, label):
try:
value = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"Invalid {label}") from error
if _json_depth(value) > MAX_JSON_DEPTH:
raise ValueError(f"{label} is nested too deeply")
return value
def _asset_id_from_reference(reference):
asset_id = reference.get("preview", {}).get("snapshot_asset_id") if isinstance(reference, dict) else None
if not isinstance(asset_id, str) or not asset_id.endswith(".webp"):
return None
return require_filename(asset_id)
def _validate_manifest(manifest, archive, names):
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
raise ValueError("Unsupported recipe package version")
declared = manifest.get("entries", [])
if not isinstance(declared, list) or len(declared) > MAX_ENTRY_COUNT:
raise ValueError("Invalid package manifest")
declared_map = {}
for item in declared:
if not isinstance(item, dict):
raise ValueError("Invalid package manifest entry")
name = _safe_archive_name(item.get("name"))
if name in declared_map or name not in names:
raise ValueError("Package manifest does not match archive")
declared_map[name] = item
for name in names:
if name == "manifest.json":
continue
item = declared_map.get(name)
if not item:
raise ValueError("Package contains undeclared data")
data = _read_zip_entry(archive, name)
if item.get("sha256") != _sha256(data) or item.get("size") != len(data):
raise ValueError("Package checksum mismatch")
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True):
value = json.loads(json.dumps(recipe, ensure_ascii=False))
for reference in value.get("params", {}).get("model_references", []):
if not include_snapshots:
reference.pop("preview", None)
if not include_identity:
identity = reference.get("identity")
if isinstance(identity, dict):
for key in ("sha256", "size", "provenance"):
identity.pop(key, None)
identity["status"] = "unverified"
return value
def _referenced_asset_ids(recipe):
result = set()
references = recipe.get("params", {}).get("model_references", [])
for reference in references if isinstance(references, list) else []:
asset_id = _asset_id_from_reference(reference)
if asset_id:
result.add(asset_id)
return result
def _add_zip_entry(archive, entries, name, data, media_type):
name = _safe_archive_name(name)
archive.writestr(name, data)
entries.append({"name": name, "size": len(data), "sha256": _sha256(data), "media_type": media_type})
def _recipe_history_files(recipes_dir, filename):
history_dir = recipe_store._history_dir(recipes_dir, filename)
files = []
if not os.path.isdir(history_dir):
return files
with os.scandir(history_dir) as entries:
for entry in entries:
if entry.is_file() and entry.name.endswith(".json"):
files.append(require_filename(entry.name))
files.sort()
return files[:MAX_HISTORY_ENTRIES]
def _build_export(raw_recipe, recipes_dir, filename, options):
include_snapshots = options.get("include_snapshots") is True
include_history = options.get("include_history") is True
include_identity = options.get("include_identity", True) is True
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity)
entries = []
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
recipe_bytes = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
if include_snapshots:
assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename)
for asset_id in sorted(_referenced_asset_ids(recipe)):
asset_path = resolve_within(assets_dir, asset_id)
if not os.path.isfile(asset_path):
continue
data = open(asset_path, "rb").read(MAX_ENTRY_BYTES + 1)
if len(data) > MAX_ENTRY_BYTES or not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
continue
_add_zip_entry(archive, entries, f"assets/{asset_id}", data, "image/webp")
if include_history:
for history_name in _recipe_history_files(recipes_dir, filename):
history_path = resolve_within(recipe_store._history_dir(recipes_dir, filename), history_name)
data = open(history_path, "rb").read(MAX_ENTRY_BYTES + 1)
if len(data) > MAX_ENTRY_BYTES:
raise ValueError("Historical recipe is too large")
history_recipe = _parse_json(data, "historical recipe")
history_recipe = _sanitize_recipe_for_export(history_recipe, include_snapshots, include_identity)
data = json.dumps(history_recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
_add_zip_entry(archive, entries, f"history/{history_name}", data, "application/json")
manifest = {
"package_version": PACKAGE_VERSION,
"recipe_schema_version": recipe.get("schema_version", 1),
"exported_at": int(time.time() * 1000),
"options": {
"include_snapshots": include_snapshots,
"include_history": include_history,
"include_identity": include_identity,
},
"entries": entries,
}
manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
archive.writestr("manifest.json", manifest_bytes)
return output.getvalue()
def _validate_recipe_assets(archive, recipe, names):
referenced = _referenced_asset_ids(recipe)
for asset_id in referenced:
name = f"assets/{asset_id}"
if name not in names:
raise ValueError("Recipe references a missing snapshot asset")
data = _read_zip_entry(archive, name)
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
raise ValueError("Snapshot asset is not a WebP image")
for name in names:
if name.startswith("assets/") and (name[7:] not in referenced or not name.endswith(".webp")):
raise ValueError("Package contains an unreferenced asset")
def _inspect_package(raw):
archive, names = _validate_zip(raw)
with archive:
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
_validate_manifest(manifest, archive, names)
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
recipe_store._normalise_recipe(recipe)
_validate_recipe_assets(archive, recipe, names)
history_names = [name for name in names if name.startswith("history/")]
if len(history_names) > MAX_HISTORY_ENTRIES:
raise ValueError("Too many history entries")
for name in history_names:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
recipe_store._normalise_recipe(history)
return {
"manifest": manifest,
"recipe": recipe,
"history_names": history_names,
"asset_names": [name for name in names if name.startswith("assets/")],
}
def _prune_inspections():
now = time.time()
for token, record in list(_INSPECTIONS.items()):
if now - record["created"] > INSPECTION_TTL_SECONDS:
_INSPECTIONS.pop(token, None)
while len(_INSPECTIONS) >= MAX_INSPECTIONS:
oldest = min(_INSPECTIONS, key=lambda token: _INSPECTIONS[token]["created"])
_INSPECTIONS.pop(oldest, None)
def _existing_recipe_names(recipes_dir):
return [item["data"].get("name", "") for item in recipe_store._list_recipes(recipes_dir)]
def _unique_name(name, existing):
base = name.strip() or "Imported Workflow Recipe"
if base not in existing:
return base
for index in range(2, 1000):
candidate = f"{base} ({index})"
if candidate not in existing:
return candidate
raise ValueError("Could not create a unique recipe name")
def _copy_imported_assets(archive, inspection, staging, source_recipe, target_recipe):
asset_dir = os.path.join(staging, "assets")
os.makedirs(asset_dir, exist_ok=True)
for name in inspection["asset_names"]:
asset_id = require_filename(name.split("/", 1)[1])
data = _read_zip_entry(archive, name)
with open(resolve_within(asset_dir, asset_id), "wb") as asset_file:
asset_file.write(data)
source_refs = {
(
item.get("node_id"),
item.get("widget_index"),
item.get("category"),
item.get("saved_value"),
): item
for item in source_recipe.get("params", {}).get("model_references", [])
if isinstance(item, dict)
}
for target in target_recipe.get("params", {}).get("model_references", []):
source = source_refs.get((target.get("node_id"), target.get("widget_index"), target.get("category"), target.get("saved_value")))
asset_id = _asset_id_from_reference(source or {})
if asset_id and os.path.isfile(resolve_within(asset_dir, asset_id)):
target["preview"] = source["preview"]
def _write_staged_json(path, value):
with open(path, "w", encoding="utf-8", newline="\n") as output:
json.dump(value, output, ensure_ascii=False, separators=(",", ":"))
def _commit_import(record, payload):
recipes_dir = get_recipes_dir()
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
collision = payload.get("collision", "rename")
name_override = payload.get("name")
if name_override is not None:
if not isinstance(name_override, str) or not name_override.strip():
raise ValueError("Invalid imported recipe name")
package_recipe["name"] = name_override.strip()
existing_names = _existing_recipe_names(recipes_dir)
target_filename = None
if collision == "replace":
target_filename = require_filename(payload.get("target_filename", ""))
if not target_filename.endswith(".json"):
raise ValueError("Invalid replacement target")
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
raise FileNotFoundError
elif collision != "rename":
raise ValueError("Unsupported collision choice")
else:
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
normalized = recipe_store._normalise_recipe(package_recipe)
normalized["presentation"]["imported"] = True
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
normalized = recipe_store._enrich_recipe(normalized)
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
final_path = resolve_within(recipes_dir, filename)
final_assets = recipe_store._recipe_assets_dir(recipes_dir, filename)
final_history = recipe_store._history_dir(recipes_dir, filename)
backup_assets = None
try:
archive, _ = _validate_zip(record["raw"])
with archive:
_copy_imported_assets(archive, record, staging, record["recipe"], normalized)
_write_staged_json(os.path.join(staging, "recipe.json"), normalized)
if record["history_names"]:
history_stage = os.path.join(staging, "history")
os.makedirs(history_stage, exist_ok=True)
for name in record["history_names"]:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
if collision == "replace":
existing = recipe_store._read_recipe(final_path)
recipe_store._archive_recipe(recipes_dir, filename, existing)
if os.path.isdir(final_assets):
backup_assets = os.path.join(staging, "old-assets")
shutil.move(final_assets, backup_assets)
os.replace(os.path.join(staging, "recipe.json"), final_path)
staged_assets = os.path.join(staging, "assets")
if os.path.isdir(staged_assets):
os.replace(staged_assets, final_assets)
if record["history_names"]:
history_stage = os.path.join(staging, "history")
if os.path.isdir(final_history):
shutil.rmtree(final_history)
os.replace(history_stage, final_history)
except Exception:
if collision == "replace" and backup_assets and not os.path.isdir(final_assets) and os.path.isdir(backup_assets):
shutil.move(backup_assets, final_assets)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
return filename, normalized.get("name", "")
async def api_export_recipe_package(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid recipe filename")
recipes_dir = get_recipes_dir()
recipe = await asyncio.to_thread(recipe_store._read_recipe, resolve_within(recipes_dir, filename))
package = await asyncio.to_thread(_build_export, recipe, recipes_dir, filename, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid export request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
return web.Response(
body=package,
content_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
)
async def api_import_recipe_package_inspect(request):
try:
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
report = await asyncio.to_thread(_inspect_package, raw)
except (ValueError, OSError, zipfile.BadZipFile):
return web.json_response({"status": "error", "message": "Invalid recipe package"}, status=400)
_prune_inspections()
token = uuid.uuid4().hex
_INSPECTIONS[token] = {"created": time.time(), "raw": raw, **report}
return web.json_response({
"status": "success",
"token": token,
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
"asset_count": len(report["asset_names"]),
"history_count": len(report["history_names"]),
"existing_names": _existing_recipe_names(get_recipes_dir()),
})
async def api_import_recipe_package_commit(request):
try:
payload = await request.json()
token = payload.get("token")
if not isinstance(token, str) or token not in _INSPECTIONS:
raise ValueError("Invalid inspection token")
record = _INSPECTIONS.pop(token)
if time.time() - record["created"] > INSPECTION_TTL_SECONDS:
raise ValueError("Inspection token expired")
filename, name = await asyncio.to_thread(_commit_import, record, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": name})
+22
View File
@@ -107,6 +107,17 @@ export const i18n = {
recipeNoTags: '暂无标签',
recipeNoMatches: '没有匹配的配方。请清除搜索或标签筛选。',
recipeFilterByTag: '按此标签筛选',
recipeImport: '导入配方包',
recipeExport: '导出配方包',
recipeExportError: '导出工作流配方包失败。',
recipeImportError: '导入工作流配方包失败。',
recipeExportSnapshotsConfirm: '导出时包含模型预览快照吗?\n\n快照是展示数据,不包含模型文件。',
recipeExportHistoryConfirm: '导出时包含历史版本吗?\n\n历史可能包含旧提示词和备注。',
recipeExportRedactIdentityConfirm: '是否移除模型 SHA-256、大小和来源信息?\n\n点击“确定”将移除这些身份信息。',
recipeImportSummary: '配方包检查完成。确认导入以下内容吗?',
recipeImportAssets: '预览资产',
recipeImportHistory: '历史版本',
recipeImportRenamePrompt: '配方名称已存在,请输入导入后的名称:',
recipeModel: '模型',
recipeLoras: 'LoRA',
recipePrompt: '正向提示词',
@@ -323,6 +334,17 @@ export const i18n = {
recipeNoTags: 'No tags yet',
recipeNoMatches: 'No recipes match. Clear the search or tag filters.',
recipeFilterByTag: 'Filter by this tag',
recipeImport: 'Import package',
recipeExport: 'Export package',
recipeExportError: 'Could not export the Workflow Recipe package.',
recipeImportError: 'Could not import the Workflow Recipe package.',
recipeExportSnapshotsConfirm: 'Include model preview snapshots in the export?\n\nSnapshots are presentation data; model files are never included.',
recipeExportHistoryConfirm: 'Include historical versions in the export?\n\nHistory may contain older prompts and notes.',
recipeExportRedactIdentityConfirm: 'Remove model SHA-256, size, and provenance information?\n\nChoose OK to remove those identity fields.',
recipeImportSummary: 'The recipe package passed inspection. Import these contents?',
recipeImportAssets: 'Preview assets',
recipeImportHistory: 'Historical versions',
recipeImportRenamePrompt: 'This recipe name already exists. Enter the imported name:',
recipeModel: 'Model',
recipeLoras: 'LoRAs',
recipePrompt: 'Positive prompt',
+80
View File
@@ -38,6 +38,58 @@ function outputImageUrl(image) {
return `/view?${query.toString()}`;
}
async function exportRecipePackage(filename) {
const includeSnapshots = confirm(t('recipeExportSnapshotsConfirm'));
const includeHistory = confirm(t('recipeExportHistoryConfirm'));
const includeIdentity = !confirm(t('recipeExportRedactIdentityConfirm'));
const response = await fetch('/anomalous/export_recipe_package', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename,
include_snapshots: includeSnapshots,
include_history: includeHistory,
include_identity: includeIdentity,
}),
});
if (!response.ok) throw new Error('recipe export failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename.replace(/\.json$/i, '')}.anomalous-recipe.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function importRecipePackage(owner, file) {
const inspectResponse = await fetch('/anomalous/import_recipe_package_inspect', {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: file,
});
const inspectPayload = await inspectResponse.json();
if (!inspectResponse.ok || inspectPayload.status !== 'success') throw new Error('recipe import inspection failed');
const recipeName = inspectPayload.recipe?.name || t('recipeUntitled');
const summary = `${t('recipeImportSummary')}\n\n${recipeName}\n${t('recipeImportAssets')}: ${inspectPayload.asset_count || 0}\n${t('recipeImportHistory')}: ${inspectPayload.history_count || 0}`;
if (!confirm(summary)) return;
let name = recipeName;
if ((inspectPayload.existing_names || []).includes(name)) {
name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`);
if (!name?.trim()) return;
}
const commitResponse = await fetch('/anomalous/import_recipe_package_commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inspectPayload.token, collision: 'rename', name: name.trim() }),
});
const commitPayload = await commitResponse.json();
if (!commitResponse.ok || commitPayload.status !== 'success') throw new Error('recipe import commit failed');
await owner.refreshRecipes();
}
async function captureOutputThumbnail(image) {
const url = outputImageUrl(image);
if (!url) return null;
@@ -780,6 +832,24 @@ export async function showRecipes() {
save.dataset.recipeSaveCurrent = 'true';
save.type = 'button';
save.onclick = () => this.handleSaveRecipe();
const importButton = appendText(actionBar, 'button', t('recipeImport'), 'anomalous-btn-primary');
importButton.type = 'button';
importButton.onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip,.anomalous-recipe.zip,application/zip';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
await importRecipePackage(this, file);
} catch (error) {
console.error('Could not import Workflow Recipe package:', error);
alert(t('recipeImportError'));
}
};
input.click();
};
this.recipeView.appendChild(actionBar);
this.recipeListContainer = document.createElement('div');
@@ -879,6 +949,16 @@ 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 exportButton = appendText(actions, 'button', t('recipeExport'), 'anomalous-btn-primary');
exportButton.type = 'button';
exportButton.onclick = async () => {
try {
await exportRecipePackage(recipe.filename);
} catch (error) {
console.error('Could not export Workflow Recipe package:', error);
alert(t('recipeExportError'));
}
};
const restore = appendText(actions, 'button', t('recipeOpenCanvas'), 'anomalous-btn-primary');
restore.type = 'button';
restore.onclick = async () => {