Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_recipes.js
T

1199 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Workflow Recipes UI, built on the same modal/card language as Notebooks. */
import { app } from '../../../scripts/app.js';
import { i18n } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import {
captureCanvasThumbnail,
captureRecipeDraft,
applyRecipeWidgetChanges,
extractRecipeParameterChoicesFromMetadata,
} from './recipe_parser.js';
import {
appendRecipeOnCanvas,
openRecipeOnCanvas,
showRecipeDetail,
} from './ui_recipe_detail.js';
const t = (key) => {
let lang = window.anomalous_browser_lang || 'zh';
if (lang.startsWith('en')) lang = 'en';
return i18n[lang]?.[key] || i18n.en?.[key] || key;
};
function formatRecipeText(key, values = {}) {
return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key));
}
async function runRecipeCardAction(actionButton, action, errorKey) {
if (!actionButton || actionButton.disabled) return;
actionButton.disabled = true;
actionButton.classList.add('is-busy');
try {
await action();
} catch (error) {
console.error('Workflow Recipe action failed:', error);
await anomalousAlert(t(errorKey));
} finally {
actionButton.disabled = false;
actionButton.classList.remove('is-busy');
}
}
function appendText(parent, tagName, text, className = '') {
const element = document.createElement(tagName);
if (className) element.className = className;
element.textContent = text;
parent.appendChild(element);
return element;
}
async function copyCardValueWithFeedback(buttonElement, value) {
const original = buttonElement.textContent;
try {
await navigator.clipboard.writeText(String(value));
buttonElement.textContent = `✓ ${t('recipeCopied')}`;
buttonElement.classList.add('copied-success');
} catch (error) {
console.warn('Could not copy recipe parameter:', error);
buttonElement.textContent = `! ${t('recipeCopyFailed')}`;
buttonElement.classList.add('copied-failure');
}
window.setTimeout(() => {
buttonElement.textContent = original;
buttonElement.classList.remove('copied-success', 'copied-failure');
}, 1200);
}
function safeThumbnail(value) {
return typeof value === 'string' && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)
? value
: null;
}
function outputImageUrl(image) {
if (!image || image.type !== 'output' || typeof image.filename !== 'string') return null;
const query = new URLSearchParams({ filename: image.filename, type: 'output' });
if (image.subfolder) query.set('subfolder', image.subfolder);
return `/view?${query.toString()}`;
}
function previewIsVideo(url) {
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
}
function appendRecipeCover(parent, url, alt) {
if (!url) return;
if (previewIsVideo(url)) {
const video = document.createElement('video');
video.className = 'anomalous-recipe-thumbnail';
video.src = url;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.onpointerenter = () => video.play().catch(() => {});
video.onpointerleave = () => {
video.pause();
video.currentTime = 0;
};
parent.appendChild(video);
return;
}
const image = document.createElement('img');
image.className = 'anomalous-recipe-thumbnail';
image.src = url;
image.alt = alt;
image.loading = 'lazy';
parent.appendChild(image);
}
async function exportRecipePackage(filename) {
const includeSnapshots = await anomalousConfirm(t('recipeExportSnapshotsConfirm'));
const includeHistory = await anomalousConfirm(t('recipeExportHistoryConfirm'));
const includeIdentity = !await anomalousConfirm(t('recipeExportRedactIdentityConfirm'));
const response = await fetch('/anomalous/export_recipe_package', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename,
include_snapshots: includeSnapshots,
include_history: includeHistory,
include_identity: includeIdentity,
}),
});
if (!response.ok) throw new Error('recipe export failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename.replace(/\.json$/i, '')}.anomalous-recipe.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function importRecipePackage(owner, file) {
const inspectResponse = await fetch('/anomalous/import_recipe_package_inspect', {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: file,
});
const inspectPayload = await inspectResponse.json();
if (!inspectResponse.ok || inspectPayload.status !== 'success') throw new Error('recipe import inspection failed');
const recipeName = inspectPayload.recipe?.name || t('recipeUntitled');
const summary = `${t('recipeImportSummary')}\n\n${recipeName}\n${t('recipeImportAssets')}: ${inspectPayload.asset_count || 0}\n${t('recipeImportHistory')}: ${inspectPayload.history_count || 0}`;
if (!await anomalousConfirm(summary)) return;
let name = recipeName;
if ((inspectPayload.existing_names || []).includes(name)) {
name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`);
if (!name?.trim()) return;
}
const commitResponse = await fetch('/anomalous/import_recipe_package_commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inspectPayload.token, collision: 'rename', name: name.trim() }),
});
const commitPayload = await commitResponse.json();
if (!commitResponse.ok || commitPayload.status !== 'success') throw new Error('recipe import commit failed');
await owner.refreshRecipes();
}
async function captureOutputThumbnail(image) {
const url = outputImageUrl(image);
if (!url) return null;
const response = await fetch(url);
if (!response.ok) return null;
const blob = await response.blob();
if (previewIsVideo(url) || /^video\//i.test(blob.type)) {
const objectUrl = URL.createObjectURL(blob);
const video = document.createElement('video');
video.src = objectUrl;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
try {
await new Promise((resolve, reject) => {
video.onloadeddata = resolve;
video.onerror = reject;
video.load();
});
if (video.duration > 0.2) {
await new Promise((resolve) => {
video.onseeked = resolve;
video.currentTime = 0.1;
});
}
const maxEdge = 720;
const scale = Math.min(1, maxEdge / Math.max(video.videoWidth, video.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
canvas.height = Math.max(1, Math.round(video.videoHeight * scale));
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/webp', 0.72);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
const bitmap = await createImageBitmap(blob);
try {
const maxEdge = 720;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/jpeg', 0.72);
} finally {
bitmap.close?.();
}
}
function compactText(value, limit = 110) {
const text = String(value || '').trim().replace(/\s+/g, ' ');
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
}
function modelDisplayName(value) {
const path = String(value || '').replace(/\\/g, '/');
const filename = path.split('/').pop() || '';
return filename.replace(/\.(?:safetensors|ckpt|pt|bin|sft)$/i, '');
}
function normaliseSearchText(value) {
const text = String(value || '').trim().toLocaleLowerCase();
try { return text.normalize('NFKC'); } catch (error) { return text; }
}
function recipeMatchesFilter(data, query, selectedTags) {
const terms = normaliseSearchText(query).split(/\s+/).filter(Boolean);
const haystack = normaliseSearchText([
data?.name || '',
data?.notes || '',
...(Array.isArray(data?.tags) ? data.tags : []),
].join(' '));
if (terms.some((term) => !haystack.includes(term))) return false;
const tags = new Set((Array.isArray(data?.tags) ? data.tags : []).map(normaliseSearchText));
for (const tag of selectedTags || []) if (!tags.has(normaliseSearchText(tag))) return false;
return true;
}
function updateRecipeFilterControls(owner, recipes) {
if (!owner.recipeTagBar) return;
owner.recipeTagBar.replaceChildren();
const tags = [...new Set((recipes || []).flatMap((item) => item?.data?.tags || []))]
.filter(Boolean)
.sort((left, right) => String(left).localeCompare(String(right)));
for (const tag of tags) {
const chip = appendText(owner.recipeTagBar, 'button', tag, 'anomalous-recipe-filter-tag');
chip.type = 'button';
chip.classList.toggle('active', owner.recipeSelectedTags?.has(tag));
chip.onclick = () => {
if (!owner.recipeSelectedTags) owner.recipeSelectedTags = new Set();
if (owner.recipeSelectedTags.has(tag)) owner.recipeSelectedTags.delete(tag);
else owner.recipeSelectedTags.add(tag);
owner.renderRecipeList(owner.recipeRecords || []);
};
}
if (!tags.length) appendText(owner.recipeTagBar, 'small', t('recipeNoTags'), 'anomalous-recipe-detail-muted');
}
function summaryValue(value, fallback = '—') {
return value === null || value === undefined || value === '' ? fallback : String(value);
}
function createBadge(text, kind = '') {
const badge = document.createElement('span');
badge.className = `anomalous-recipe-badge${kind ? ` anomalous-recipe-badge-${kind}` : ''}`;
badge.textContent = text;
return badge;
}
function displayWidgetValue(value) {
if (typeof value === 'object' && value !== null) {
try { return JSON.stringify(value); } catch (error) { return String(value); }
}
return String(value);
}
function appendPinnedParams(parent, params) {
const pinned = Array.isArray(params.pinned) ? params.pinned : [];
if (!pinned.length) return;
const section = document.createElement('section');
section.className = 'anomalous-recipe-pinned';
appendText(section, 'strong', t('recipePinnedParams'));
for (const parameter of pinned) {
const row = document.createElement('div');
row.className = 'anomalous-recipe-widget-row';
appendText(
row,
'span',
`${parameter.nodeTitle || parameter.nodeType || t('recipeUnknownNode')} · ${parameter.widgetName}`,
'anomalous-recipe-widget-name',
);
const value = displayWidgetValue(parameter.value);
appendText(row, 'code', value, 'anomalous-recipe-widget-value');
const copy = appendText(row, 'button', '⧉', 'anomalous-recipe-copy-param');
copy.type = 'button';
copy.title = t('recipeCopyParameter');
copy.onclick = () => { void copyCardValueWithFeedback(copy, value); };
section.appendChild(row);
}
parent.appendChild(section);
}
function appendNodeDetails(parent, params) {
const nodes = Array.isArray(params.nodes) ? params.nodes : [];
if (!nodes.length) return;
const details = document.createElement('details');
details.className = 'anomalous-recipe-node-details';
const summary = document.createElement('summary');
summary.textContent = `${t('recipeNodeParams')} (${summaryValue(params.nodeCount, nodes.length)})`;
details.appendChild(summary);
let rendered = false;
details.ontoggle = () => {
if (!details.open || rendered) return;
rendered = true;
const list = document.createElement('div');
list.className = 'anomalous-recipe-node-list';
for (const node of nodes) {
const nodeBlock = document.createElement('section');
nodeBlock.className = 'anomalous-recipe-node';
const nodeHeading = document.createElement('div');
nodeHeading.className = 'anomalous-recipe-node-heading';
appendText(nodeHeading, 'strong', node.title || node.type || t('recipeUnknownNode'));
if (node.title && node.type) nodeHeading.appendChild(createBadge(node.type));
if (node.module) nodeHeading.appendChild(createBadge(node.module, 'module'));
nodeBlock.appendChild(nodeHeading);
for (const widget of node.widgets || []) {
const row = document.createElement('div');
row.className = 'anomalous-recipe-widget-row';
appendText(row, 'span', widget.name, 'anomalous-recipe-widget-name');
const value = displayWidgetValue(widget.value);
appendText(row, 'code', value, 'anomalous-recipe-widget-value');
const copy = appendText(row, 'button', '⧉', 'anomalous-recipe-copy-param');
copy.type = 'button';
copy.title = t('recipeCopyParameter');
copy.onclick = () => { void copyCardValueWithFeedback(copy, value); };
nodeBlock.appendChild(row);
}
if ((node.widgetCount || 0) > (node.widgets?.length || 0)) {
appendText(nodeBlock, 'small', t('recipeSomeParamsInWorkflow'), 'anomalous-recipe-node-hint');
}
list.appendChild(nodeBlock);
}
if ((params.nodeCount || 0) > nodes.length) {
appendText(list, 'small', t('recipeMoreNodesInWorkflow'), 'anomalous-recipe-node-hint');
}
details.appendChild(list);
};
parent.appendChild(details);
}
function renderParams(parent, params = {}) {
const summary = document.createElement('div');
summary.className = 'anomalous-recipe-summary';
appendText(summary, 'div', `${t('recipeModel')}: ${summaryValue(modelDisplayName(params.baseModel))}`, 'anomalous-recipe-model');
const sampling = document.createElement('div');
sampling.className = 'anomalous-recipe-sampling';
sampling.appendChild(createBadge(`${t('recipeSteps')} ${summaryValue(params.steps)}`));
sampling.appendChild(createBadge(`CFG ${summaryValue(params.cfg)}`));
if (params.sampler_name) sampling.appendChild(createBadge(params.sampler_name, 'accent'));
if (params.scheduler) sampling.appendChild(createBadge(params.scheduler));
if (params.resolution?.width && params.resolution?.height) {
sampling.appendChild(createBadge(`${params.resolution.width}×${params.resolution.height}`));
}
summary.appendChild(sampling);
if (Array.isArray(params.loras) && params.loras.length) {
const loraRow = document.createElement('div');
loraRow.className = 'anomalous-recipe-loras';
appendText(loraRow, 'span', `${t('recipeLoras')}:`, 'anomalous-recipe-label');
for (const lora of params.loras.slice(0, 4)) {
const weight = lora.strength_model === null || lora.strength_model === undefined
? ''
: ` × ${lora.strength_model}`;
loraRow.appendChild(createBadge(`${compactText(modelDisplayName(lora.name), 42)}${weight}`, 'lora'));
}
if (params.loras.length > 4) loraRow.appendChild(createBadge(`+${params.loras.length - 4}`));
summary.appendChild(loraRow);
}
const positive = Array.isArray(params.promptPositive) ? params.promptPositive[0] : params.promptPositive;
if (positive) appendText(summary, 'p', `${t('recipePrompt')}: ${compactText(positive)}`, 'anomalous-recipe-prompt');
appendPinnedParams(summary, params);
appendNodeDetails(summary, params);
parent.appendChild(summary);
}
async function fetchRecipeBundle(filename) {
const [fullResponse, historyResponse] = await Promise.all([
fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`),
fetch(`/anomalous/recipe_history?filename=${encodeURIComponent(filename)}`),
]);
const payload = await fullResponse.json();
const historyPayload = historyResponse.ok ? await historyResponse.json() : { versions: [] };
if (!fullResponse.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
return { data: JSON.parse(JSON.stringify(payload.data)), history: historyPayload.versions || [] };
}
async function fetchRecipeData(filename) {
const response = await fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`);
const payload = await response.json();
if (!response.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
return JSON.parse(JSON.stringify(payload.data));
}
async function editRecipe(owner, recipe, filename, history = null) {
try {
const bundle = history ? { data: JSON.parse(JSON.stringify(recipe)), history } : await fetchRecipeBundle(filename);
const editable = bundle.data;
const result = await showRecipeEditDialog(owner, editable, filename, bundle.history);
if (!result || result.mode === 'restored') return;
if (result.mode === 'canvas') {
if (!await anomalousConfirm(t('recipeEditCanvasConfirm'))) return;
app.loadGraphData(editable.workflow);
app.canvas?.setDirty?.(true, true);
owner.recipeEditing = { filename, data: editable };
const saveButton = owner.recipeView?.querySelector('[data-recipe-save-current]');
if (saveButton) saveButton.textContent = t('recipeUpdateCurrent');
return;
}
applyRecipeWidgetChanges(editable.params, editable.workflow, result.changes);
const updatedChoices = extractRecipeParameterChoicesFromMetadata(editable.params, editable.workflow);
editable.name = result.name;
editable.tags = result.tags;
editable.notes = result.notes;
editable.params.pinned = updatedChoices.filter((choice) => result.pinnedKeys.has(choice.key));
const response = await fetch('/anomalous/update_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, ...editable }),
});
const updatedPayload = await response.json();
if (!response.ok || updatedPayload.status !== 'success') throw new Error('recipe update failed');
await owner.refreshRecipes();
} catch (error) {
console.error('Could not edit Workflow Recipe:', error);
await anomalousAlert(t('recipeUpdateError'));
}
}
function showRecipeSaveDialog(owner, canvasThumbnail, parameterChoices, initial = null) {
return new Promise((resolve) => {
const selection = {
thumbnail: safeThumbnail(initial?.thumbnail) || safeThumbnail(canvasThumbnail),
sourceImage: initial?.source_image || null,
pinnedKeys: new Set(),
saveModelPreviewSnapshots: initial?.presentation?.save_model_preview_snapshots === true,
};
const availablePinKeys = new Set(parameterChoices.map((choice) => choice.key));
const previousPinnedKeys = new Set((initial?.params?.pinned || []).map((pin) => pin?.key).filter(Boolean));
selection.pinnedKeys = new Set([...previousPinnedKeys].filter((key) => availablePinKeys.has(key)));
const unavailablePinCount = previousPinnedKeys.size - selection.pinnedKeys.size;
const overlay = document.createElement('div');
overlay.className = 'anomalous-recipe-dialog-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const dialog = document.createElement('div');
dialog.className = 'anomalous-recipe-dialog';
appendText(dialog, 'h3', t('recipeSaveTitle'));
const nameLabel = appendText(dialog, 'label', t('recipeName'));
const nameInput = document.createElement('input');
nameInput.className = 'anomalous-nb-select';
nameInput.type = 'text';
nameInput.maxLength = 120;
nameInput.value = initial?.name || t('recipeDefaultName');
nameLabel.appendChild(nameInput);
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
const tagsInput = document.createElement('input');
tagsInput.className = 'anomalous-nb-select';
tagsInput.type = 'text';
tagsInput.maxLength = 300;
tagsInput.placeholder = t('recipeTagsHint');
tagsInput.value = Array.isArray(initial?.tags) ? initial.tags.join(', ') : '';
tagsLabel.appendChild(tagsInput);
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
const notesInput = document.createElement('textarea');
notesInput.className = 'anomalous-nb-textarea';
notesInput.maxLength = 3000;
notesInput.placeholder = t('recipeNotesHint');
notesInput.value = initial?.notes || '';
notesLabel.appendChild(notesInput);
const coverSection = document.createElement('section');
coverSection.className = 'anomalous-recipe-save-section';
appendText(coverSection, 'strong', t('recipeBindImage'));
const coverChoices = document.createElement('div');
coverChoices.className = 'anomalous-recipe-cover-choices';
const coverPreview = document.createElement('img');
coverPreview.className = 'anomalous-recipe-dialog-preview';
coverPreview.alt = t('recipeThumbnail');
const initialPreview = selection.thumbnail || outputImageUrl(selection.sourceImage);
if (initialPreview) coverPreview.src = initialPreview;
else coverPreview.style.display = 'none';
const choiceButtons = [];
const selectCover = (button, sourceImage, previewUrl, thumbnailValue) => {
for (const choice of choiceButtons) choice.classList.toggle('selected', choice === button);
selection.sourceImage = sourceImage;
selection.thumbnail = thumbnailValue;
if (previewUrl) {
coverPreview.src = previewUrl;
coverPreview.style.display = 'block';
} else {
coverPreview.removeAttribute('src');
coverPreview.style.display = 'none';
}
};
const noneChoice = appendText(coverChoices, 'button', t('recipeNoImage'), 'anomalous-recipe-cover-choice');
noneChoice.type = 'button';
choiceButtons.push(noneChoice);
noneChoice.onclick = () => selectCover(noneChoice, null, null, null);
if (safeThumbnail(initial?.thumbnail) || initial?.source_image) {
const existingChoice = appendText(coverChoices, 'button', t('recipeKeepImage'), 'anomalous-recipe-cover-choice selected');
existingChoice.type = 'button';
choiceButtons.push(existingChoice);
existingChoice.onclick = () => selectCover(
existingChoice,
initial?.source_image || null,
safeThumbnail(initial?.thumbnail) || outputImageUrl(initial?.source_image),
safeThumbnail(initial?.thumbnail),
);
}
if (safeThumbnail(canvasThumbnail)) {
const canvasChoice = document.createElement('button');
canvasChoice.type = 'button';
canvasChoice.className = `anomalous-recipe-cover-choice${initial ? '' : ' selected'}`;
const canvasImage = document.createElement('img');
canvasImage.src = canvasThumbnail;
canvasImage.alt = t('recipeCanvasPreview');
appendText(canvasChoice, 'span', t('recipeCanvasPreview'));
canvasChoice.prepend(canvasImage);
choiceButtons.push(canvasChoice);
canvasChoice.onclick = () => selectCover(canvasChoice, null, canvasThumbnail, safeThumbnail(canvasThumbnail));
} else if (!initial) {
noneChoice.classList.add('selected');
}
const recentStatus = appendText(coverSection, 'small', t('recipeLoadingRecentImages'), 'anomalous-recipe-node-hint');
coverSection.append(coverChoices, coverPreview);
dialog.appendChild(coverSection);
const previewSnapshots = document.createElement('label');
previewSnapshots.className = 'anomalous-recipe-pin-choice';
const previewSnapshotsCheckbox = document.createElement('input');
previewSnapshotsCheckbox.type = 'checkbox';
previewSnapshotsCheckbox.checked = selection.saveModelPreviewSnapshots;
previewSnapshotsCheckbox.onchange = () => {
selection.saveModelPreviewSnapshots = previewSnapshotsCheckbox.checked;
};
const previewSnapshotCopy = document.createElement('span');
appendText(previewSnapshotCopy, 'strong', t('recipeSaveModelPreviewSnapshots'));
appendText(previewSnapshotCopy, 'small', t('recipeSaveModelPreviewSnapshotsHint'), 'anomalous-recipe-node-hint');
previewSnapshots.append(previewSnapshotsCheckbox, previewSnapshotCopy);
dialog.appendChild(previewSnapshots);
fetch('/anomalous/gallery_images?page=1&limit=12')
.then((response) => response.ok ? response.json() : Promise.reject(new Error('image list failed')))
.then((payload) => {
recentStatus.textContent = t('recipeRecentImages');
for (const imageData of payload.images || []) {
const url = outputImageUrl(imageData);
if (!url) continue;
const choice = document.createElement('button');
choice.type = 'button';
choice.className = 'anomalous-recipe-cover-choice anomalous-recipe-output-choice';
const image = document.createElement('img');
image.src = url;
image.loading = 'lazy';
image.alt = imageData.filename;
choice.appendChild(image);
choice.title = imageData.filename;
choiceButtons.push(choice);
choice.onclick = () => selectCover(choice, {
filename: imageData.filename,
subfolder: imageData.subfolder || '',
type: 'output',
}, url, null);
coverChoices.appendChild(choice);
}
})
.catch((error) => {
console.warn('Could not load recent recipe images:', error);
recentStatus.textContent = t('recipeRecentImagesUnavailable');
});
if (parameterChoices.length) {
const parameterDetails = document.createElement('details');
parameterDetails.className = 'anomalous-recipe-pin-picker';
const parameterSummary = document.createElement('summary');
parameterSummary.textContent = `${t('recipeChoosePinnedParams')} (${selection.pinnedKeys.size})`;
parameterDetails.appendChild(parameterSummary);
if (unavailablePinCount) {
appendText(
parameterDetails,
'small',
formatRecipeText('recipePinnedParamsUnavailable', { count: unavailablePinCount }),
'anomalous-recipe-node-hint',
);
}
let parametersRendered = false;
parameterDetails.ontoggle = () => {
if (!parameterDetails.open || parametersRendered) return;
parametersRendered = true;
const parameterList = document.createElement('div');
parameterList.className = 'anomalous-recipe-pin-list';
let previousNode = null;
for (const choice of parameterChoices) {
const nodeLabel = choice.nodeTitle || choice.nodeType || t('recipeUnknownNode');
const nodeKey = `${choice.nodeId}:${choice.nodeType}:${nodeLabel}`;
if (nodeKey !== previousNode) {
appendText(parameterList, 'strong', nodeLabel, 'anomalous-recipe-pin-node');
previousNode = nodeKey;
}
const label = document.createElement('label');
label.className = 'anomalous-recipe-pin-choice';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = selection.pinnedKeys.has(choice.key);
checkbox.onchange = () => {
if (checkbox.checked) selection.pinnedKeys.add(choice.key);
else selection.pinnedKeys.delete(choice.key);
parameterSummary.textContent = `${t('recipeChoosePinnedParams')} (${selection.pinnedKeys.size})`;
};
const value = compactText(displayWidgetValue(choice.value), 100);
appendText(label, 'span', `${choice.widgetName}: ${value}`);
label.prepend(checkbox);
parameterList.appendChild(label);
}
parameterDetails.appendChild(parameterList);
};
dialog.appendChild(parameterDetails);
}
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
const save = appendText(actions, 'button', t('recipeSave'), 'anomalous-btn-primary');
cancel.type = 'button';
save.type = 'button';
const close = (value) => {
overlay.remove();
resolve(value);
};
cancel.onclick = () => close(null);
overlay.onclick = (event) => {
if (event.target === overlay) close(null);
};
save.onclick = () => {
const name = nameInput.value.trim();
if (!name) {
error.textContent = t('recipeNameRequired');
nameInput.focus();
return;
}
const tags = [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20);
close({
name,
tags,
notes: notesInput.value.trim(),
thumbnail: selection.thumbnail,
sourceImage: selection.sourceImage,
pinned: parameterChoices.filter((choice) => selection.pinnedKeys.has(choice.key)),
saveModelPreviewSnapshots: selection.saveModelPreviewSnapshots,
});
};
dialog.appendChild(actions);
overlay.appendChild(dialog);
(owner.nbPanel || document.body).appendChild(overlay);
nameInput.focus();
nameInput.select();
});
}
function editableValueControl(choice, changes) {
const value = choice.value;
const wrapper = document.createElement('label');
wrapper.className = 'anomalous-recipe-edit-param';
appendText(wrapper, 'span', choice.widgetName, 'anomalous-recipe-widget-name');
if (!['string', 'number', 'boolean'].includes(typeof value)) {
appendText(wrapper, 'code', displayWidgetValue(value), 'anomalous-recipe-widget-value');
appendText(wrapper, 'small', t('recipeComplexParam'), 'anomalous-recipe-node-hint');
return wrapper;
}
const input = typeof value === 'string' && value.length > 120
? document.createElement('textarea')
: document.createElement('input');
input.className = typeof input.value === 'string' && input.tagName === 'TEXTAREA'
? 'anomalous-nb-textarea'
: 'anomalous-nb-select';
if (typeof value === 'boolean') {
input.type = 'checkbox';
input.checked = value;
} else {
input.type = typeof value === 'number' ? 'number' : 'text';
input.value = String(value);
if (typeof value === 'number') input.step = 'any';
}
const update = () => {
let nextValue;
if (typeof value === 'boolean') nextValue = input.checked;
else if (typeof value === 'number') {
nextValue = Number(input.value);
if (!Number.isFinite(nextValue)) return;
} else nextValue = input.value;
changes.set(choice.key, { ...choice, previousValue: value, value: nextValue });
};
input.onchange = update;
input.oninput = typeof value === 'string' ? update : null;
wrapper.appendChild(input);
return wrapper;
}
function showRecipeEditDialog(owner, recipeData, filename, history) {
return new Promise((resolve) => {
const params = recipeData.params || {};
const choices = extractRecipeParameterChoicesFromMetadata(params, recipeData.workflow);
const selectedPins = new Set((params.pinned || []).map((pin) => pin.key));
const changes = new Map();
const overlay = document.createElement('div');
overlay.className = 'anomalous-recipe-dialog-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const dialog = document.createElement('div');
dialog.className = 'anomalous-recipe-dialog anomalous-recipe-edit-dialog';
appendText(dialog, 'h3', t('recipeEditTitle'));
const nameLabel = appendText(dialog, 'label', t('recipeName'));
const nameInput = document.createElement('input');
nameInput.className = 'anomalous-nb-select';
nameInput.type = 'text';
nameInput.maxLength = 120;
nameInput.value = recipeData.name || '';
nameLabel.appendChild(nameInput);
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
const tagsInput = document.createElement('input');
tagsInput.className = 'anomalous-nb-select';
tagsInput.type = 'text';
tagsInput.maxLength = 300;
tagsInput.value = Array.isArray(recipeData.tags) ? recipeData.tags.join(', ') : '';
tagsInput.placeholder = t('recipeTagsHint');
tagsLabel.appendChild(tagsInput);
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
const notesInput = document.createElement('textarea');
notesInput.className = 'anomalous-nb-textarea';
notesInput.maxLength = 3000;
notesInput.value = recipeData.notes || '';
notesInput.placeholder = t('recipeNotesHint');
notesLabel.appendChild(notesInput);
if (choices.length) {
const pinDetails = document.createElement('details');
pinDetails.className = 'anomalous-recipe-pin-picker';
const pinSummary = document.createElement('summary');
pinSummary.textContent = `${t('recipeChoosePinnedParams')} (${selectedPins.size})`;
pinDetails.appendChild(pinSummary);
const pinList = document.createElement('div');
pinList.className = 'anomalous-recipe-pin-list';
for (const choice of choices) {
const label = document.createElement('label');
label.className = 'anomalous-recipe-pin-choice';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = selectedPins.has(choice.key);
checkbox.onchange = () => {
if (checkbox.checked) selectedPins.add(choice.key);
else selectedPins.delete(choice.key);
pinSummary.textContent = `${t('recipeChoosePinnedParams')} (${selectedPins.size})`;
};
appendText(label, 'span', `${choice.nodeTitle || choice.nodeType}: ${choice.widgetName}`);
label.prepend(checkbox);
pinList.appendChild(label);
}
pinDetails.appendChild(pinList);
dialog.appendChild(pinDetails);
}
if (choices.length) {
const parameterDetails = document.createElement('details');
parameterDetails.className = 'anomalous-recipe-node-details';
const parameterSummary = document.createElement('summary');
parameterSummary.textContent = t('recipeEditNodeParams');
parameterDetails.appendChild(parameterSummary);
let rendered = false;
parameterDetails.ontoggle = () => {
if (!parameterDetails.open || rendered) return;
rendered = true;
const list = document.createElement('div');
list.className = 'anomalous-recipe-node-list';
let currentNode = null;
for (const choice of choices) {
const nodeKey = `${choice.nodeId}:${choice.nodeType}:${choice.nodeTitle || ''}`;
if (nodeKey !== currentNode) {
appendText(list, 'strong', choice.nodeTitle || choice.nodeType || t('recipeUnknownNode'), 'anomalous-recipe-pin-node');
currentNode = nodeKey;
}
list.appendChild(editableValueControl(choice, changes));
}
parameterDetails.appendChild(list);
};
dialog.appendChild(parameterDetails);
}
const historyDetails = document.createElement('details');
historyDetails.className = 'anomalous-recipe-node-details';
const historySummary = document.createElement('summary');
historySummary.textContent = `${t('recipeHistory')} (${history.length})`;
historyDetails.appendChild(historySummary);
const historyList = document.createElement('div');
historyList.className = 'anomalous-recipe-history-list';
if (!history.length) {
appendText(historyList, 'small', t('recipeHistoryEmpty'), 'anomalous-recipe-node-hint');
} else {
for (const version of history) {
const row = document.createElement('div');
row.className = 'anomalous-recipe-history-row';
const date = Number.isFinite(Number(version.timestamp))
? new Date(Number(version.timestamp)).toLocaleString()
: t('recipeUnknownVersion');
appendText(row, 'span', `${date} · ${version.name || t('recipeUntitled')}`);
const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger');
restore.type = 'button';
restore.onclick = async () => {
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
try {
const response = await fetch('/anomalous/restore_recipe_version', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, version: version.version }),
});
if (!response.ok) throw new Error('recipe history restore failed');
overlay.remove();
await owner.refreshRecipes();
resolve({ mode: 'restored' });
} catch (error) {
console.error('Could not restore Workflow Recipe version:', error);
await anomalousAlert(t('recipeUpdateError'));
}
};
row.appendChild(restore);
historyList.appendChild(row);
}
}
historyDetails.appendChild(historyList);
dialog.appendChild(historyDetails);
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
const canvas = appendText(actions, 'button', t('recipeEditCanvas'), 'anomalous-btn-primary');
const save = appendText(actions, 'button', t('recipeUpdate'), 'anomalous-btn-success');
for (const button of [cancel, canvas, save]) button.type = 'button';
const close = (value) => { overlay.remove(); resolve(value); };
cancel.onclick = () => close(null);
overlay.onclick = (event) => { if (event.target === overlay) close(null); };
canvas.onclick = () => close({ mode: 'canvas' });
save.onclick = () => {
const name = nameInput.value.trim();
if (!name) {
error.textContent = t('recipeNameRequired');
nameInput.focus();
return;
}
close({
mode: 'save',
name,
tags: [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20),
notes: notesInput.value.trim(),
pinnedKeys: selectedPins,
changes: [...changes.values()],
});
};
dialog.appendChild(actions);
overlay.appendChild(dialog);
(owner.nbPanel || document.body).appendChild(overlay);
nameInput.focus();
nameInput.select();
});
}
export async function showRecipes() {
if (!this.notebookContainer) {
this.nbPanel.style.display = 'flex';
await this.showNotebooks();
}
this.recipeDetailFinish?.('closed');
this.notebookBody.style.display = 'none';
this.notebookNotesTab?.classList.remove('active');
this.notebookRecipesTab?.classList.add('active');
if (this.recipeDetailView) {
this.recipeDetailView.remove();
this.recipeDetailView = null;
this.recipeListContainer.style.display = '';
this.recipeView.querySelector('.anomalous-recipe-actionbar').style.display = '';
}
if (this.recipeView) this.recipeView.style.display = 'flex';
if (this.recipesInitialized) {
await this.refreshRecipes();
return;
}
this.recipesInitialized = true;
this.recipeSelectedTags = this.recipeSelectedTags || new Set();
this.recipeSearchQuery = this.recipeSearchQuery || '';
this.recipeView = document.createElement('div');
this.recipeView.className = 'anomalous-recipe-body';
const actionBar = document.createElement('div');
actionBar.className = 'anomalous-recipe-actionbar';
const search = document.createElement('input');
search.type = 'search';
search.className = 'anomalous-recipe-search';
search.placeholder = t('recipeSearchPlaceholder');
search.value = this.recipeSearchQuery || '';
search.oninput = () => {
this.recipeSearchQuery = search.value;
this.renderRecipeList(this.recipeRecords || []);
};
this.recipeSearchInput = search;
actionBar.appendChild(search);
const clearFilters = appendText(actionBar, 'button', t('recipeClearFilters'), 'anomalous-btn-danger');
clearFilters.type = 'button';
clearFilters.onclick = () => {
this.recipeSearchQuery = '';
this.recipeSelectedTags = new Set();
search.value = '';
updateRecipeFilterControls(this, this.recipeRecords || []);
this.renderRecipeList(this.recipeRecords || []);
};
this.recipeTagBar = document.createElement('div');
this.recipeTagBar.className = 'anomalous-recipe-filter-tags';
actionBar.appendChild(this.recipeTagBar);
this.recipeFilterSummary = appendText(actionBar, 'small', '0/0', 'anomalous-recipe-filter-summary');
const save = appendText(actionBar, 'button', t('recipeSaveCurrent'), 'anomalous-btn-primary');
save.dataset.recipeSaveCurrent = 'true';
save.type = 'button';
save.onclick = () => this.handleSaveRecipe();
this.recipeSaveStatus = appendText(actionBar, 'small', '', 'anomalous-recipe-save-status');
const importButton = appendText(actionBar, 'button', t('recipeImport'), 'anomalous-btn-primary');
importButton.type = 'button';
importButton.onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip,.anomalous-recipe.zip,application/zip';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
await importRecipePackage(this, file);
} catch (error) {
console.error('Could not import Workflow Recipe package:', error);
await anomalousAlert(t('recipeImportError'));
}
};
input.click();
};
this.recipeView.appendChild(actionBar);
this.recipeListContainer = document.createElement('div');
this.recipeListContainer.className = 'anomalous-recipe-list';
this.recipeView.appendChild(this.recipeListContainer);
this.notebookContainer.appendChild(this.recipeView);
await this.refreshRecipes();
}
export async function refreshRecipes() {
if (!this.recipeListContainer) return;
try {
const response = await fetch('/anomalous/recipes');
if (!response.ok) throw new Error('recipe list request failed');
const payload = await response.json();
this.recipeRecords = payload.recipes || [];
updateRecipeFilterControls(this, this.recipeRecords);
this.renderRecipeList(this.recipeRecords);
} catch (error) {
console.error('Could not load Workflow Recipes:', error);
this.recipeListContainer.replaceChildren();
appendText(this.recipeListContainer, 'p', t('recipeLoadError'), 'anomalous-recipe-empty');
}
}
export function renderRecipeList(recipes) {
this.recipeListContainer.replaceChildren();
const records = Array.isArray(recipes) ? recipes : [];
const selectedTags = this.recipeSelectedTags || new Set();
const filtered = records.filter((recipe) => recipeMatchesFilter(
recipe?.data || {},
this.recipeSearchQuery || '',
selectedTags,
));
if (this.recipeFilterSummary) this.recipeFilterSummary.textContent = `${filtered.length}/${records.length}`;
if (!records.length) {
appendText(this.recipeListContainer, 'p', t('recipeEmpty'), 'anomalous-recipe-empty');
return;
}
if (!filtered.length) {
appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty');
return;
}
for (const recipe of filtered) {
const data = recipe?.data || {};
const card = document.createElement('article');
card.className = 'anomalous-recipe-card';
appendText(card, 'h3', data.name || t('recipeUntitled'));
const sourceImageUrl = outputImageUrl(data.source_image);
const thumbnail = previewIsVideo(sourceImageUrl)
? sourceImageUrl
: safeThumbnail(data.thumbnail) || sourceImageUrl;
appendRecipeCover(card, thumbnail, data.name || t('recipeThumbnail'));
if (Array.isArray(data.tags) && data.tags.length) {
const tags = document.createElement('div');
tags.className = 'anomalous-recipe-tags';
for (const tag of data.tags.slice(0, 8)) {
const tagButton = appendText(tags, 'button', compactText(tag, 32), 'anomalous-recipe-badge anomalous-recipe-badge-tag');
tagButton.type = 'button';
tagButton.title = t('recipeFilterByTag');
tagButton.onclick = (event) => {
event.stopPropagation();
if (!this.recipeSelectedTags) this.recipeSelectedTags = new Set();
this.recipeSelectedTags.add(tag);
this.renderRecipeList(this.recipeRecords || []);
updateRecipeFilterControls(this, this.recipeRecords || []);
};
}
card.appendChild(tags);
}
if (data.notes) appendText(card, 'p', compactText(data.notes, 180), 'anomalous-recipe-notes');
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
// Make card clickable for details
card.style.cursor = 'pointer';
card.onclick = () => runRecipeCardAction(card, async () => {
const bundle = await fetchRecipeBundle(recipe.filename);
const result = await showRecipeDetail(this, {
recipe: bundle.data,
filename: recipe.filename,
history: bundle.history,
});
if (result?.mode === 'edit') await editRecipe(this, bundle.data, recipe.filename, bundle.history);
}, 'recipeLoadError');
// Secondary actions (Icon buttons)
const secondaryActions = document.createElement('div');
secondaryActions.className = 'anomalous-recipe-actions-secondary';
const edit = appendText(secondaryActions, 'button', '✏️', 'anomalous-btn-icon anomalous-btn-edit');
edit.type = 'button';
edit.title = t('recipeEdit');
edit.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(edit, () => editRecipe(this, recipe?.data || {}, recipe.filename), 'recipeUpdateError'); };
const exportButton = appendText(secondaryActions, 'button', '📥', 'anomalous-btn-icon anomalous-btn-export');
exportButton.type = 'button';
exportButton.title = t('recipeExport');
exportButton.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(exportButton, () => exportRecipePackage(recipe.filename), 'recipeExportError'); };
const remove = appendText(secondaryActions, 'button', '🗑️', 'anomalous-btn-icon anomalous-btn-delete');
remove.type = 'button';
remove.title = t('recipeDelete');
remove.onclick = (e) => {
e.stopPropagation();
runRecipeCardAction(remove, async () => {
if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return;
const response = await fetch('/anomalous/delete_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: recipe.filename }),
});
if (!response.ok) throw new Error('recipe deletion failed');
await this.refreshRecipes();
}, 'recipeDeleteError');
};
// Primary actions (Main buttons)
const primaryActions = document.createElement('div');
primaryActions.className = 'anomalous-recipe-actions-primary';
const restore = appendText(primaryActions, 'button', t('recipeOpenCanvas'), 'anomalous-btn-primary');
restore.type = 'button';
restore.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(restore, async () => {
const data = await fetchRecipeData(recipe.filename);
await openRecipeOnCanvas(this, data);
}, 'recipeRestoreError'); };
const append = appendText(primaryActions, 'button', t('recipeAppendCanvas'), 'anomalous-btn-ghost');
append.type = 'button';
append.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(append, async () => {
const data = await fetchRecipeData(recipe.filename);
if (!appendRecipeOnCanvas(this, data)) throw new Error('recipe append failed');
}, 'recipeAppendError'); };
secondaryActions.append(edit, exportButton, remove);
primaryActions.append(append, restore);
actions.append(secondaryActions, primaryActions);
card.appendChild(actions);
this.recipeListContainer.appendChild(card);
}
}
export async function handleSaveRecipe() {
if (!app.graph?.serialize) {
await anomalousAlert(t('recipeSaveError'));
return;
}
const draft = captureRecipeDraft(app.graph);
if (!draft.workflow || !Array.isArray(draft.workflow.nodes)) {
await anomalousAlert(t('recipeSaveError'));
return;
}
const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas);
const editing = this.recipeEditing || null;
const details = await showRecipeSaveDialog(this, canvasThumbnail, draft.parameterChoices, editing?.data || null);
if (!details) return;
const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]');
if (saveButton) {
saveButton.disabled = true;
saveButton.textContent = t('recipeSaving');
}
try {
draft.metadata.pinned = details.pinned;
let thumbnail = details.thumbnail;
if (details.sourceImage) {
try {
thumbnail = await captureOutputThumbnail(details.sourceImage);
} catch (error) {
console.warn('Could not persist bound recipe image thumbnail:', error);
}
}
const response = await fetch(editing ? '/anomalous/update_recipe' : '/anomalous/save_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(editing ? { filename: editing.filename } : {}),
name: details.name,
tags: details.tags,
notes: details.notes,
params: draft.metadata,
workflow: draft.workflow,
thumbnail,
source_image: details.sourceImage,
presentation: { save_model_preview_snapshots: details.saveModelPreviewSnapshots },
}),
});
const payload = await response.json();
if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed');
const receipt = payload.receipt || {};
const receiptMatchesDraft = receipt.node_count === draft.stats.nodeCount
&& receipt.link_count === draft.stats.linkCount
&& receipt.group_count === draft.stats.groupCount;
if (this.recipeSaveStatus) {
this.recipeSaveStatus.textContent = receiptMatchesDraft
? formatRecipeText('recipeSaveReceipt', {
nodes: receipt.node_count,
links: receipt.link_count,
groups: receipt.group_count,
})
: t('recipeSaveReceiptMismatch');
this.recipeSaveStatus.classList.toggle('is-warning', !receiptMatchesDraft);
}
this.recipeEditing = null;
await this.refreshRecipes();
} catch (error) {
console.error('Could not save Workflow Recipe:', error);
await anomalousAlert(t('recipeSaveError'));
} finally {
if (saveButton) {
saveButton.disabled = false;
saveButton.textContent = this.recipeEditing ? t('recipeUpdateCurrent') : t('recipeSaveCurrent');
}
}
}