diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 5f4b8d49..59cedcd6 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -1821,6 +1821,10 @@ class RecipeManagementHandler: if recipe_scanner is None: raise RuntimeError("Recipe scanner unavailable") + # Opt-in workflow embedding. The widget historically POSTs with no + # body at all, so a missing/empty body is not an error. + workflow = await self._read_optional_json_field(request, "workflow") + analysis = await self._analysis_service.analyze_widget_metadata( recipe_scanner=recipe_scanner ) @@ -1833,6 +1837,7 @@ class RecipeManagementHandler: recipe_scanner=recipe_scanner, metadata=metadata, image_bytes=image_bytes, + workflow=workflow, ) return web.json_response(result.payload, status=result.status) except RecipeValidationError as exc: @@ -1897,6 +1902,24 @@ class RecipeManagementHandler: return [] return [tag.strip() for tag in tag_text.split(",") if tag.strip()] + async def _read_optional_json_field( + self, request: web.Request, field: str + ) -> Any: + """Read one field from an optional JSON request body. + + Some callers (notably the widget's long-standing "Save Recipe" action) + POST with no body at all, and a stale cached extension may still do so + after a body is introduced. A missing, empty or malformed body is + therefore treated as "no value" rather than a request error. + """ + if not request.can_read_body: + return None + try: + data = await request.json() + except Exception: + return None + return data.get(field) if isinstance(data, dict) else None + async def _count_recipe_loras( self, recipe_scanner: Any, recipe_id: Optional[str] ) -> Optional[int]: diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 9027a90b..60a12113 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -18,6 +18,7 @@ from ...utils.base_model import ( RELATION_INCOMPATIBLE, base_model_relation, ) +from ...utils.constants import MAX_WORKFLOW_EMBED_BYTES from ...utils.utils import calculate_recipe_fingerprint from ..pending_delete_service import get_pending_delete_service from .errors import RecipeNotFoundError, RecipeValidationError @@ -874,8 +875,15 @@ class RecipePersistenceService: recipe_scanner, metadata: dict[str, Any], image_bytes: bytes, + workflow: Any = None, ) -> PersistenceResult: - """Save a recipe constructed from widget metadata.""" + """Save a recipe constructed from widget metadata. + + ``workflow`` is the caller's ComfyUI graph (UI or API format) to embed + in the stored preview. Embedding is opt-in because the graph is by far + the largest metadata field and its widget values may contain sensitive + data; an oversized graph is dropped rather than inflating the preview. + """ if not metadata: raise RecipeValidationError("No generation metadata found") @@ -884,12 +892,25 @@ class RecipePersistenceService: os.makedirs(recipes_dir, exist_ok=True) recipe_id = str(uuid.uuid4()) + + workflow_json = self._exif_utils.normalise_workflow(workflow) + workflow_skipped: Optional[str] = None + if workflow_json and len(workflow_json.encode("utf-8")) > MAX_WORKFLOW_EMBED_BYTES: + self._logger.warning( + "Widget workflow is %d bytes (limit %d); saving recipe without it", + len(workflow_json), + MAX_WORKFLOW_EMBED_BYTES, + ) + workflow_json = None + workflow_skipped = "too_large" + optimized_image, extension = self._exif_utils.optimize_image( image_data=image_bytes, target_width=self._card_preview_width, format="webp", quality=85, preserve_metadata=True, + workflow=workflow_json, ) image_filename = f"{recipe_id}{extension}" image_path = os.path.join(recipes_dir, image_filename) @@ -943,9 +964,9 @@ class RecipePersistenceService: if key not in ["checkpoint", "loras"] }, "loras_stack": lora_stack, - # Widget saves re-encode an in-memory tensor to PNG/WebP with no - # embedded metadata chunks, so a workflow can never be present. - "has_workflow": False, + # Set by detection below: the workflow is embedded during + # re-encoding only when the caller opted in and it fit the cap. + "has_workflow": self._detect_has_workflow(image_path), # Widget saves read LoRAs straight from the current workflow; an # empty list means the workflow used no LoRAs. "import_info": build_import_info(CHANNEL_WIDGET, None, loras_data), @@ -961,15 +982,17 @@ class RecipePersistenceService: self._exif_utils.append_recipe_metadata(image_path, recipe_data) await recipe_scanner.add_recipe(recipe_data) - return PersistenceResult( - { - "success": True, - "recipe_id": recipe_id, - "image_path": image_path, - "json_path": json_path, - "recipe_name": recipe_name, - } - ) + payload: dict[str, Any] = { + "success": True, + "recipe_id": recipe_id, + "image_path": image_path, + "json_path": json_path, + "recipe_name": recipe_name, + "has_workflow": recipe_data["has_workflow"], + } + if workflow_skipped: + payload["workflow_skipped"] = workflow_skipped + return PersistenceResult(payload) # Helper methods --------------------------------------------------- diff --git a/py/utils/constants.py b/py/utils/constants.py index 09fe9330..6a283ced 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -41,6 +41,14 @@ PREVIEW_EXTENSIONS = [ # Card preview image width CARD_PREVIEW_WIDTH = 480 +# Upper bound for a ComfyUI workflow embedded into a recipe preview on the +# opt-in widget save path. The workflow is by far the largest metadata field +# (tens of KB for a simple graph), so an anomalous graph — e.g. one carrying +# base64 blobs in widget values — is skipped instead of inflating the preview. +# Imports are deliberately not capped: their workflow comes from an image the +# user already chose, and preserving it is the point. +MAX_WORKFLOW_EMBED_BYTES = 256 * 1024 + # Width for optimized example images EXAMPLE_IMAGE_WIDTH = 832 diff --git a/tests/frontend/components/lorasWidgetEvents.saveRecipeMenu.test.js b/tests/frontend/components/lorasWidgetEvents.saveRecipeMenu.test.js new file mode 100644 index 00000000..5fa40b49 --- /dev/null +++ b/tests/frontend/components/lorasWidgetEvents.saveRecipeMenu.test.js @@ -0,0 +1,79 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { EVENTS_MODULE, API_MODULE, APP_MODULE, COMPONENTS_MODULE, UTILS_MODULE } = + vi.hoisted(() => ({ + EVENTS_MODULE: new URL('../../../web/comfyui/loras_widget_events.js', import.meta.url) + .pathname, + API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname, + APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname, + COMPONENTS_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url) + .pathname, + UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url) + .pathname, + })); + +const saveRecipeDirectly = vi.fn(); + +vi.mock(API_MODULE, () => ({ api: {} })); +vi.mock(APP_MODULE, () => ({ app: {} })); + +vi.mock(COMPONENTS_MODULE, () => ({ + createMenuItem: (text, icon, onClick) => { + const el = document.createElement('div'); + el.className = 'lm-lora-menu-item'; + el.textContent = text; + if (onClick) el.addEventListener('click', onClick); + return el; + }, + createDropIndicator: vi.fn(), +})); + +vi.mock(UTILS_MODULE, () => ({ + parseLoraValue: vi.fn(() => []), + formatLoraValue: vi.fn((value) => value), + syncClipStrengthIfCollapsed: vi.fn(), + saveRecipeDirectly, + copyToClipboard: vi.fn(), + showToast: vi.fn(), + moveLoraByDirection: vi.fn(), + getDropTargetIndex: vi.fn(), + getLoraStrengthRange: vi.fn(), + applyStrengthRangeCue: vi.fn(), +})); + +function findMenuItem(label) { + return Array.from(document.querySelectorAll('.lm-lora-menu-item')).find( + (item) => item.textContent === label + ); +} + +describe('LoRA widget context menu save options', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + it('offers workflow embedding as a separate, opt-in action', async () => { + const { createContextMenu } = await import(EVENTS_MODULE); + const widget = { value: [], callback: vi.fn() }; + + createContextMenu(10, 10, 'lora-a', widget, null, vi.fn()); + + const plain = findMenuItem('Save Recipe'); + const withWorkflow = findMenuItem('Save Recipe with Workflow'); + expect(plain).toBeTruthy(); + expect(withWorkflow).toBeTruthy(); + + plain.click(); + expect(saveRecipeDirectly).toHaveBeenLastCalledWith(); + + // Re-open: the first click removed the menu. + createContextMenu(10, 10, 'lora-a', widget, null, vi.fn()); + findMenuItem('Save Recipe with Workflow').click(); + expect(saveRecipeDirectly).toHaveBeenLastCalledWith({ embedWorkflow: true }); + }); +}); diff --git a/tests/frontend/utils/saveRecipeDirectly.test.js b/tests/frontend/utils/saveRecipeDirectly.test.js new file mode 100644 index 00000000..56f94c2a --- /dev/null +++ b/tests/frontend/utils/saveRecipeDirectly.test.js @@ -0,0 +1,92 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { UTILS_MODULE, APP_MODULE, API_MODULE, BASE_PATH_MODULE } = vi.hoisted(() => ({ + UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url).pathname, + APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname, + API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname, + BASE_PATH_MODULE: new URL('../../../web/comfyui/base_path.js', import.meta.url).pathname, +})); + +const toastAdd = vi.fn(); +const graphToPrompt = vi.fn(); + +vi.mock(APP_MODULE, () => ({ + app: { + graphToPrompt, + extensionManager: { toast: { add: toastAdd } }, + }, +})); + +vi.mock(API_MODULE, () => ({ + api: { fetchApi: vi.fn() }, +})); + +vi.mock(BASE_PATH_MODULE, () => ({ + lmUrl: (path) => `/lm${path}`, +})); + +async function runSave(options, responseBody) { + let captured = null; + globalThis.fetch = vi.fn(async (url, init) => { + captured = { url, init }; + return { json: async () => responseBody }; + }); + + const { saveRecipeDirectly } = await import(UTILS_MODULE); + await saveRecipeDirectly(options); + return captured; +} + +describe('saveRecipeDirectly', () => { + beforeEach(() => { + graphToPrompt.mockResolvedValue({ + workflow: { nodes: [{ id: 1 }], last_node_id: 1 }, + output: { 1: { class_type: 'KSampler' } }, + }); + }); + + afterEach(() => { + delete globalThis.fetch; + vi.clearAllMocks(); + }); + + it('posts no workflow by default', async () => { + const captured = await runSave(undefined, { success: true, has_workflow: false }); + + expect(captured.init.body).toBe('{}'); + expect(JSON.parse(captured.init.body)).not.toHaveProperty('workflow'); + }); + + it('embeds the UI-format graph when asked', async () => { + const captured = await runSave( + { embedWorkflow: true }, + { success: true, has_workflow: true } + ); + + const body = JSON.parse(captured.init.body); + expect(body.workflow).toEqual({ nodes: [{ id: 1 }], last_node_id: 1 }); + expect(captured.init.headers['Content-Type']).toBe('application/json'); + }); + + it('reports a skipped oversized workflow as a warning', async () => { + await runSave( + { embedWorkflow: true }, + { success: true, has_workflow: false, workflow_skipped: 'too_large' } + ); + + const lastToast = toastAdd.mock.calls.at(-1)[0]; + expect(lastToast.severity).toBe('warn'); + expect(lastToast.summary).toBe('Recipe Saved without Workflow'); + expect(lastToast.detail).toContain('too large'); + }); + + it('reports a successful embed distinctly from a plain save', async () => { + await runSave( + { embedWorkflow: true }, + { success: true, has_workflow: true } + ); + + const lastToast = toastAdd.mock.calls.at(-1)[0]; + expect(lastToast.summary).toBe('Recipe Saved with Workflow'); + }); +}); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 585b4101..a5749fe3 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -208,6 +208,11 @@ class StubAnalysisService: self.remote_calls: List[Optional[str]] = [] self.local_calls: List[Optional[str]] = [] self.local_ignore_recipe_metadata_calls: List[bool] = [] + self.widget_analysis_calls: List[Any] = [] + self.widget_result = SimpleNamespace( + payload={"metadata": {"loras": ""}, "image_bytes": b"widget-image"}, + status=200, + ) self.result = SimpleNamespace(payload={"loras": []}, status=200) self._recipe_parser_factory: Any = None StubAnalysisService.instances.append(self) @@ -242,7 +247,8 @@ class StubAnalysisService: return self.result async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace: - return SimpleNamespace(payload={"metadata": {}, "image_bytes": b""}, status=200) + self.widget_analysis_calls.append(recipe_scanner) + return self.widget_result class StubPersistenceService: @@ -252,6 +258,7 @@ class StubPersistenceService: def __init__(self, **_: Any) -> None: self.save_calls: List[Dict[str, Any]] = [] + self.widget_calls: List[Dict[str, Any]] = [] self.delete_calls: List[str] = [] self.move_calls: List[Dict[str, str]] = [] self.update_calls: List[Dict[str, Any]] = [] @@ -359,9 +366,24 @@ class StubPersistenceService: ) async def save_recipe_from_widget( - self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes + self, + *, + recipe_scanner, + metadata: Dict[str, Any], + image_bytes: bytes, + workflow: Any = None, ) -> SimpleNamespace: # pragma: no cover - return SimpleNamespace(payload={"success": True}, status=200) + self.widget_calls.append( + { + "recipe_scanner": recipe_scanner, + "metadata": metadata, + "image_bytes": image_bytes, + "workflow": workflow, + } + ) + return SimpleNamespace( + payload={"success": True, "has_workflow": workflow is not None}, status=200 + ) class StubSharingService: @@ -481,6 +503,33 @@ async def recipe_harness( StubSharingService.instances.clear() +async def test_save_from_widget_forwards_workflow_body(monkeypatch, tmp_path: Path) -> None: + """The opt-in workflow arrives through a real JSON body and is handed to + the persistence layer; the response reports whether it was embedded.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + workflow = {"nodes": [{"id": 1}], "last_node_id": 1} + + response = await harness.client.post( + "/api/lm/recipes/save-from-widget", json={"workflow": workflow} + ) + payload = await response.json() + + assert response.status == 200 + assert payload["has_workflow"] is True + assert harness.persistence.widget_calls[0]["workflow"] == workflow + + +async def test_save_from_widget_without_body_still_saves(monkeypatch, tmp_path: Path) -> None: + """The long-standing body-less POST must keep working unchanged.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post("/api/lm/recipes/save-from-widget") + payload = await response.json() + + assert response.status == 200 + assert payload["has_workflow"] is False + assert harness.persistence.widget_calls[0]["workflow"] is None + + async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> None: async with recipe_harness(monkeypatch, tmp_path) as harness: recipe_path = harness.tmp_dir / "recipes" / "demo.png" diff --git a/tests/routes/test_recipe_widget_save.py b/tests/routes/test_recipe_widget_save.py new file mode 100644 index 00000000..b44ce934 --- /dev/null +++ b/tests/routes/test_recipe_widget_save.py @@ -0,0 +1,129 @@ +"""Handler tests for the widget "Save Recipe" endpoint. + +Covers the opt-in workflow body: the endpoint historically received no body at +all, so a missing, empty or malformed body must degrade to "no workflow" +rather than failing the save. +""" + +from __future__ import annotations + +import json +import logging +from types import SimpleNamespace +from typing import Any + +import pytest + +from py.routes.handlers.recipe_handlers import RecipeManagementHandler + + +async def _noop_ensure() -> None: + return None + + +class FakeRequest: + """Minimal request double exposing the optional-body contract.""" + + def __init__( + self, + *, + body: Any = None, + can_read_body: bool = True, + json_raises: bool = False, + ) -> None: + self._body = body + self.can_read_body = can_read_body + self._json_raises = json_raises + + async def json(self) -> Any: + if self._json_raises or self._body is None: + raise ValueError("no JSON body") + return self._body + + +class CapturingPersistence: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def save_recipe_from_widget(self, **kwargs: Any) -> SimpleNamespace: + self.calls.append(kwargs) + return SimpleNamespace( + payload={"success": True, "has_workflow": bool(kwargs.get("workflow"))}, + status=200, + ) + + +def _make_handler(persistence: CapturingPersistence) -> RecipeManagementHandler: + analysis_service = SimpleNamespace( + analyze_widget_metadata=lambda **kwargs: _analysis_result() + ) + + return RecipeManagementHandler( + ensure_dependencies_ready=_noop_ensure, + recipe_scanner_getter=lambda: object(), + logger=logging.getLogger(__name__), + persistence_service=persistence, # pyright: ignore[reportArgumentType] + analysis_service=analysis_service, # pyright: ignore[reportArgumentType] + downloader_factory=lambda: None, + civitai_client_getter=lambda: None, + ) + + +async def _analysis_result() -> SimpleNamespace: + return SimpleNamespace( + payload={"metadata": {"loras": ""}, "image_bytes": b"image"} + ) + + +@pytest.mark.asyncio +async def test_widget_save_forwards_workflow_from_json_body(): + persistence = CapturingPersistence() + handler = _make_handler(persistence) + workflow = {"nodes": [{"id": 1}]} + + response = await handler.save_recipe_from_widget( + FakeRequest(body={"workflow": workflow}) # type: ignore[arg-type] + ) + + assert response.status == 200 + assert persistence.calls[0]["workflow"] == workflow + + +@pytest.mark.asyncio +async def test_widget_save_without_body_passes_no_workflow(): + persistence = CapturingPersistence() + handler = _make_handler(persistence) + + response = await handler.save_recipe_from_widget( + FakeRequest(can_read_body=False) # type: ignore[arg-type] + ) + + assert response.status == 200 + assert persistence.calls[0]["workflow"] is None + + +@pytest.mark.asyncio +async def test_widget_save_tolerates_malformed_body(): + persistence = CapturingPersistence() + handler = _make_handler(persistence) + + await handler.save_recipe_from_widget( + FakeRequest(json_raises=True) # type: ignore[arg-type] + ) + await handler.save_recipe_from_widget( + FakeRequest(body=["not", "an", "object"]) # type: ignore[arg-type] + ) + + assert [call["workflow"] for call in persistence.calls] == [None, None] + + +@pytest.mark.asyncio +async def test_widget_save_reports_embedded_workflow_in_response(): + persistence = CapturingPersistence() + handler = _make_handler(persistence) + + response = await handler.save_recipe_from_widget( + FakeRequest(body={"workflow": {"nodes": []}}) # type: ignore[arg-type] + ) + + assert json.loads(response.text)["has_workflow"] is True diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index 410e8f1b..9263b334 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -50,6 +50,13 @@ class DummyExifUtils: self.embedded_workflows.append((image_path, workflow)) return image_path + def normalise_workflow(self, workflow): + if isinstance(workflow, str): + return workflow or None + if isinstance(workflow, (dict, list)): + return json.dumps(workflow) + return None + def extract_image_metadata(self, path): return {} @@ -981,6 +988,137 @@ async def test_save_recipe_from_widget_enriches_checkpoint_from_local_cache(tmp_ } +@pytest.mark.asyncio +async def test_save_recipe_from_widget_embeds_opted_in_workflow(tmp_path): + """Opt-in widget saves embed the live graph so the recipe can send its + workflow back to ComfyUI, mirroring imported recipes.""" + class DummyScanner: + def __init__(self, root): + self.recipes_dir = str(root) + self.added = [] + + async def get_local_lora(self, name): # pragma: no cover - no loras + return None + + async def add_recipe(self, recipe_data): + self.added.append(recipe_data) + + image_buffer = BytesIO() + Image.new("RGB", (96, 48), color="navy").save( + image_buffer, format="PNG" + ) + + scanner = DummyScanner(tmp_path) + service = RecipePersistenceService( + exif_utils=ExifUtils, + card_preview_width=64, + logger=logging.getLogger("test"), + ) + + workflow = {"nodes": [{"id": 1}], "last_node_id": 1} + result = await service.save_recipe_from_widget( + recipe_scanner=scanner, + metadata={"loras": "", "prompt": "a calm scene"}, + image_bytes=image_buffer.getvalue(), + workflow=workflow, + ) + + assert result.payload["has_workflow"] is True + assert "workflow_skipped" not in result.payload + + stored = json.loads(Path(result.payload["json_path"]).read_text()) + assert stored["has_workflow"] is True + assert ExifUtils._load_structured_metadata(result.payload["image_path"])[ + "workflow" + ] == json.dumps(workflow) + assert scanner.added[0]["has_workflow"] is True + + +@pytest.mark.asyncio +async def test_save_recipe_from_widget_without_workflow_stays_unflagged(tmp_path): + """The default action must keep saving a workflow-free preview.""" + + class DummyScanner: + def __init__(self, root): + self.recipes_dir = str(root) + + async def get_local_lora(self, name): # pragma: no cover - no loras + return None + + async def add_recipe(self, recipe_data): + return None + + image_buffer = BytesIO() + Image.new("RGB", (96, 48), color="navy").save( + image_buffer, format="PNG" + ) + + service = RecipePersistenceService( + exif_utils=ExifUtils, + card_preview_width=64, + logger=logging.getLogger("test"), + ) + + result = await service.save_recipe_from_widget( + recipe_scanner=DummyScanner(tmp_path), + metadata={"loras": "", "prompt": "a calm scene"}, + image_bytes=image_buffer.getvalue(), + ) + + assert result.payload["has_workflow"] is False + assert ExifUtils._load_structured_metadata(result.payload["image_path"])[ + "workflow" + ] is None + + +@pytest.mark.asyncio +async def test_save_recipe_from_widget_skips_oversized_workflow( + tmp_path, monkeypatch +): + """A pathological graph is dropped instead of inflating the preview.""" + monkeypatch.setattr( + "py.services.recipes.persistence_service.MAX_WORKFLOW_EMBED_BYTES", 32 + ) + + class DummyScanner: + def __init__(self, root): + self.recipes_dir = str(root) + self.added = [] + + async def get_local_lora(self, name): # pragma: no cover - no loras + return None + + async def add_recipe(self, recipe_data): + self.added.append(recipe_data) + + image_buffer = BytesIO() + Image.new("RGB", (96, 48), color="navy").save( + image_buffer, format="PNG" + ) + + scanner = DummyScanner(tmp_path) + service = RecipePersistenceService( + exif_utils=ExifUtils, + card_preview_width=64, + logger=logging.getLogger("test"), + ) + + workflow = {"nodes": [{"id": index} for index in range(20)]} + result = await service.save_recipe_from_widget( + recipe_scanner=scanner, + metadata={"loras": "", "prompt": "a calm scene"}, + image_bytes=image_buffer.getvalue(), + workflow=workflow, + ) + + assert result.payload["workflow_skipped"] == "too_large" + assert result.payload["has_workflow"] is False + assert ExifUtils._load_structured_metadata(result.payload["image_path"])[ + "workflow" + ] is None + assert scanner.added[0]["has_workflow"] is False + + @pytest.mark.asyncio async def test_move_recipe_updates_paths(tmp_path): exif_utils = DummyExifUtils() diff --git a/web/comfyui/loras_widget_events.js b/web/comfyui/loras_widget_events.js index 8037cd92..f89900c3 100644 --- a/web/comfyui/loras_widget_events.js +++ b/web/comfyui/loras_widget_events.js @@ -873,6 +873,19 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render } ); + // Save recipe with the current graph embedded. Kept opt-in rather than + // folded into "Save Recipe": the workflow dwarfs every other metadata field + // and can carry sensitive widget values, so it stays an explicit choice. + const saveWithWorkflowOption = createMenuItem( + 'Save Recipe with Workflow', + '', + () => { + menu.remove(); + document.removeEventListener('click', closeMenu); + saveRecipeDirectly({ embedWorkflow: true }); + } + ); + // Move Up option with arrow up icon const moveUpOption = createMenuItem( 'Move Up', @@ -982,6 +995,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render menu.appendChild(copyTriggerWordsOption); menu.appendChild(separator2); menu.appendChild(saveOption); + menu.appendChild(saveWithWorkflowOption); document.body.appendChild(menu); diff --git a/web/comfyui/loras_widget_utils.js b/web/comfyui/loras_widget_utils.js index 7f552dff..5af71cdc 100644 --- a/web/comfyui/loras_widget_utils.js +++ b/web/comfyui/loras_widget_utils.js @@ -460,15 +460,29 @@ export function syncClipStrengthIfCollapsed(loraData) { } // Function to directly save the recipe without dialog -export async function saveRecipeDirectly() { +export async function saveRecipeDirectly({ embedWorkflow = false } = {}) { try { const prompt = await app.graphToPrompt(); console.log('Prompt:', prompt); // for debugging purposes + + // Embedding the graph is opt-in: it is by far the largest metadata field + // and its widget values can contain sensitive data (paths, API keys). The + // UI-format graph is sent rather than the API prompt so node layout and + // groups survive — that is what "Send Workflow to ComfyUI" restores. + const requestBody = {}; + if (embedWorkflow) { + if (prompt && prompt.workflow) { + requestBody.workflow = prompt.workflow; + } else { + showToast('No workflow available to embed; saving the recipe without it', 'warning'); + } + } + // Show loading toast if (app && app.extensionManager && app.extensionManager.toast) { app.extensionManager.toast.add({ severity: 'info', - summary: 'Saving Recipe', + summary: embedWorkflow ? 'Saving Recipe with Workflow' : 'Saving Recipe', detail: 'Please wait...', life: 2000 }); @@ -476,7 +490,9 @@ export async function saveRecipeDirectly() { // Send the request to the backend API const response = await fetch(lmUrl('/api/lm/recipes/save-from-widget'), { - method: 'POST' + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) }); const result = await response.json(); @@ -484,12 +500,23 @@ export async function saveRecipeDirectly() { // Show result toast if (app && app.extensionManager && app.extensionManager.toast) { if (result.success) { - app.extensionManager.toast.add({ - severity: 'success', - summary: 'Recipe Saved', - detail: 'Recipe has been saved successfully', - life: 3000 - }); + let severity = 'success'; + let summary = embedWorkflow ? 'Recipe Saved with Workflow' : 'Recipe Saved'; + let detail = embedWorkflow + ? 'Recipe and the current workflow have been saved' + : 'Recipe has been saved successfully'; + + if (embedWorkflow && result.workflow_skipped === 'too_large') { + severity = 'warn'; + summary = 'Recipe Saved without Workflow'; + detail = 'The workflow is too large to embed; the recipe was saved without it'; + } else if (embedWorkflow && result.has_workflow !== true) { + severity = 'warn'; + summary = 'Recipe Saved without Workflow'; + detail = 'The workflow could not be embedded in the recipe image'; + } + + app.extensionManager.toast.add({ severity, summary, detail, life: 5000 }); } else { app.extensionManager.toast.add({ severity: 'error',