Files
DemonGatanjieuandClaude Opus 5.5 100d0dbac1 Make saves lighter: a page by canvas, one way out to a workflow card
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>
2026-11-04 22:23:31 +08:00

278 lines
14 KiB
JavaScript

/**
* The panel under a workflow's preset tabs for one set of values (the workflow's own, or a saved
* preset): its name with Apply to canvas, ✦, ⇄ and ⋯, and for a preset how many values it sets
* unlike the workflow's own (orange in the flow above); for the workflow's own, its cover, notes
* and tags; its focus values (重点参数, ui_recipe_focus.js; each node value has a ☆ to mark one);
* the main values (steps, CFG, sampler…, the model and LoRAs); the prompts; the pictures
* made with these values (each ⇄ to compare); the models (the workflow's own only) and every
* node's values, folded (this browser remembers which the viewer left open). A tip says what a
* set is for while the workflow has none, and where ⇄ is on a set until the viewer has used it.
* A new set being made shows its editor instead.
*/
import { translate } from './locales.js';
import { applyRecipeParametersToCanvas } from './recipe_actions.js';
import { appendText, button, dateText } from './ui_recipe_detail_dom.js';
import { formatRecipeResolution } from './ui_recipe_parameter_utils.js';
import { renderParameterNotebookEditor, renderPromptSection, renderRawNodesLazy } from './ui_recipe_parameters.js';
import { renderImageGrid } from './ui_recipe_gallery.js';
import { renderRecipeInfo } from './ui_recipe_info.js';
import { modelDisplayName, renderRecipeModels } from './ui_recipe_models.js';
import { deleteSet, differenceNote, editNewSet, listedSets, renameSet, saveCanvasAsSet } from './ui_recipe_sets.js';
import { openParamCompare } from './ui_param_compare.js';
import { openPopupMenu } from './ui_popup_menu.js';
import { focusTest } from './recipe_focus.js';
import { drawFocus, focusStars } from './ui_recipe_focus.js';
import { handWorkflowToAi } from './ui_ai_handoff.js';
const t = (key, params) => translate(key, params);
const FOLDS_KEY = 'anomalous_wfd_folds';
const COMPARE_TIP_KEY = 'anomalous_wfd_compare_tip_done';
/** This browser's memory of the details (folds left open, tips seen); without storage it just forgets. */
function stored(key, fallback) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : JSON.parse(value);
} catch (error) {
return fallback;
}
}
function store(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn('Could not remember a workflow details choice:', error);
}
}
function section(parent, title, className = '') {
const box = document.createElement('section');
box.className = `anomalous-wfd-section ${className}`.trim();
const head = document.createElement('div');
head.className = 'anomalous-wfd-section-head';
appendText(head, 'h3', title);
box.appendChild(head);
parent.appendChild(box);
return box;
}
/**
* A folded section: its body is drawn the first time it opens. It opens as the viewer last left
* it (`name`), or anyway when `open`; `note` ({ text, warn }) follows the title.
*/
function foldedSection(parent, { title, name, note = null, open = false }, draw) {
const box = document.createElement('details');
box.className = 'anomalous-wfd-section is-folded';
const summary = appendText(box, 'summary', title);
if (note) appendText(summary, 'span', note.text, `anomalous-wfd-fold-note${note.warn ? ' is-warn' : ''}`);
summary.addEventListener('click', () => store(FOLDS_KEY, { ...stored(FOLDS_KEY, {}), [name]: !box.open }));
let drawn = false;
const drawOnce = () => {
if (drawn || !box.open) return;
drawn = true;
draw(box);
};
box.addEventListener('toggle', drawOnce);
parent.appendChild(box);
box.open = open || Boolean(stored(FOLDS_KEY, {})[name]);
drawOnce();
}
/** The models' state after the title: how many are missing, or that all are here (once checked). */
function modelsNote(references) {
if (!references.some(ref => ref.currentAvailability)) return null;
const missing = references.filter(ref => ref.currentAvailability === 'missing').length;
return missing ? { text: t('recipeStatusMissing', { count: missing }), warn: true } : { text: t('recipeStatusReady') };
}
/** No saved set yet: what one is for, with Save the canvas at hand. */
function noSetsTip(main, ctx) {
const tip = appendText(main, 'div', '', 'anomalous-wfd-tip');
const text = appendText(tip, 'div', '', 'anomalous-wfd-tip-text');
appendText(text, 'strong', t('workflowSetsTipTitle'));
appendText(text, 'p', t('workflowSetsTip'));
const save = button(tip, `+ ${t('workflowSetFromCanvas')}`, 'anomalous-wfd-tip-action');
save.onclick = async () => {
save.disabled = true;
await saveCanvasAsSet(ctx);
save.disabled = false;
};
}
/** On a saved set, until the viewer has pressed ⇄ or closed it: what ⇄ shows. */
function compareTip(main) {
if (stored(COMPARE_TIP_KEY, false)) return;
const tip = appendText(main, 'div', '', 'anomalous-wfd-tip is-compare');
appendText(tip, 'p', t('workflowCompareTip'));
const done = button(tip, t('workflowTipDone'), 'anomalous-wfd-tip-action');
done.onclick = () => {
store(COMPARE_TIP_KEY, true);
tip.remove();
};
}
/** Writes `source`'s values onto the canvas's matching nodes and says how it went. */
function applyToCanvas(source, applyButton, status) {
applyButton.disabled = true;
status.classList.remove('is-error');
try {
const result = applyRecipeParametersToCanvas(source);
status.textContent = t('recipeParameterApplied', { count: result.widgets });
} catch (error) {
console.error('Could not apply parameter set:', error);
const key = {
recipe_parameter_skeleton_mismatch: 'recipeParameterSkeletonMismatch',
recipe_parameter_widget_mismatch: 'recipeParameterWidgetMismatch',
recipe_parameter_node_unavailable: 'recipeParameterNodeUnavailable',
}[error.code] || 'recipeParameterApplyError';
status.textContent = `${t(key)} ${error.message || ''}`.trim();
status.classList.add('is-error');
} finally {
applyButton.disabled = false;
}
}
function setHead(main, ctx, notebook, source) {
const { recipe } = ctx;
const name = notebook ? notebook.name || t('recipeParameterUntitled') : t('workflowOwnValues');
const head = document.createElement('div');
head.className = 'anomalous-wfd-set-head';
const titles = document.createElement('div');
titles.className = 'anomalous-wfd-set-titles';
appendText(titles, 'h2', name, 'anomalous-wfd-set-title');
const meta = appendText(titles, 'small', notebook ? dateText(notebook.timestamp) : t('workflowOwnValuesHint'), 'anomalous-wfd-muted');
const differs = notebook && differenceNote(notebook, recipe);
if (differs?.differs) appendText(meta, 'span', t('workflowPresetMarked', { count: differs.count }), 'anomalous-wfd-set-differs');
const actions = document.createElement('div');
actions.className = 'anomalous-wfd-actions';
const status = document.createElement('small');
status.className = 'anomalous-wfd-apply-status';
const apply = button(actions, t('recipeParameterApply'), 'anomalous-wfd-primary');
apply.onclick = () => applyToCanvas(source, apply, status);
const own = { workflow: recipe.workflow, label: t('workflowOwnValues') };
const mine = { workflow: source.workflow, label: name };
const hand = button(actions, `✦ ${t('aiHand')}`, 'anomalous-wf-filter');
hand.title = t('workflowHandHint');
// the AI runs it by name: run_workflow(workflow="workflow / preset") applies the preset (api/mcp_fill.py)
hand.onclick = () => void handWorkflowToAi(ctx.owner, notebook ? `${recipe.name} / ${notebook.name}` : recipe.name);
const compare = button(actions, `⇄ ${t('workflowCompare')}`, 'anomalous-wf-filter');
compare.onclick = () => {
store(COMPARE_TIP_KEY, true);
main.querySelector('.anomalous-wfd-tip.is-compare')?.remove();
openPopupMenu(compare, [
...(notebook ? [{ label: t('compareWithDefault'), run: () => openParamCompare(own, mine, { focus: focusTest(recipe) }) }] : []),
{ label: t('compareWithCanvas'), run: () => openParamCompare(mine, { canvas: true }, { focus: focusTest(recipe) }) },
]);
};
const more = button(actions, '⋯', 'anomalous-wf-icon-btn');
more.title = t('notebookMore');
more.onclick = () => openPopupMenu(more, [
{ label: t('workflowSetCopy'), run: () => editNewSet(ctx, source) },
...(notebook ? [
{ label: t('recipeParameterRename'), run: () => renameSet(ctx, notebook) },
{ label: t('recipeParameterDelete'), danger: true, run: () => deleteSet(ctx, notebook) },
] : []),
]);
head.append(titles, actions);
main.append(head, status);
return { own, mine };
}
/** The values one sees first: tiles for steps, CFG and the like, then the model and LoRAs. */
function mainValues(main, params) {
const box = section(main, t('workflowMainValues'));
const tiles = document.createElement('div');
tiles.className = 'anomalous-wfd-stats';
for (const [key, value] of [
['recipeDetailSteps', params.steps],
['recipeDetailCFG', params.cfg],
['recipeDetailSampler', params.sampler_name || params.samplers],
['recipeDetailScheduler', params.scheduler],
['recipeDetailResolution', formatRecipeResolution(params.resolution)],
['recipeDetailDenoise', params.denoise],
]) {
const shown = Array.isArray(value) ? value.join(', ') : value;
if (shown === undefined || shown === null || shown === '') continue;
const tile = document.createElement('div');
tile.className = 'anomalous-wfd-stat';
appendText(tile, 'span', t(key), 'anomalous-wfd-stat-label');
const strong = appendText(tile, 'strong', String(shown), 'anomalous-wfd-stat-value');
strong.title = String(shown);
tiles.appendChild(tile);
}
if (tiles.childElementCount) box.appendChild(tiles);
const models = document.createElement('div');
models.className = 'anomalous-wfd-model-lines';
const base = params.baseModel || params.baseModels;
if (base) {
const line = appendText(models, 'div', '', 'anomalous-wfd-model-line');
appendText(line, 'span', t('recipeDetailBaseModel'), 'anomalous-wfd-stat-label');
appendText(line, 'span', modelDisplayName(base), 'anomalous-wfd-model-name').title = String(base);
}
for (const lora of Array.isArray(params.loras) ? params.loras : []) {
const loraName = typeof lora === 'object' && lora ? lora.name || '' : String(lora);
const line = appendText(models, 'div', '', 'anomalous-wfd-model-line');
appendText(line, 'span', 'LoRA', 'anomalous-wfd-stat-label');
appendText(line, 'span', modelDisplayName(loraName), 'anomalous-wfd-model-name').title = loraName;
if (typeof lora === 'object' && lora) appendText(line, 'span', `${lora.strength_model ?? 1} / ${lora.strength_clip ?? 1}`, 'anomalous-wfd-model-weight');
}
if (models.childElementCount) box.appendChild(models);
if (!box.querySelector('.anomalous-wfd-stats, .anomalous-wfd-model-lines')) appendText(box, 'p', t('recipeDetailNoSavedParameters'), 'anomalous-wfd-muted');
}
/** The pictures made with these values, each ⇄ to compare (with the workflow's own values too, for a set). */
function pictures(main, ctx, sides) {
const { owner, parameterGallery, notebook } = ctx;
const count = parameterGallery.status === 'ready' ? ` (${parameterGallery.images.length})` : '';
const box = section(main, `${t('workflowSetPictures')}${count}`);
if (parameterGallery.status === 'ready' && parameterGallery.images.length) {
renderImageGrid(box, owner, parameterGallery.images, {
compareOthers: notebook ? [{ label: t('compareWithDefault'), side: sides.own }] : [],
focus: focusTest(ctx.recipe),
});
return;
}
const key = parameterGallery.status === 'error' ? 'recipeGalleryLoadError'
: parameterGallery.status === 'ready' ? 'workflowSetNoPictures' : 'recipeGalleryLoading';
appendText(box, 'p', t(key), 'anomalous-wfd-muted');
}
/** Draws the set view into `main`. ctx: owner, recipe, parameterState, parameterGallery, references, notebook, openModel, rerender. */
export function renderSetView(main, ctx) {
const { owner, recipe, parameterState, references, notebook } = ctx;
main.replaceChildren();
if (parameterState.editor) {
renderParameterNotebookEditor(main, owner, recipe, parameterState, ctx.rerender);
return;
}
const source = notebook?.data?.workflow ? notebook.data : recipe;
const sides = setHead(main, ctx, notebook, source);
if (notebook) compareTip(main);
else if (parameterState.status === 'ready' && !listedSets(recipe, parameterState.notebooks).length) noSetsTip(main, ctx);
if (!notebook) renderRecipeInfo(main, owner, recipe);
const focusSlot = document.createElement('div');
focusSlot.className = 'anomalous-wfd-focus-slot';
main.appendChild(focusSlot);
drawFocus(focusSlot, ctx, source);
mainValues(main, source.params || {});
renderPromptSection(section(main, t('recipeDetailPrompts')), owner, recipe, source, ctx.rerender);
pictures(main, ctx, sides);
if (!notebook) {
const note = modelsNote(references);
foldedSection(main, {
title: `${t('recipeDetailModelComposition')} (${references.length})`,
name: 'models',
note,
open: Boolean(note?.warn),
}, (box) => {
const list = document.createElement('div');
box.appendChild(list);
renderRecipeModels(list, owner, recipe, references, ctx.openModel);
});
}
foldedSection(main, { title: t('workflowAllNodeValues'), name: 'nodes' }, box => renderRawNodesLazy(box, source, {
star: focusStars(ctx, focusSlot, source),
}));
}