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
+36 -9
View File
@@ -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',