/** 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 { outputImageUrl, previewIsVideo } from './ui_recipe_media.js'; import { translate } from './locales.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { captureCanvasThumbnail, captureRecipeDraft, } from './recipe_parser.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 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?.(); } } 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 { 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, workflow_scope: draft.workflowScope, thumbnail, source_image: details.sourceImage, presentation: { save_model_preview_snapshots: details.saveModelPreviewSnapshots }, verify_model_identities: details.verifyModelIdentities, }), }); const payload = await response.json(); if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed'); const recipeFilename = payload.filename || editing?.filename; if (recipeFilename) { try { const parameterResponse = await fetch('/anomalous/save_parameter', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: details.name, tags: details.tags, notes: details.notes, params: draft.metadata, workflow: draft.workflow, recipe_filename: recipeFilename, }), }); if (!parameterResponse.ok) throw new Error('parameter snapshot request failed'); } catch (error) { // Recipe persistence is already successful; a snapshot failure // must not make the user retry and create a duplicate recipe. console.warn('Could not save the recipe parameter snapshot:', error); } } 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'); } } }