feat(recipes): opt-in workflow embedding for widget recipe saves

Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.

- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
  UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
  long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
  the existing optimize_image workflow path, derives has_workflow by detection,
  and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
This commit is contained in:
Will Miao
2026-09-29 09:03:26 +08:00
parent 69691b17a1
commit faeb66a23d
10 changed files with 607 additions and 25 deletions
@@ -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 });
});
});
@@ -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');
});
});
+52 -3
View File
@@ -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"
+129
View File
@@ -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
+138
View File
@@ -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()