161 lines
7.8 KiB
JavaScript
161 lines
7.8 KiB
JavaScript
/**
|
||
* Workflow Recipe version timeline, comparison, and restore actions.
|
||
*/
|
||
|
||
import { translate } from './locales.js';
|
||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||
import { shortHash } from './recipe_identity.js';
|
||
import { buildRecipeDiff, diffIsEmpty } from './recipe_diff.js';
|
||
import {
|
||
appendCopyButton,
|
||
appendText,
|
||
appendValueViewer,
|
||
button,
|
||
dateText,
|
||
} from './ui_recipe_detail_dom.js';
|
||
|
||
const t = (key, params) => translate(key, params);
|
||
|
||
function fullDiffValue(value) {
|
||
if (value === null || value === undefined || value === '') return t('recipeDetailUnavailable');
|
||
if (typeof value === 'string') return value.trim();
|
||
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
|
||
}
|
||
export function fingerprintText(recipe) {
|
||
return recipe?.workflow_fingerprint?.value || '';
|
||
}
|
||
export function diffCategoryLabel(category) {
|
||
return t({
|
||
pinned: 'recipeDiffPinned',
|
||
prompts: 'recipeDiffPrompts',
|
||
models: 'recipeDiffModels',
|
||
parameters: 'recipeDiffParameters',
|
||
workflow: 'recipeDiffWorkflow',
|
||
presentation: 'recipeDiffPresentation',
|
||
}[category] || 'recipeDiffOther');
|
||
}
|
||
|
||
export function appendDiffValue(parent, label, value, kind) {
|
||
const item = document.createElement('div');
|
||
item.className = `anomalous-recipe-diff-value anomalous-recipe-diff-value-${kind}`;
|
||
appendText(item, 'small', label, 'anomalous-recipe-detail-muted');
|
||
appendValueViewer(item, fullDiffValue(value));
|
||
parent.appendChild(item);
|
||
}
|
||
|
||
export function renderDiffPanel(parent, owner, recipe, version, trigger) {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'anomalous-recipe-version-diff';
|
||
appendText(panel, 'strong', t('recipeDiffLoading'));
|
||
parent.appendChild(panel);
|
||
trigger.disabled = true;
|
||
fetch(`/anomalous/recipe_version?filename=${encodeURIComponent(owner.recipeDetailFilename)}&version=${encodeURIComponent(version.version)}`)
|
||
.then(async (response) => {
|
||
const payload = await response.json();
|
||
if (!response.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('version diff request failed');
|
||
return payload.data;
|
||
})
|
||
.then((historical) => {
|
||
panel.replaceChildren();
|
||
const changes = buildRecipeDiff(historical, recipe);
|
||
if (diffIsEmpty(changes)) {
|
||
appendText(panel, 'p', t('recipeDiffNoChanges'), 'anomalous-recipe-detail-muted');
|
||
return;
|
||
}
|
||
appendText(panel, 'strong', `${t('recipeDiffSummary')} (${changes.length})`);
|
||
const groups = new Map();
|
||
for (const change of changes) {
|
||
if (!groups.has(change.category)) groups.set(change.category, []);
|
||
groups.get(change.category).push(change);
|
||
}
|
||
for (const [category, categoryChanges] of groups) {
|
||
const group = document.createElement('section');
|
||
group.className = 'anomalous-recipe-diff-group';
|
||
appendText(group, 'h5', diffCategoryLabel(category));
|
||
for (const change of categoryChanges) {
|
||
const row = document.createElement('article');
|
||
row.className = `anomalous-recipe-diff-row anomalous-recipe-diff-${change.kind}`;
|
||
const values = document.createElement('div');
|
||
values.className = 'anomalous-recipe-diff-values';
|
||
const marker = change.kind === 'added' ? '+' : change.kind === 'removed' ? '−' : '→';
|
||
appendText(row, 'span', marker, 'anomalous-recipe-diff-marker');
|
||
appendText(row, 'strong', change.label || change.key, 'anomalous-recipe-diff-label');
|
||
if (change.kind !== 'added') appendDiffValue(values, t('recipeDiffBefore'), change.before, 'before');
|
||
if (change.kind === 'changed') appendText(row, 'span', '→', 'anomalous-recipe-diff-arrow');
|
||
if (change.kind !== 'removed') appendDiffValue(values, t('recipeDiffAfter'), change.after, 'after');
|
||
row.appendChild(values);
|
||
group.appendChild(row);
|
||
}
|
||
panel.appendChild(group);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('Could not compare Workflow Recipe version:', error);
|
||
panel.replaceChildren();
|
||
appendText(panel, 'p', t('recipeDiffError'), 'anomalous-recipe-dialog-error');
|
||
})
|
||
.finally(() => {
|
||
trigger.disabled = false;
|
||
trigger.textContent = t('recipeCompareVersion');
|
||
});
|
||
}
|
||
|
||
export function renderVersions(content, owner, recipe, history, finish) {
|
||
const section = document.createElement('section');
|
||
section.className = 'anomalous-recipe-detail-section';
|
||
appendText(section, 'h4', t('recipeHistory'));
|
||
const timeline = document.createElement('div');
|
||
timeline.className = 'anomalous-recipe-version-timeline';
|
||
const current = document.createElement('article');
|
||
current.className = 'anomalous-recipe-version-row current';
|
||
appendText(current, 'strong', t('recipeDetailCurrentVersion'));
|
||
appendText(current, 'span', dateText(recipe.updated_timestamp || recipe.timestamp));
|
||
const currentFingerprint = fingerprintText(recipe);
|
||
appendText(current, 'code', shortHash(currentFingerprint) || t('recipeDetailNotIndexed'));
|
||
if (currentFingerprint) appendCopyButton(current, currentFingerprint, t('recipeDetailCopyFingerprint'));
|
||
timeline.appendChild(current);
|
||
for (const version of history || []) {
|
||
const row = document.createElement('article');
|
||
row.className = 'anomalous-recipe-version-row';
|
||
const copy = document.createElement('div');
|
||
appendText(copy, 'strong', version.name || t('recipeUnknownVersion'));
|
||
appendText(copy, 'span', dateText(version.timestamp));
|
||
row.appendChild(copy);
|
||
const versionFingerprint = version.workflow_fingerprint?.value || '';
|
||
appendText(row, 'code', shortHash(versionFingerprint) || t('recipeDetailNotIndexed'));
|
||
if (versionFingerprint) appendCopyButton(row, versionFingerprint, t('recipeDetailCopyFingerprint'));
|
||
const compare = button(row, t('recipeCompareVersion'), 'anomalous-btn-primary');
|
||
compare.onclick = () => {
|
||
const existing = row.querySelector('.anomalous-recipe-version-diff');
|
||
if (existing) {
|
||
existing.remove();
|
||
compare.textContent = t('recipeCompareVersion');
|
||
return;
|
||
}
|
||
compare.textContent = t('recipeDiffLoading');
|
||
renderDiffPanel(row, owner, recipe, version, compare);
|
||
};
|
||
const restore = button(row, t('recipeRestoreVersion'), 'anomalous-btn-danger');
|
||
restore.onclick = async () => {
|
||
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
|
||
try {
|
||
const response = await fetch('/anomalous/restore_recipe_version', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: owner.recipeDetailFilename, version: version.version }),
|
||
});
|
||
if (!response.ok) throw new Error('restore failed');
|
||
await owner.refreshRecipes();
|
||
finish('restored');
|
||
} catch (error) {
|
||
console.error('Could not restore recipe version:', error);
|
||
await anomalousAlert(t('recipeUpdateError'));
|
||
}
|
||
};
|
||
timeline.appendChild(row);
|
||
}
|
||
if (!(history || []).length) appendText(timeline, 'p', t('recipeHistoryEmpty'), 'anomalous-recipe-detail-muted');
|
||
section.appendChild(timeline);
|
||
content.appendChild(section);
|
||
}
|