mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-29 15:04:09 -03:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user