feat: refactor model composition and implement modern custom dialogs for recipes
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
export function anomalousAlert(message, title = 'Anomalous') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.inset = '0';
|
||||
overlay.style.zIndex = '999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.padding = '20px';
|
||||
overlay.style.boxSizing = 'border-box';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
|
||||
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
||||
dialog.style.borderRadius = '16px';
|
||||
dialog.style.padding = '24px';
|
||||
dialog.style.maxWidth = '400px';
|
||||
dialog.style.width = '100%';
|
||||
dialog.style.maxHeight = '90vh';
|
||||
dialog.style.overflowY = 'auto';
|
||||
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
|
||||
dialog.style.color = '#f3f4f6';
|
||||
dialog.style.display = 'flex';
|
||||
dialog.style.flexDirection = 'column';
|
||||
dialog.style.gap = '16px';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
heading.style.margin = '0';
|
||||
heading.style.fontSize = '1.25rem';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.margin = '0';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
text.style.color = '#ccc';
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.marginTop = '8px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = window.anomalous_browser_lang === 'zh' ? '确定' : 'OK';
|
||||
okBtn.className = 'anomalous-btn-primary';
|
||||
okBtn.style.padding = '8px 24px';
|
||||
|
||||
const cleanup = () => {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
};
|
||||
|
||||
okBtn.onclick = cleanup;
|
||||
|
||||
footer.appendChild(okBtn);
|
||||
dialog.append(heading, text, footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export function anomalousConfirm(message, title = 'Anomalous') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.inset = '0';
|
||||
overlay.style.zIndex = '999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.padding = '20px';
|
||||
overlay.style.boxSizing = 'border-box';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
|
||||
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
||||
dialog.style.borderRadius = '16px';
|
||||
dialog.style.padding = '24px';
|
||||
dialog.style.maxWidth = '400px';
|
||||
dialog.style.width = '100%';
|
||||
dialog.style.maxHeight = '90vh';
|
||||
dialog.style.overflowY = 'auto';
|
||||
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
|
||||
dialog.style.color = '#f3f4f6';
|
||||
dialog.style.display = 'flex';
|
||||
dialog.style.flexDirection = 'column';
|
||||
dialog.style.gap = '16px';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
heading.style.margin = '0';
|
||||
heading.style.fontSize = '1.25rem';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.margin = '0';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
text.style.color = '#ccc';
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.gap = '12px';
|
||||
footer.style.marginTop = '8px';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = window.anomalous_browser_lang === 'zh' ? '取消' : 'Cancel';
|
||||
cancelBtn.className = 'anomalous-btn-ghost';
|
||||
cancelBtn.style.padding = '8px 24px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = window.anomalous_browser_lang === 'zh' ? '确定' : 'OK';
|
||||
okBtn.className = 'anomalous-btn-danger';
|
||||
okBtn.style.padding = '8px 24px';
|
||||
|
||||
const finish = (result) => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => finish(false);
|
||||
okBtn.onclick = () => finish(true);
|
||||
|
||||
footer.append(cancelBtn, okBtn);
|
||||
dialog.append(heading, text, footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
cancelBtn.focus();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { i18n } from './locales.js';
|
||||
import { i18n } from './locales.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import {
|
||||
deriveRecipeModelReferences,
|
||||
formatIdentitySize,
|
||||
@@ -66,7 +67,7 @@ export async function openRecipeOnCanvas(owner, recipe) {
|
||||
const missingWarning = missingTypes.length
|
||||
? `\n\n${t('recipeMissingNodes')}:\n${missingTypes.slice(0, 12).join('\n')}`
|
||||
: '';
|
||||
if ((canvasMayContainUserWork() || missingWarning) && !confirm(`${t('recipeOpenCanvasConfirm')}${missingWarning}`)) return false;
|
||||
if ((canvasMayContainUserWork() || missingWarning) && !await anomalousConfirm(`${t('recipeOpenCanvasConfirm')}${missingWarning}`)) return false;
|
||||
try {
|
||||
await app.loadGraphData(recipe.workflow);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
@@ -74,7 +75,7 @@ export async function openRecipeOnCanvas(owner, recipe) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Could not open Workflow Recipe on canvas:', error);
|
||||
alert(t('recipeRestoreError'));
|
||||
await anomalousAlert(t('recipeRestoreError'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +87,7 @@ export function appendRecipeOnCanvas(owner, recipe) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Could not append Workflow Recipe:', error);
|
||||
alert(error.code === 'recipe_append_missing_node'
|
||||
await anomalousAlert(error.code === 'recipe_append_missing_node'
|
||||
? `${t('recipeAppendError')}\n${error.message}`
|
||||
: t('recipeAppendError'));
|
||||
return false;
|
||||
@@ -657,39 +658,8 @@ function renderOverview(content, owner, recipe, references, finish) {
|
||||
const params = recipe.params || {};
|
||||
const modelComposition = document.createElement('div');
|
||||
modelComposition.className = 'anomalous-recipe-model-composition';
|
||||
appendText(modelComposition, 'h5', t('recipeDetailModelComposition'));
|
||||
const modelCompositionGrid = document.createElement('div');
|
||||
modelCompositionGrid.className = 'anomalous-recipe-model-composition-grid';
|
||||
const baseModelCard = document.createElement('article');
|
||||
baseModelCard.className = 'anomalous-recipe-model-composition-card is-base-model';
|
||||
appendText(baseModelCard, 'span', t('recipeDetailBaseModel'), 'anomalous-recipe-detail-label');
|
||||
appendValueViewer(
|
||||
baseModelCard,
|
||||
modelDisplayName(params.baseModel) || t('recipeDetailUnavailable'),
|
||||
'anomalous-recipe-model-composition-value',
|
||||
);
|
||||
modelCompositionGrid.appendChild(baseModelCard);
|
||||
const loras = Array.isArray(params.loras) ? params.loras : [];
|
||||
if (loras.length) {
|
||||
loras.forEach((lora, index) => {
|
||||
const loraCard = document.createElement('article');
|
||||
loraCard.className = 'anomalous-recipe-model-composition-card';
|
||||
appendText(loraCard, 'span', `${t('recipeDetailLoraSummary')} ${index + 1}`, 'anomalous-recipe-detail-label');
|
||||
appendValueViewer(
|
||||
loraCard,
|
||||
modelDisplayName(lora?.name) || t('recipeDetailUnavailable'),
|
||||
'anomalous-recipe-model-composition-value',
|
||||
);
|
||||
if (lora?.strength_model !== null && lora?.strength_model !== undefined) {
|
||||
appendText(loraCard, 'small', `× ${lora.strength_model}`, 'anomalous-recipe-detail-muted');
|
||||
}
|
||||
modelCompositionGrid.appendChild(loraCard);
|
||||
});
|
||||
} else {
|
||||
appendText(modelCompositionGrid, 'small', `${t('recipeDetailLoraSummary')}: ${t('recipeDetailUnavailable')}`, 'anomalous-recipe-detail-muted');
|
||||
}
|
||||
modelComposition.appendChild(modelCompositionGrid);
|
||||
summary.appendChild(modelComposition);
|
||||
renderModelComposition(modelComposition, owner, recipe, references, finish, params);
|
||||
const values = [
|
||||
[t('recipeSteps'), params.steps],
|
||||
['CFG', params.cfg],
|
||||
@@ -733,12 +703,11 @@ function renderOverview(content, owner, recipe, references, finish) {
|
||||
content.appendChild(overview);
|
||||
}
|
||||
|
||||
function renderModels(content, owner, recipe, references, finish) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'anomalous-recipe-detail-section';
|
||||
function renderModelComposition(container, owner, recipe, references, finish, params) {
|
||||
container.replaceChildren();
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'anomalous-recipe-detail-section-heading';
|
||||
appendText(heading, 'h4', t('recipeDetailModels'));
|
||||
appendText(heading, 'h5', t('recipeDetailModelComposition'));
|
||||
const refresh = button(heading, t('recipeDetailRefreshAvailability'), 'anomalous-btn-primary');
|
||||
const status = appendText(
|
||||
heading,
|
||||
@@ -770,18 +739,16 @@ function renderModels(content, owner, recipe, references, finish) {
|
||||
}
|
||||
}
|
||||
await loadCurrentPreviews(owner, references);
|
||||
content.replaceChildren();
|
||||
renderModels(content, owner, recipe, references, finish);
|
||||
renderModelComposition(container, owner, recipe, references, finish, params);
|
||||
} catch (error) {
|
||||
console.error('Could not refresh recipe model availability:', error);
|
||||
status.textContent = t('recipeDetailRefreshError');
|
||||
refresh.disabled = false;
|
||||
}
|
||||
};
|
||||
section.appendChild(heading);
|
||||
container.appendChild(heading);
|
||||
if (!references.length) {
|
||||
appendText(section, 'p', t('recipeDetailNoModelReferences'), 'anomalous-recipe-detail-muted');
|
||||
content.appendChild(section);
|
||||
appendText(container, 'p', t('recipeDetailNoModelReferences'), 'anomalous-recipe-detail-muted');
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
@@ -789,7 +756,8 @@ function renderModels(content, owner, recipe, references, finish) {
|
||||
for (const reference of references) {
|
||||
const card = document.createElement('article');
|
||||
const isLocal = Boolean(reference.localModel);
|
||||
card.className = `anomalous-recipe-model-reference${isLocal ? ' is-local' : ' is-unresolved'}`;
|
||||
const isBase = params && params.baseModel && reference.saved_value === params.baseModel;
|
||||
card.className = `anomalous-recipe-model-reference${isLocal ? ' is-local' : ' is-unresolved'}${isBase ? ' is-base-model' : ''}`;
|
||||
const body = document.createElement('div');
|
||||
body.className = 'anomalous-recipe-model-reference-body';
|
||||
const openModel = isLocal
|
||||
@@ -864,8 +832,7 @@ function renderModels(content, owner, recipe, references, finish) {
|
||||
match.onclick = async () => {
|
||||
match.disabled = true;
|
||||
await matchLocalModel(owner, recipe, reference, matchStatus, () => {
|
||||
content.replaceChildren();
|
||||
renderModels(content, owner, recipe, references, finish);
|
||||
renderModelComposition(container, owner, recipe, references, finish, params);
|
||||
});
|
||||
if (!reference.localModel) match.disabled = false;
|
||||
};
|
||||
@@ -875,8 +842,7 @@ function renderModels(content, owner, recipe, references, finish) {
|
||||
card.appendChild(body);
|
||||
list.appendChild(card);
|
||||
}
|
||||
section.appendChild(list);
|
||||
content.appendChild(section);
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
function parameterNodesWithPromptFallback(recipe) {
|
||||
@@ -1096,7 +1062,7 @@ function renderVersions(content, owner, recipe, history, finish) {
|
||||
};
|
||||
const restore = button(row, t('recipeRestoreVersion'), 'anomalous-btn-danger');
|
||||
restore.onclick = async () => {
|
||||
if (!confirm(t('recipeRestoreVersionConfirm'))) return;
|
||||
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
|
||||
try {
|
||||
const response = await fetch('/anomalous/restore_recipe_version', {
|
||||
method: 'POST',
|
||||
@@ -1108,7 +1074,7 @@ function renderVersions(content, owner, recipe, history, finish) {
|
||||
finish('restored');
|
||||
} catch (error) {
|
||||
console.error('Could not restore recipe version:', error);
|
||||
alert(t('recipeUpdateError'));
|
||||
await anomalousAlert(t('recipeUpdateError'));
|
||||
}
|
||||
};
|
||||
timeline.appendChild(row);
|
||||
@@ -1187,7 +1153,7 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) {
|
||||
const tabDefinitions = [
|
||||
['overview', t('recipeDetailOverview'), () => {
|
||||
renderOverview(content, owner, recipe, references, finish);
|
||||
renderModels(content, owner, recipe, references, finish);
|
||||
|
||||
const paramsWrapper = document.createElement('details');
|
||||
paramsWrapper.className = 'anomalous-recipe-detail-section';
|
||||
const summary = document.createElement('summary');
|
||||
|
||||
+16
-15
@@ -1,7 +1,8 @@
|
||||
/** Workflow Recipes UI, built on the same modal/card language as Notebooks. */
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { i18n } from './locales.js';
|
||||
import { i18n } from './locales.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import {
|
||||
captureCanvasThumbnail,
|
||||
captureRecipeDraft,
|
||||
@@ -32,7 +33,7 @@ async function runRecipeCardAction(actionButton, action, errorKey) {
|
||||
await action();
|
||||
} catch (error) {
|
||||
console.error('Workflow Recipe action failed:', error);
|
||||
alert(t(errorKey));
|
||||
await anomalousAlert(t(errorKey));
|
||||
} finally {
|
||||
actionButton.disabled = false;
|
||||
actionButton.classList.remove('is-busy');
|
||||
@@ -108,9 +109,9 @@ function appendRecipeCover(parent, url, alt) {
|
||||
}
|
||||
|
||||
async function exportRecipePackage(filename) {
|
||||
const includeSnapshots = confirm(t('recipeExportSnapshotsConfirm'));
|
||||
const includeHistory = confirm(t('recipeExportHistoryConfirm'));
|
||||
const includeIdentity = !confirm(t('recipeExportRedactIdentityConfirm'));
|
||||
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' },
|
||||
@@ -143,7 +144,7 @@ async function importRecipePackage(owner, file) {
|
||||
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 (!confirm(summary)) return;
|
||||
if (!await anomalousConfirm(summary)) return;
|
||||
let name = recipeName;
|
||||
if ((inspectPayload.existing_names || []).includes(name)) {
|
||||
name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`);
|
||||
@@ -418,7 +419,7 @@ async function editRecipe(owner, recipe, filename, history = null) {
|
||||
const result = await showRecipeEditDialog(owner, editable, filename, bundle.history);
|
||||
if (!result || result.mode === 'restored') return;
|
||||
if (result.mode === 'canvas') {
|
||||
if (!confirm(t('recipeEditCanvasConfirm'))) return;
|
||||
if (!await anomalousConfirm(t('recipeEditCanvasConfirm'))) return;
|
||||
app.loadGraphData(editable.workflow);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
owner.recipeEditing = { filename, data: editable };
|
||||
@@ -443,7 +444,7 @@ async function editRecipe(owner, recipe, filename, history = null) {
|
||||
await owner.refreshRecipes();
|
||||
} catch (error) {
|
||||
console.error('Could not edit Workflow Recipe:', error);
|
||||
alert(t('recipeUpdateError'));
|
||||
await anomalousAlert(t('recipeUpdateError'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,7 +840,7 @@ function showRecipeEditDialog(owner, recipeData, filename, history) {
|
||||
const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger');
|
||||
restore.type = 'button';
|
||||
restore.onclick = async () => {
|
||||
if (!confirm(t('recipeRestoreVersionConfirm'))) return;
|
||||
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
|
||||
try {
|
||||
const response = await fetch('/anomalous/restore_recipe_version', {
|
||||
method: 'POST',
|
||||
@@ -852,7 +853,7 @@ function showRecipeEditDialog(owner, recipeData, filename, history) {
|
||||
resolve({ mode: 'restored' });
|
||||
} catch (error) {
|
||||
console.error('Could not restore Workflow Recipe version:', error);
|
||||
alert(t('recipeUpdateError'));
|
||||
await anomalousAlert(t('recipeUpdateError'));
|
||||
}
|
||||
};
|
||||
row.appendChild(restore);
|
||||
@@ -971,7 +972,7 @@ export async function showRecipes() {
|
||||
await importRecipePackage(this, file);
|
||||
} catch (error) {
|
||||
console.error('Could not import Workflow Recipe package:', error);
|
||||
alert(t('recipeImportError'));
|
||||
await anomalousAlert(t('recipeImportError'));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@@ -1084,7 +1085,7 @@ export function renderRecipeList(recipes) {
|
||||
remove.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
runRecipeCardAction(remove, async () => {
|
||||
if (!confirm(t('recipeDeleteConfirm'))) return;
|
||||
if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return;
|
||||
const response = await fetch('/anomalous/delete_recipe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1124,12 +1125,12 @@ export function renderRecipeList(recipes) {
|
||||
|
||||
export async function handleSaveRecipe() {
|
||||
if (!app.graph?.serialize) {
|
||||
alert(t('recipeSaveError'));
|
||||
await anomalousAlert(t('recipeSaveError'));
|
||||
return;
|
||||
}
|
||||
const draft = captureRecipeDraft(app.graph);
|
||||
if (!draft.workflow || !Array.isArray(draft.workflow.nodes)) {
|
||||
alert(t('recipeSaveError'));
|
||||
await anomalousAlert(t('recipeSaveError'));
|
||||
return;
|
||||
}
|
||||
const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas);
|
||||
@@ -1187,7 +1188,7 @@ export async function handleSaveRecipe() {
|
||||
await this.refreshRecipes();
|
||||
} catch (error) {
|
||||
console.error('Could not save Workflow Recipe:', error);
|
||||
alert(t('recipeSaveError'));
|
||||
await anomalousAlert(t('recipeSaveError'));
|
||||
} finally {
|
||||
if (saveButton) {
|
||||
saveButton.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user