Files
DemonGatanjieu-Anomalous_Mo…/api/recipes.py
T
DemonGatanjieuandClaude Opus 5.5 5b97d8a5ab Cloud servers: an allow_remote_changes file lets other devices change things
ComfyUI on a rented cloud server (AutoDL, RunPod) is only ever opened
from another device, so since v1.58.1 the plugin was view only there.
An empty allow_remote_changes file in the plugin folder, made on the
server and read once at start, now makes local_guard.may_change true for
every request; the middleware and the routes that check for themselves
(import, placement, scan choice, recipe focus) use it. is_local stays for
import's search of this computer's Downloads and for MCP. The page cannot
make or remove the file, and the file is git-ignored.

GET /anomalous/access answers {local, changes}. The header chip is now a
button: "view only" says what is refused and the two ways for cloud users
(SSH tunnel, the file); with the file it reads "remote changes on", in
yellow, and says that anyone with the address may change things.

docs/guides/cloud-server.md explains both ways and the risk; README,
AGENTS.md rule 4, backend.md and the CHANGELOG (v1.61.0) say so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:00:30 +08:00

510 lines
24 KiB
Python

"""HTTP routes for Workflow Recipes."""
import asyncio
import copy
import json
import os
import time
import uuid
from aiohttp import web
import folder_paths
from . import recipe_schema as _recipe_schema_module
from .recipe_constants import *
from .recipe_images import (
_attach_preview_snapshots, _bounded_gallery_value, _decode_embedded_json,
_embedded_parameter_signature, _embedded_workflow_node_signature,
_embedded_workflow_payload, _gallery_parameter_diff, _output_source_path,
_parameter_gallery_images, _recipe_assets_dir, _recipe_gallery_images,
_recipe_cover_webp_bytes, _recent_output_pngs, _store_recipe_gallery_cover,
_thumbnail_webp_bytes,
)
from .recipe_schema import (
_build_model_references,
_computed_identity_for_reference as _schema_computed_identity_for_reference,
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
_identity_for_reference as _schema_identity_for_reference,
_model_reference_key, _model_reference_specs,
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
)
from .recipe_store import (
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
)
from .local_guard import may_change
from .utils import require_filename, resolve_within
from .workflow_schema import (
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
_parameter_signature, _recipe_receipt, _validate_workflow,
_volatile_widget_indexes, _widget_values, _workflow_fingerprint,
_workflow_link_records, _workflow_node_key, _workflow_node_signature,
_workflow_node_types,
)
def _computed_identity_for_reference(saved_value):
"""Compatibility facade for callers that patched the former route module."""
previous = _recipe_schema_module._resolve_exact_model_reference
_recipe_schema_module._resolve_exact_model_reference = _resolve_exact_model_reference
try:
return _schema_computed_identity_for_reference(saved_value)
finally:
_recipe_schema_module._resolve_exact_model_reference = previous
def _identity_for_reference(saved_value):
"""Compatibility facade for the former monolithic recipes module."""
previous = _recipe_schema_module._resolve_exact_model_reference
_recipe_schema_module._resolve_exact_model_reference = _resolve_exact_model_reference
try:
return _schema_identity_for_reference(saved_value)
finally:
_recipe_schema_module._resolve_exact_model_reference = previous
def _enrich_recipe(*args, **kwargs):
"""Compatibility facade while domain callers migrate to recipe_schema."""
previous = _recipe_schema_module._identity_for_reference
_recipe_schema_module._identity_for_reference = _identity_for_reference
try:
return _schema_enrich_recipe(*args, **kwargs)
finally:
_recipe_schema_module._identity_for_reference = previous
async def api_get_recipes(request):
recipes_dir = get_recipes_dir()
recipes = await asyncio.to_thread(_list_recipes, recipes_dir)
return web.json_response({"recipes": recipes})
async def api_save_recipe(request):
try:
payload = await request.json()
verify_identities = payload.get("verify_model_identities", False)
if not isinstance(verify_identities, bool):
raise ValueError("Invalid verification preference")
recipe = _normalise_recipe(payload)
except (ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
except Exception:
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
try:
recipes_dir = get_recipes_dir()
recipe["created_timestamp"] = recipe["timestamp"]
recipe["updated_timestamp"] = recipe["timestamp"]
recipe = await asyncio.to_thread(
_enrich_recipe,
recipe,
recipes_dir,
filename,
True,
False,
verify_identities,
)
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not save recipe"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"receipt": _recipe_receipt(recipe, filename),
})
async def api_delete_recipe(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
recipes_dir = get_recipes_dir()
path = resolve_within(recipes_dir, filename)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
except Exception:
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
try:
await asyncio.to_thread(_delete_recipe_with_history, recipes_dir, filename)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete recipe"}, status=500)
return web.json_response({"status": "success"})
async def api_get_recipe_full(request):
try:
filename = require_filename(request.query.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
recipes_dir = get_recipes_dir()
path = resolve_within(recipes_dir, filename)
data = await asyncio.to_thread(_read_recipe, path)
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
raise ValueError("Invalid recipe")
except (ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500)
return web.json_response({"status": "success", "data": data})
async def api_get_recipe_asset(request):
try:
filename = require_filename(request.query.get("filename", ""))
asset_id = require_filename(request.query.get("asset", ""))
if not filename.endswith(".json") or not asset_id.endswith(".webp"):
raise ValueError("Invalid recipe asset")
recipes_dir = get_recipes_dir()
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename), asset_id)
if not os.path.isfile(asset_path):
raise FileNotFoundError
except (ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe asset"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read recipe asset"}, status=500)
return web.FileResponse(asset_path)
async def api_get_recipe_gallery(request):
"""Find recent output PNGs whose embedded node composition matches one recipe."""
try:
filename = request.query.get("filename")
fingerprint = request.query.get("fingerprint")
if filename:
filename = require_filename(filename)
if not filename.endswith(".json"):
raise ValueError("Invalid recipe")
recipes_dir = get_recipes_dir()
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
raise ValueError("Invalid recipe")
fingerprint = _workflow_node_signature(recipe["workflow"])["value"]
elif not isinstance(fingerprint, str) or not SHA256_PATTERN.fullmatch(fingerprint):
raise ValueError("Invalid fingerprint")
images, scanned = await asyncio.to_thread(_recipe_gallery_images, fingerprint.lower())
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe gallery request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read recipe gallery"}, status=500)
return web.json_response({
"status": "success",
"fingerprint": fingerprint.lower(),
"match_mode": "node-types",
"images": images,
"scanned": scanned,
})
async def api_get_recipe_parameter_gallery(request):
"""Find output PNGs with the same saved node parameters as one recipe."""
try:
filename = require_filename(request.query.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid recipe")
recipes_dir = get_recipes_dir()
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
raise ValueError("Invalid recipe")
signature = _parameter_signature(recipe["workflow"])["value"]
images, scanned = await asyncio.to_thread(_parameter_gallery_images, signature.lower())
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe parameter gallery request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read recipe parameter gallery"}, status=500)
return web.json_response({
"status": "success",
"fingerprint": signature.lower(),
"match_mode": "parameters",
"images": images,
"scanned": scanned,
})
async def api_get_recipe_gallery_compare(request):
"""Return bounded parameters and differences for one matched output image."""
try:
filename = require_filename(request.query.get("filename", ""))
source_image = _normalise_source_image({
"type": "output",
"filename": request.query.get("image_filename", ""),
"subfolder": request.query.get("image_subfolder", ""),
})
recipes_dir = get_recipes_dir()
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
image_path = await asyncio.to_thread(_output_source_path, source_image)
payload = await asyncio.to_thread(_embedded_workflow_payload, image_path)
if not payload or not (payload.get("workflow") or payload.get("prompt")):
raise ValueError("Image has no workflow metadata")
comparison = _gallery_parameter_diff(recipe.get("workflow"), payload)
return web.json_response({
"status": "success",
"image": source_image,
"match_mode": "node-types",
"comparison": comparison,
})
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid gallery comparison request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Image or recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read gallery comparison"}, status=500)
async def api_set_recipe_gallery_cover(request):
"""Promote one verified output image to a portable recipe cover asset."""
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
source_image = _normalise_source_image(payload.get("source_image"))
recipes_dir = get_recipes_dir()
existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict):
raise ValueError("Invalid recipe")
cover = await asyncio.to_thread(_store_recipe_gallery_cover, recipes_dir, filename, source_image)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe cover"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not create recipe cover"}, status=500)
recipe = copy.deepcopy(existing)
recipe["source_image"] = source_image
recipe["thumbnail"] = None
presentation = dict(recipe.get("presentation") or {})
presentation["cover_asset_id"] = cover["asset_id"]
recipe["presentation"] = presentation
recipe["timestamp"] = int(time.time() * 1000)
recipe["updated_timestamp"] = recipe["timestamp"]
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
if recipe.get("workflow_scope") not in {"partial", "complete"}:
recipe["workflow_scope"] = "complete"
recipe["schema_version"] = max(7, int(recipe.get("schema_version") or 1))
try:
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
except OSError:
return web.json_response({"status": "error", "message": "Could not update recipe cover"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"cover": cover,
"source_image": source_image,
"workflow_fingerprint": recipe["workflow_fingerprint"],
})
async def api_update_recipe(request):
"""Replace one recipe while preserving its prior state in local history."""
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
recipes_dir = get_recipes_dir()
path = resolve_within(recipes_dir, filename)
existing = await asyncio.to_thread(_read_recipe, path)
if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict):
raise ValueError("Invalid recipe")
refresh_identities = bool(payload.get("refreshIdentities"))
verify_identities = payload.get("verify_model_identities", False)
if not isinstance(verify_identities, bool):
raise ValueError("Invalid verification preference")
refresh_only = refresh_identities and set(payload).issubset({"filename", "refreshIdentities"})
recipe = copy.deepcopy(existing) if refresh_only else _updated_recipe(payload, existing)
recipe = await asyncio.to_thread(
_enrich_recipe,
recipe,
recipes_dir,
filename,
True,
refresh_identities,
verify_identities,
)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500)
except Exception:
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
try:
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
except OSError:
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"receipt": _recipe_receipt(recipe, filename),
})
def _set_recipe_focus(recipes_dir, filename, entries):
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
focus = _normalise_focus_params(entries)
path = resolve_within(recipes_dir, filename)
if os.path.getsize(path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
recipe = _read_recipe(path)
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
raise ValueError("Invalid recipe")
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
recipe["params"] = {**params, "focusParams": focus}
_write_recipe(recipes_dir, filename, recipe)
return focus
async def api_set_recipe_focus(request):
"""Marks or notes a recipe's focus values (a star in its details). Only from this computer (local_guard.may_change)."""
if not may_change(request):
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
return web.json_response({"status": "success", "focusParams": focus})
async def api_get_recipe_history(request):
try:
filename = require_filename(request.query.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
recipes_dir = get_recipes_dir()
# Verify the root recipe exists before exposing its history directory.
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
versions = await asyncio.to_thread(_list_recipe_history, recipes_dir, filename)
except (ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read recipe history"}, status=500)
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()
filename = require_filename(payload.get("filename", ""))
version = require_filename(payload.get("version", ""))
if not filename.endswith(".json") or not version.endswith(".json"):
raise ValueError("Invalid filename")
recipes_dir = get_recipes_dir()
existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
historical = await asyncio.to_thread(
_read_recipe,
resolve_within(_history_dir(recipes_dir, filename), version),
)
if not isinstance(existing, dict) or not isinstance(historical, dict):
raise ValueError("Invalid recipe")
recipe = _updated_recipe(historical, existing)
recipe = await asyncio.to_thread(_enrich_recipe, recipe, recipes_dir, filename, False)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
except Exception:
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
try:
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
except OSError:
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"receipt": _recipe_receipt(recipe, 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"]
identity_result = _identity_for_reference(saved_value)
result["identity"] = identity_result[0] if isinstance(identity_result, tuple) else identity_result
results.append(result)
return web.json_response({"status": "success", "results": results})