- Every node value under the details' "every node's values" has a star; marked values show at
the top of the workflow's own values and of each set as tiles (node, value, a note, and on a
set the default when it differs). Set rows list them first; the comparison window leads its
key values with them and their note.
- Kept in the recipe's params as focusParams [{node, type, widget, note}]; the server refuses
any other shape (at most 12, short strings, notes up to 120 characters). Kept while the node
of that id and type is there, also when the workflow is updated from the canvas.
- compareParams' isKey now also receives the node ids, so a mark holds for one node only.
- Tests: tests/recipe_focus.mjs, focus values in test_recipe_roundtrip.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
9.6 KiB
JavaScript
225 lines
9.6 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 } from './ui_recipe_dialogs.js';
|
|
import { showRecipeSaveDialog } from './ui_recipe_save_dialog.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 { focusEntries } from './recipe_focus.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,
|
|
workflowScope: draft.workflowScope,
|
|
modelAudit,
|
|
initial: 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;
|
|
}
|
|
// Focus values (重点参数) stay while their node is still there as the same type.
|
|
const focus = focusEntries(editing?.data?.params, draft.workflow);
|
|
if (focus.length) draft.metadata.focusParams = focus;
|
|
|
|
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'));
|
|
}
|
|
}
|