Files

209 lines
7.8 KiB
JavaScript

/** Pure, bounded semantic comparison for saved Workflow Recipes. */
const MAX_VALUE_LENGTH = 320;
const MAX_CHANGES = 160;
function cloneSafe(value) {
if (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value)) return value;
try {
const encoded = JSON.stringify(value);
if (!encoded || encoded.length > MAX_VALUE_LENGTH * 8) return '[complex value]';
return JSON.parse(encoded);
} catch (error) {
return '[complex value]';
}
}
function stable(value) {
const cloned = cloneSafe(value);
try { return JSON.stringify(cloned); } catch (error) { return String(cloned); }
}
export function formatDiffValue(value) {
if (value === null || value === undefined || value === '') return '—';
let text;
if (typeof value === 'string') text = value;
else {
try { text = JSON.stringify(value); } catch (error) { text = String(value); }
}
text = String(text).replace(/\s+/g, ' ').trim();
return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` : text;
}
function itemKey(item, fallback) {
if (item?.key) return String(item.key);
const node = item?.nodeId ?? item?.node_id ?? '';
const index = item?.widgetIndex ?? item?.widget_index ?? '';
const name = item?.widgetName ?? item?.widget_name ?? '';
return `${node}:${index}:${name}:${fallback}`;
}
function addMapChanges(changes, category, before, after, getKey, getValue, getLabel) {
const left = new Map((before || []).map((item, index) => [getKey(item, index), item]));
const right = new Map((after || []).map((item, index) => [getKey(item, index), item]));
const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
for (const key of keys) {
const leftItem = left.get(key);
const rightItem = right.get(key);
const leftValue = leftItem === undefined ? undefined : getValue(leftItem);
const rightValue = rightItem === undefined ? undefined : getValue(rightItem);
const same = leftItem !== undefined && rightItem !== undefined && stable(leftValue) === stable(rightValue);
if (same) continue;
changes.push({
kind: leftItem === undefined ? 'added' : rightItem === undefined ? 'removed' : 'changed',
category,
key,
label: getLabel(rightItem || leftItem),
before: cloneSafe(leftValue),
after: cloneSafe(rightValue),
});
}
}
function valueAt(recipe, path, fallback = null) {
const parts = path.split('.');
let value = recipe;
for (const part of parts) {
if (!value || typeof value !== 'object') return fallback;
value = value[part];
}
return value === undefined ? fallback : value;
}
function summaryValue(recipe, key) {
const params = recipe?.params || {};
const values = {
baseModel: params.baseModel,
steps: params.steps,
cfg: params.cfg,
sampler_name: params.sampler_name,
scheduler: params.scheduler,
denoise: params.denoise,
seed: params.seed,
resolution: params.resolution,
nodeCount: params.nodeCount,
loras: (params.loras || []).map((item) => ({
name: item?.name,
strength_model: item?.strength_model,
strength_clip: item?.strength_clip,
})),
};
return values[key];
}
function addScalarChanges(changes, category, entries) {
for (const entry of entries) {
const before = cloneSafe(entry.before);
const after = cloneSafe(entry.after);
if (stable(before) === stable(after)) continue;
changes.push({
kind: before === undefined || before === null ? 'added' : after === undefined || after === null ? 'removed' : 'changed',
category,
key: entry.key,
label: entry.label,
before,
after,
});
}
}
function graphSummary(recipe) {
const graph = recipe?.workflow || {};
return {
nodes: Array.isArray(graph.nodes) ? graph.nodes.length : null,
links: Array.isArray(graph.links) ? graph.links.length : graph.links && typeof graph.links === 'object' ? Object.keys(graph.links).length : null,
fingerprint: recipe?.workflow_fingerprint?.value || null,
};
}
/** Return deterministic UI-neutral changes between two recipe records. */
export function buildRecipeDiff(beforeRecipe, afterRecipe) {
const before = beforeRecipe || {};
const after = afterRecipe || {};
const changes = [];
addScalarChanges(changes, 'pinned', [
{
key: 'pinned',
label: 'Pinned parameters',
before: (before.params?.pinned || []).map((item) => ({
key: itemKey(item, 'pinned'),
value: item?.value,
})),
after: (after.params?.pinned || []).map((item) => ({
key: itemKey(item, 'pinned'),
value: item?.value,
})),
},
]);
addMapChanges(
changes,
'prompts',
before.params?.promptPositive || [],
after.params?.promptPositive || [],
(_item, index) => `positive:${index}`,
(item) => item,
() => 'Positive prompt',
);
addMapChanges(
changes,
'prompts',
before.params?.promptNegative || [],
after.params?.promptNegative || [],
(_item, index) => `negative:${index}`,
(item) => item,
() => 'Negative prompt',
);
addMapChanges(
changes,
'models',
before.params?.model_references || [],
after.params?.model_references || [],
(item, index) => itemKey(item, `model:${index}`),
(item) => ({
saved_value: item?.saved_value,
user_note: item?.user_note || '',
identity_status: item?.identity?.status,
identity_sha256: item?.identity?.sha256,
identity_size: item?.identity?.size,
}),
(item) => `${item?.category || 'model'} · ${item?.node_title || item?.node_type || 'loader'}`,
);
const summaryKeys = ['baseModel', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise', 'seed', 'resolution', 'nodeCount', 'loras'];
addScalarChanges(changes, 'parameters', summaryKeys.map((key) => ({
key,
label: key,
before: summaryValue(before, key),
after: summaryValue(after, key),
})));
const beforeGraph = graphSummary(before);
const afterGraph = graphSummary(after);
addScalarChanges(changes, 'workflow', [
{ key: 'fingerprint', label: 'Workflow fingerprint', before: beforeGraph.fingerprint, after: afterGraph.fingerprint },
{ key: 'node_count', label: 'Node count', before: beforeGraph.nodes, after: afterGraph.nodes },
{ key: 'link_count', label: 'Link count', before: beforeGraph.links, after: afterGraph.links },
]);
addScalarChanges(changes, 'presentation', [
{ key: 'name', label: 'Recipe name', before: before.name, after: after.name },
{ key: 'notes', label: 'Notes', before: before.notes, after: after.notes },
{ key: 'tags', label: 'Tags', before: before.tags || [], after: after.tags || [] },
{ key: 'cover', label: 'Cover', before: Boolean(before.thumbnail || before.source_image), after: Boolean(after.thumbnail || after.source_image) },
{
key: 'preview_snapshots',
label: 'Model preview snapshots',
before: (before.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length,
after: (after.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length,
},
]);
return changes.slice(0, MAX_CHANGES);
}
export function diffIsEmpty(changes) {
return !Array.isArray(changes) || changes.length === 0;
}