Recent's star and the image workbench's whole-workflow save now make a Workflow Recipe with the image as cover (recipe_save.js lays the image's workflow on a canvas of its own and saves it through the same path as the canvas save, now shared). The Material Library's "all" lists the pieces only and loses its Workflow pill. Whole workflows kept as materials before can be moved from the Workflows page: each becomes a recipe and its material gains moved_to_recipe, which keeps it out of every listing while the file stays. Recent marks images kept as recipes and opens them; partial recipes are "Modules" everywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
9.3 KiB
JavaScript
221 lines
9.3 KiB
JavaScript
/** Workflow Recipes UI, built on the same modal/card language as Notebooks. */
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import {
|
|
showRecipes as showRecipeCatalog,
|
|
refreshRecipes as refreshRecipeCatalog,
|
|
renderRecipeList as renderRecipeCatalog,
|
|
} from './ui_recipe_catalog.js';
|
|
import { formatRecipeText, showRecipeEditDialog, showRecipeSaveDialog } from './ui_recipe_dialogs.js';
|
|
import { translate } from './locales.js';
|
|
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
|
import {
|
|
captureCanvasThumbnail,
|
|
captureRecipeDraft,
|
|
} from './recipe_parser.js';
|
|
import { persistRecipe } from './recipe_save.js';
|
|
import { showRecipeDetail } from './ui_recipe_detail.js';
|
|
import {
|
|
canVerifyRecipeModelReference,
|
|
deriveRecipeModelReferences,
|
|
normaliseIdentity,
|
|
} from './recipe_identity.js';
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
export async function showRecipes() {
|
|
return showRecipeCatalog.call(this);
|
|
}
|
|
|
|
export async function refreshRecipes() {
|
|
return refreshRecipeCatalog.call(this);
|
|
}
|
|
|
|
export function renderRecipeList(recipes) {
|
|
return renderRecipeCatalog.call(this, recipes, {
|
|
editRecipe,
|
|
fetchRecipeBundle,
|
|
fetchRecipeData,
|
|
});
|
|
}
|
|
|
|
function recipeAuditKey(reference) {
|
|
return `${reference?.node_id ?? ''}\u001f${reference?.widget_index ?? ''}\u001f${reference?.saved_value ?? ''}`;
|
|
}
|
|
|
|
async function inspectRecipeModelIdentities(draft) {
|
|
const references = deriveRecipeModelReferences({
|
|
workflow: draft?.workflow,
|
|
params: draft?.metadata,
|
|
});
|
|
if (!references.length) return { verifiableMissing: [] };
|
|
|
|
try {
|
|
const response = await fetch('/anomalous/refresh_recipe_identity', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ references }),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || payload.status !== 'success') throw new Error('recipe identity inspection failed');
|
|
const results = new Map((payload.results || []).map((result) => [recipeAuditKey(result), result]));
|
|
for (const reference of references) {
|
|
const result = results.get(recipeAuditKey(reference));
|
|
if (!result) continue;
|
|
reference.currentAvailability = result.availability;
|
|
const current = normaliseIdentity(reference.identity);
|
|
const inspected = normaliseIdentity(result.identity);
|
|
if (current.status !== 'verified' && inspected.status === 'verified') {
|
|
reference.identity = inspected;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// The audit is advisory. A temporary inspection failure must never
|
|
// prevent the workflow snapshot itself from being saved.
|
|
console.warn('Could not inspect recipe model identities:', error);
|
|
}
|
|
|
|
return {
|
|
verifiableMissing: references.filter((reference) => (
|
|
canVerifyRecipeModelReference(reference)
|
|
&& normaliseIdentity(reference.identity).status !== 'verified'
|
|
&& reference.currentAvailability !== 'missing'
|
|
)),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
editable.name = result.name;
|
|
editable.tags = result.tags;
|
|
editable.notes = result.notes;
|
|
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'));
|
|
}
|
|
}
|
|
|
|
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 modelAudit = await inspectRecipeModelIdentities(draft);
|
|
const details = await showRecipeSaveDialog(
|
|
this,
|
|
canvasThumbnail,
|
|
draft.workflowScope,
|
|
modelAudit,
|
|
editing?.data || null,
|
|
);
|
|
if (!details) return;
|
|
|
|
// Prompt-role labels belong to the recipe skeleton. Preserve labels whose
|
|
// node id and type still match when an existing recipe is refreshed from
|
|
// the live canvas; stale labels are discarded safely.
|
|
const previousPromptRoles = editing?.data?.params?.promptRoleOverrides;
|
|
if (previousPromptRoles && typeof previousPromptRoles === 'object') {
|
|
const workflowNodes = new Map((draft.workflow.nodes || []).map((node) => [String(node?.id), node]));
|
|
const retained = {};
|
|
for (const [nodeId, override] of Object.entries(previousPromptRoles)) {
|
|
const node = workflowNodes.get(String(nodeId));
|
|
if (!node || !override || typeof override !== 'object') continue;
|
|
if (override.nodeType && override.nodeType !== node.type) continue;
|
|
retained[nodeId] = { ...override, nodeType: node.type || override.nodeType || 'Unknown' };
|
|
}
|
|
if (Object.keys(retained).length) draft.metadata.promptRoleOverrides = retained;
|
|
}
|
|
|
|
const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]');
|
|
if (saveButton) {
|
|
saveButton.disabled = true;
|
|
saveButton.textContent = t('recipeSaving');
|
|
}
|
|
try {
|
|
const { payload } = await persistRecipe(draft, details, editing);
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Opens the Workflows page on one recipe's detail (from Recent's star, a toast). */
|
|
export async function openRecipeByFilename(filename) {
|
|
this.goTo?.('recipes');
|
|
try {
|
|
const bundle = await fetchRecipeBundle(filename);
|
|
const result = await showRecipeDetail(this, { recipe: bundle.data, filename, history: bundle.history });
|
|
if (result?.mode === 'edit') await editRecipe(this, bundle.data, filename, bundle.history);
|
|
} catch (error) {
|
|
console.error('Could not open Workflow Recipe:', error);
|
|
await anomalousAlert(t('recipeLoadError'));
|
|
}
|
|
}
|