Presets, combos and Add to saves took more to keep sensible than they gave, so a save now only becomes a new workflow card and only the canvas makes one. The panel above the bar moves to the Saves page, a part for each canvas, open or not, with each save's pictures and its runs: the whole group in order, or one save alone. Running moves to snapshot_run.js for the bar and the page; what is clicked goes in in click order, and closed canvases run too. A run taken off ComfyUI's queue is now forgotten at once, so "runs to go" does not stay wrong for a day. A first save on an unsaved tab now reaches ComfyUI's draft of it, so a reload right after keeps the tab's saves on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
8.0 KiB
JavaScript
153 lines
8.0 KiB
JavaScript
/**
|
|
* The presets of a workflow's details: the row of tabs under the flow (the workflow's own values,
|
|
* then each saved preset unlike them, newest first, each with how many values it changes; + to add
|
|
* one; all its pictures and its versions at the end) and the preset actions (save the canvas, new,
|
|
* rename, delete).
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { translate } from './locales.js';
|
|
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
|
|
import { assertRecipeSkeleton } from './recipe_actions.js';
|
|
import { captureRecipeDraft } from './recipe_parser.js';
|
|
import { appendText, button, dateText } from './ui_recipe_detail_dom.js';
|
|
import { cloneJson } from './ui_recipe_parameter_utils.js';
|
|
import { nextSetName, setDifferences } from './ui_param_set_keys.js';
|
|
import { openPopupMenu } from './ui_popup_menu.js';
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
|
|
const post = (url, body) => fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}).then(async (response) => {
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok || (payload.status && payload.status !== 'success')) throw new Error(payload.message || `HTTP ${response.status}`);
|
|
return payload;
|
|
});
|
|
|
|
/** The saved sets worth listing: those whose values differ from the workflow's own (saving it keeps one equal). */
|
|
export function listedSets(recipe, notebooks) {
|
|
const own = recipe?.parameter_signature?.value;
|
|
return (notebooks || []).filter(notebook => !own || notebook?.data?.parameter_signature?.value !== own);
|
|
}
|
|
|
|
/** Saves the canvas's values as a new set of this workflow, when its nodes match; selects it. */
|
|
export async function saveCanvasAsSet(ctx) {
|
|
const { owner, recipe, parameterState } = ctx;
|
|
try {
|
|
if (!app.graph?.serialize) throw new Error('recipe_parameter_canvas_unavailable');
|
|
const current = captureRecipeDraft(app.graph);
|
|
assertRecipeSkeleton(recipe.workflow, current.workflow);
|
|
const { filename } = await post('/anomalous/save_parameter', {
|
|
name: nextSetName(recipe, parameterState.notebooks),
|
|
tags: recipe.tags || [],
|
|
notes: recipe.notes || '',
|
|
params: current.metadata,
|
|
workflow: current.workflow,
|
|
recipe_filename: owner.recipeDetailFilename,
|
|
});
|
|
parameterState.selectedFilename = filename || null;
|
|
await parameterState.refresh?.(true);
|
|
ctx.select('set', filename || null);
|
|
} catch (error) {
|
|
console.error('Could not save the canvas as a parameter set:', error);
|
|
await anomalousAlert(error.code === 'recipe_parameter_skeleton_mismatch'
|
|
? t('recipeParameterSkeletonMismatch')
|
|
: t('recipeParameterReadCurrentError'));
|
|
}
|
|
}
|
|
|
|
/** A new set to edit, from `source`'s values (the workflow's own when none). */
|
|
export function editNewSet(ctx, source) {
|
|
const { recipe, parameterState } = ctx;
|
|
const draft = cloneJson({ workflow: (source || recipe).workflow, params: (source || recipe).params || {} });
|
|
if (!draft?.workflow) return;
|
|
parameterState.editor = { draft, name: nextSetName(recipe, parameterState.notebooks) };
|
|
ctx.select('set', parameterState.selectedFilename);
|
|
}
|
|
|
|
export async function renameSet(ctx, notebook) {
|
|
const current = notebook.name || t('recipeParameterUntitled');
|
|
const name = (await anomalousPrompt(t('recipeParameterRenamePrompt'), current))?.trim();
|
|
if (!name || name === current) return;
|
|
try {
|
|
await post('/anomalous/rename_parameter', { filename: notebook.filename, name });
|
|
await ctx.parameterState.refresh?.(true);
|
|
} catch (error) {
|
|
console.error('Could not rename parameter set:', error);
|
|
await anomalousAlert(t('recipeParameterRenameError'));
|
|
}
|
|
}
|
|
|
|
export async function deleteSet(ctx, notebook) {
|
|
const name = notebook.name || t('recipeParameterUntitled');
|
|
if (!await anomalousConfirm(t('recipeParameterDeleteConfirm', { name }))) return;
|
|
const { parameterState } = ctx;
|
|
try {
|
|
await post('/anomalous/delete_parameter', { filename: notebook.filename });
|
|
if (parameterState.selectedFilename === notebook.filename) parameterState.selectedFilename = null;
|
|
parameterState.editor = null;
|
|
await parameterState.refresh?.(true);
|
|
ctx.select('set', parameterState.selectedFilename);
|
|
} catch (error) {
|
|
console.error('Could not delete parameter set:', error);
|
|
await anomalousAlert(t('recipeParameterDeleteError'));
|
|
}
|
|
}
|
|
|
|
/** One tab of the row: `name` over `note`; `className` adds is-own, is-active and the like. */
|
|
function tab(row, name, note, className, onClick) {
|
|
const item = button(row, '', `anomalous-wfd-tab ${className}`.trim());
|
|
appendText(item, 'span', name, 'anomalous-wfd-tab-name').title = name;
|
|
if (note) appendText(item, 'span', note.text, `anomalous-wfd-tab-note${note.differs ? ' is-differs' : ''}`);
|
|
item.setAttribute('role', 'tab');
|
|
item.setAttribute('aria-selected', String(className.includes('is-active')));
|
|
item.onclick = onClick;
|
|
return item;
|
|
}
|
|
|
|
/** A preset's line under its name: how many values it sets unlike the workflow's own ({ text, differs, count }). */
|
|
export function differenceNote(notebook, recipe) {
|
|
const count = setDifferences(notebook, recipe);
|
|
if (count === null) return { text: dateText(notebook.timestamp), count };
|
|
return count ? { text: t('workflowPresetDiffers', { count }), differs: true, count } : { text: t('paramSetSame'), count };
|
|
}
|
|
|
|
/**
|
|
* Draws the row of tabs under the flow into `row` for the current view ({ kind: 'set' | 'gallery' |
|
|
* 'versions', filename }): the workflow's own values, then each preset unlike them, newest first,
|
|
* then + (save the canvas, or a new one from the preset shown); at the end all pictures and versions.
|
|
*/
|
|
export function renderPresetTabs(row, ctx) {
|
|
const { recipe, parameterState, gallery, history, view } = ctx;
|
|
row.replaceChildren();
|
|
const tabs = appendText(row, 'div', '', 'anomalous-wfd-tabs');
|
|
tabs.setAttribute('role', 'tablist');
|
|
tabs.setAttribute('aria-label', t('recipeParameterSnapshots'));
|
|
const showing = filename => view.kind === 'set' && view.filename === filename && !parameterState.editor;
|
|
const choose = filename => () => {
|
|
parameterState.editor = null;
|
|
ctx.select('set', filename);
|
|
};
|
|
tab(tabs, t('workflowOwnValues'), { text: t('workflowOwnValuesShort') }, `is-own${showing(null) ? ' is-active' : ''}`, choose(null)).title = t('workflowOwnValuesHint');
|
|
if (parameterState.status === 'loading') appendText(tabs, 'span', t('recipeParameterLoading'), 'anomalous-wfd-muted');
|
|
else if (parameterState.status === 'error') appendText(tabs, 'span', t('recipeParameterLoadError'), 'anomalous-wfd-muted');
|
|
for (const notebook of listedSets(recipe, parameterState.notebooks)) {
|
|
tab(tabs, notebook.name || t('recipeParameterUntitled'), differenceNote(notebook, recipe), showing(notebook.filename) ? 'is-active' : '', choose(notebook.filename));
|
|
}
|
|
if (parameterState.editor) tab(tabs, parameterState.editor.name, { text: t('workflowSetEditing') }, 'is-active is-editing', () => {});
|
|
const add = button(tabs, `+ ${t('workflowPresetAdd')}`, 'anomalous-wfd-tab-add');
|
|
add.onclick = () => openPopupMenu(add, [
|
|
{ label: t('recipeParameterReadCurrent'), run: () => saveCanvasAsSet(ctx) },
|
|
{ label: t('workflowPresetFromShown'), run: () => editNewSet(ctx, parameterState.notebooks.find(item => item.filename === view.filename)?.data) },
|
|
]);
|
|
const more = appendText(row, 'div', '', 'anomalous-wfd-views');
|
|
const pictures = gallery.status === 'ready' ? ` (${gallery.images.length})` : '';
|
|
for (const [kind, label] of [['gallery', `${t('workflowAllPictures')}${pictures}`], ['versions', `${t('recipeDetailVersions')} (${(history || []).length})`]]) {
|
|
const item = button(more, label, `anomalous-wfd-view${view.kind === kind ? ' is-active' : ''}`);
|
|
item.onclick = () => ctx.select(kind);
|
|
}
|
|
}
|