470 lines
19 KiB
JavaScript
470 lines
19 KiB
JavaScript
/** Recipe model preview, matching, replacement, and identity UI. */
|
|
|
|
import { translate } from "./locales.js";
|
|
import { formatIdentitySize, normaliseIdentity, recipeReferenceKey } from "./recipe_identity.js";
|
|
import { replaceWorkflowModelHashRecord } from "./recipe_provenance.js";
|
|
import { appendText } from "./ui_recipe_detail_dom.js";
|
|
import { outputImageUrl } from "./ui_recipe_gallery.js";
|
|
import { updateRecipeMetadata } from "./ui_recipe_metadata.js";
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
|
|
export function identityBadge(reference) {
|
|
const identity = normaliseIdentity(reference?.identity);
|
|
const wrapper = document.createElement('span');
|
|
wrapper.className = 'anomalous-recipe-identity-badge-wrap';
|
|
const badge = document.createElement('span');
|
|
badge.className = `anomalous-recipe-identity-badge anomalous-recipe-identity-${identity.status}`;
|
|
|
|
const textSpan = document.createElement('span');
|
|
textSpan.textContent = t(`recipeIdentity${identity.status[0].toUpperCase()}${identity.status.slice(1)}`);
|
|
badge.appendChild(textSpan);
|
|
|
|
const helpIcon = document.createElement('button');
|
|
helpIcon.type = 'button';
|
|
helpIcon.textContent = '?';
|
|
helpIcon.className = 'anomalous-recipe-identity-help';
|
|
const helpText = t('recipeIdentityHelpDesc') || 'Verification checks physical file consistency, not model quality.';
|
|
helpIcon.setAttribute('aria-expanded', 'false');
|
|
helpIcon.setAttribute('aria-label', helpText);
|
|
badge.appendChild(helpIcon);
|
|
|
|
const explanation = document.createElement('span');
|
|
explanation.className = 'anomalous-recipe-identity-explanation';
|
|
explanation.textContent = helpText;
|
|
explanation.setAttribute('role', 'note');
|
|
helpIcon.onclick = (event) => {
|
|
event.stopPropagation();
|
|
const expanded = wrapper.classList.toggle('is-open');
|
|
helpIcon.setAttribute('aria-expanded', String(expanded));
|
|
};
|
|
|
|
wrapper.append(badge, explanation);
|
|
return wrapper;
|
|
}
|
|
|
|
function folderTypesForReference(reference) {
|
|
const category = String(reference?.category || '').toLowerCase();
|
|
return {
|
|
checkpoint: ['checkpoints'],
|
|
unet: ['unet', 'diffusion_models'],
|
|
lora: ['loras'],
|
|
vae: ['vae'],
|
|
text_encoder: ['text_encoders', 'clip'],
|
|
clip_vision: ['clip_vision'],
|
|
controlnet: ['controlnet'],
|
|
}[category] || [];
|
|
}
|
|
|
|
function resolutionTypesForReference(reference) {
|
|
return folderTypesForReference(reference).filter((type) => [
|
|
'checkpoints', 'unet', 'diffusion_models', 'loras', 'vae', 'vae_approx',
|
|
'controlnet', 'clip', 'text_encoders', 'clip_vision',
|
|
].includes(type));
|
|
}
|
|
|
|
export function modelDisplayName(value) {
|
|
const path = String(value || '').replace(/\\/g, '/');
|
|
const filename = path.split('/').pop() || t('recipeDetailUnavailable');
|
|
return filename.replace(/\.(?:safetensors|ckpt|pt|bin|sft)$/i, '');
|
|
}
|
|
|
|
function previewIsVideo(url) {
|
|
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
|
|
}
|
|
|
|
export function appendRecipeCover(parent, owner, recipe) {
|
|
const sourceUrl = outputImageUrl(recipe?.source_image);
|
|
const savedCover = recipeAssetUrl(owner, recipe?.presentation?.cover_asset_id);
|
|
const url = savedCover || (previewIsVideo(sourceUrl) ? sourceUrl : recipe?.thumbnail || sourceUrl);
|
|
if (!url) return false;
|
|
if (previewIsVideo(url)) {
|
|
const video = document.createElement('video');
|
|
video.src = url;
|
|
video.muted = true;
|
|
video.loop = true;
|
|
video.playsInline = true;
|
|
video.preload = 'metadata';
|
|
video.onpointerenter = () => video.play().catch(() => {});
|
|
video.onpointerleave = () => {
|
|
video.pause();
|
|
video.currentTime = 0;
|
|
};
|
|
parent.appendChild(video);
|
|
} else {
|
|
const image = document.createElement('img');
|
|
image.src = url;
|
|
image.alt = recipe.name || t('recipeThumbnail');
|
|
image.loading = 'lazy';
|
|
parent.appendChild(image);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function recipeAssetUrl(owner, assetId) {
|
|
if (!owner?.recipeDetailFilename || !assetId) return '';
|
|
return `/anomalous/recipe_asset?filename=${encodeURIComponent(owner.recipeDetailFilename)}&asset=${encodeURIComponent(assetId)}`;
|
|
}
|
|
|
|
export function appendModelPreview(parent, owner, reference, onActivate = null) {
|
|
const preview = document.createElement('div');
|
|
preview.className = 'anomalous-recipe-model-preview';
|
|
if (onActivate) {
|
|
preview.classList.add('is-clickable');
|
|
preview.title = t('recipeOpenLocalModel');
|
|
preview.onclick = onActivate;
|
|
}
|
|
const snapshotUrl = recipeAssetUrl(owner, reference?.preview?.snapshot_asset_id);
|
|
const url = snapshotUrl || reference?.currentPreviewUrl;
|
|
if (!url) {
|
|
preview.classList.add('empty');
|
|
appendText(preview, 'span', String(reference?.category || t('recipeDetailModel')).slice(0, 3).toUpperCase());
|
|
appendText(preview, 'small', t('recipeDetailNoPreview'));
|
|
parent.appendChild(preview);
|
|
return;
|
|
}
|
|
|
|
if (previewIsVideo(url)) {
|
|
const video = document.createElement('video');
|
|
video.src = url;
|
|
video.muted = true;
|
|
video.loop = true;
|
|
video.playsInline = true;
|
|
video.preload = 'metadata';
|
|
video.onpointerenter = () => video.play().catch(() => {});
|
|
video.onpointerleave = () => video.pause();
|
|
preview.appendChild(video);
|
|
} else {
|
|
const image = document.createElement('img');
|
|
image.src = url;
|
|
image.alt = reference.saved_value || t(snapshotUrl ? 'recipeDetailSavedSnapshot' : 'recipeDetailCurrentPreview');
|
|
image.loading = 'lazy';
|
|
preview.appendChild(image);
|
|
}
|
|
appendText(preview, 'small', t(snapshotUrl ? 'recipeDetailSavedSnapshot' : 'recipeDetailCurrentPreview'));
|
|
parent.appendChild(preview);
|
|
}
|
|
|
|
export async function loadCurrentPreviews(owner, references) {
|
|
const contextRequests = references
|
|
.filter((reference) => typeof reference?.saved_value === 'string' && reference.saved_value)
|
|
.map((reference) => ({
|
|
key: recipeReferenceKey(reference),
|
|
path: reference.saved_value,
|
|
folder_types: folderTypesForReference(reference),
|
|
exact_only: true,
|
|
}));
|
|
if (!contextRequests.length) return;
|
|
|
|
const response = await fetch('/anomalous/resolve_paths_to_previews', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths: [], exact_only: true, context_requests: contextRequests }),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error('recipe preview request failed');
|
|
const models = payload.context_models || {};
|
|
for (const reference of references) {
|
|
const model = models[recipeReferenceKey(reference)];
|
|
if (!model) continue;
|
|
reference.currentPreviewUrl = model.preview_url || '';
|
|
reference.currentAvailability = 'available';
|
|
reference.localModel = model;
|
|
}
|
|
}
|
|
|
|
export function openLocalModel(owner, model) {
|
|
if (!model || typeof owner?.showDetail !== 'function') return false;
|
|
owner.historyStack = [];
|
|
owner.currentType = model.type || owner.currentType;
|
|
owner.currentPathIdx = model.path_idx ?? model.path_index ?? 0;
|
|
owner.currentSubfolder = model.subfolder || '/';
|
|
owner.currentDetailModel = model;
|
|
// The recipe workspace is a child overlay of the main browser modal. Closing
|
|
// the browser here also hides the detail panel we are navigating to.
|
|
owner.modal?.classList.add('visible');
|
|
for (const panel of [
|
|
owner.grid,
|
|
owner.galleryPanel,
|
|
owner.doctorPanel,
|
|
owner.assistantPanel,
|
|
owner.paramPanel,
|
|
owner.nbPanel,
|
|
]) {
|
|
if (panel) panel.style.display = 'none';
|
|
}
|
|
owner.showDetail(model);
|
|
return true;
|
|
}
|
|
|
|
async function resolveMatchedModelPreview(reference, model) {
|
|
if (!model?.filename || !model?.type) return null;
|
|
const response = await fetch('/anomalous/resolve_paths_to_previews', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
paths: [],
|
|
exact_only: true,
|
|
context_requests: [{
|
|
key: 'match',
|
|
path: model.filename,
|
|
folder_types: [model.type],
|
|
exact_only: true,
|
|
}],
|
|
}),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error('matched model preview request failed');
|
|
return payload.context_models?.match || null;
|
|
}
|
|
|
|
function sameStoredModelReference(candidate, reference) {
|
|
return String(candidate?.node_id ?? '') === String(reference?.node_id ?? '')
|
|
&& Number(candidate?.widget_index) === Number(reference?.widget_index)
|
|
&& String(candidate?.category || '') === String(reference?.category || '')
|
|
&& candidate?.saved_value === reference?.saved_value;
|
|
}
|
|
|
|
function identityForLocalMatch(reference, model, result) {
|
|
const sourceIdentity = normaliseIdentity(reference?.identity);
|
|
const metadataHash = String(model?.metadata?.hash || '').trim();
|
|
const sha256 = result?.matched_by_hash && sourceIdentity.sha256
|
|
? sourceIdentity.sha256
|
|
: (/^[0-9a-f]{64}$/i.test(metadataHash) ? metadataHash.toLowerCase() : '');
|
|
const modelSize = Number(model?.size_bytes);
|
|
const resultSize = Number(result?.size);
|
|
const size = Number.isFinite(modelSize) && modelSize > 0 ? modelSize : resultSize;
|
|
const identity = {
|
|
status: sha256 ? 'verified' : 'unverified',
|
|
provenance: result?.matched_by_hash
|
|
? 'local hash match'
|
|
: (sha256 ? 'confirmed local candidate metadata' : 'manual size confirmation'),
|
|
};
|
|
if (sha256) identity.sha256 = sha256;
|
|
if (Number.isFinite(size) && size > 0) identity.size = size;
|
|
return identity;
|
|
}
|
|
|
|
export async function matchLocalModel(owner, recipe, reference, status, rerender) {
|
|
const identity = normaliseIdentity(reference.identity);
|
|
const query = new URLSearchParams({
|
|
hash: identity.sha256 || 'unknown',
|
|
size: identity.size || '',
|
|
filename: reference.saved_value || '',
|
|
});
|
|
const types = resolutionTypesForReference(reference);
|
|
if (types.length) query.set('type', types.join(','));
|
|
status.textContent = t('recipeMatchingLocalModel');
|
|
try {
|
|
const response = await fetch(`/anomalous/resolve_hash?${query.toString()}`);
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error('local model matching failed');
|
|
if (!result.found && !result.confirmation_required) {
|
|
status.textContent = result.identity_conflict
|
|
? t('recipeLocalModelIdentityConflict')
|
|
: result.ambiguous
|
|
? t('recipeLocalModelAmbiguous')
|
|
: t('recipeLocalModelNotFound');
|
|
return;
|
|
}
|
|
const model = await resolveMatchedModelPreview(reference, result);
|
|
if (!model) throw new Error('matched local model metadata unavailable');
|
|
reference.localModel = model;
|
|
reference.currentPreviewUrl = model.preview_url || '';
|
|
reference.currentAvailability = 'available';
|
|
reference.localMatch = {
|
|
filename: result.filename,
|
|
type: model.type,
|
|
matched_by_hash: result.matched_by_hash === true,
|
|
matched_by_size: result.matched_by_size === true,
|
|
confirmation_required: result.confirmation_required === true,
|
|
identity: identityForLocalMatch(reference, model, result),
|
|
};
|
|
rerender();
|
|
} catch (error) {
|
|
console.error('Could not match imported recipe model locally:', error);
|
|
status.textContent = t('recipeLocalModelMatchError');
|
|
}
|
|
}
|
|
|
|
export async function matchRecipeModels(owner, references, status, rerender) {
|
|
const candidates = references.filter((reference) => !reference.localModel);
|
|
const items = candidates.map((reference, index) => {
|
|
const identity = normaliseIdentity(reference.identity);
|
|
return {
|
|
key: String(index),
|
|
hash: identity.sha256 || 'unknown',
|
|
size: identity.size ?? null,
|
|
type: resolutionTypesForReference(reference).join(','),
|
|
};
|
|
});
|
|
if (!items.length) {
|
|
status.textContent = t('recipeAllModelsMatched');
|
|
return { found: 0, total: 0 };
|
|
}
|
|
|
|
status.textContent = t('recipeMatchingRecipeModels');
|
|
const response = await fetch('/anomalous/resolve_hash_batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || !Array.isArray(payload.results)) throw new Error('recipe model matching failed');
|
|
|
|
let found = 0;
|
|
for (const item of payload.results) {
|
|
const reference = candidates[Number(item.key)];
|
|
const result = item.result;
|
|
if (!reference || (!result?.found && !result?.confirmation_required)) continue;
|
|
let model;
|
|
try {
|
|
model = await resolveMatchedModelPreview(reference, result);
|
|
} catch (error) {
|
|
console.warn('Could not load preview for matched recipe model:', error);
|
|
continue;
|
|
}
|
|
if (!model) continue;
|
|
reference.localModel = model;
|
|
reference.currentPreviewUrl = model.preview_url || '';
|
|
reference.currentAvailability = 'available';
|
|
reference.localMatch = {
|
|
filename: result.filename,
|
|
type: model.type,
|
|
matched_by_hash: result.matched_by_hash === true,
|
|
matched_by_size: result.matched_by_size === true,
|
|
confirmation_required: result.confirmation_required === true,
|
|
identity: identityForLocalMatch(reference, model, result),
|
|
};
|
|
found += 1;
|
|
}
|
|
rerender();
|
|
return { found, total: candidates.length };
|
|
}
|
|
|
|
export async function applyLocalModelMatch(owner, recipe, reference, status, rerender) {
|
|
const filename = reference.localMatch?.filename || '';
|
|
const node = (recipe?.workflow?.nodes || []).find(
|
|
(candidate) => String(candidate?.id ?? '') === String(reference?.node_id ?? ''),
|
|
);
|
|
const index = Number(reference?.widget_index);
|
|
if (!filename || !node || !Number.isInteger(index) || !Array.isArray(node.widgets_values) || index < 0 || index >= node.widgets_values.length) {
|
|
status.textContent = t('recipeApplyLocalMatchError');
|
|
return false;
|
|
}
|
|
|
|
const workflow = JSON.parse(JSON.stringify(recipe.workflow));
|
|
const target = workflow.nodes.find((candidate) => String(candidate?.id ?? '') === String(reference.node_id ?? ''));
|
|
if (!target || !Array.isArray(target.widgets_values)) {
|
|
status.textContent = t('recipeApplyLocalMatchError');
|
|
return false;
|
|
}
|
|
target.widgets_values[index] = filename;
|
|
const previousValue = reference.saved_value;
|
|
const localIdentity = normaliseIdentity(reference.localMatch?.identity);
|
|
replaceWorkflowModelHashRecord(workflow, reference.node_id, previousValue, filename, localIdentity);
|
|
|
|
const params = JSON.parse(JSON.stringify(recipe.params || {}));
|
|
if (Array.isArray(params.model_references)) {
|
|
const stored = params.model_references.find((candidate) => sameStoredModelReference(candidate, reference));
|
|
if (stored) {
|
|
stored.saved_value = filename;
|
|
stored.identity = localIdentity;
|
|
}
|
|
}
|
|
if (params.baseModel === previousValue) params.baseModel = filename;
|
|
|
|
status.textContent = t('recipeApplyingLocalMatch');
|
|
try {
|
|
await updateRecipeMetadata(owner, recipe, { workflow, params });
|
|
reference.saved_value = filename;
|
|
reference.identity = localIdentity;
|
|
reference.localMatch = null;
|
|
reference.currentAvailability = 'available';
|
|
rerender();
|
|
status.textContent = t('recipeApplyLocalMatchSuccess');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Could not apply local recipe model match:', error);
|
|
status.textContent = t('recipeApplyLocalMatchError');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function applyAllLocalModelMatches(owner, recipe, references, status, rerender) {
|
|
const candidates = (references || []).filter((ref) => ref?.localMatch?.filename);
|
|
if (!candidates.length) return false;
|
|
|
|
const workflow = JSON.parse(JSON.stringify(recipe.workflow));
|
|
const params = JSON.parse(JSON.stringify(recipe.params || {}));
|
|
if (!Array.isArray(params.model_references)) params.model_references = [];
|
|
|
|
let appliedCount = 0;
|
|
for (const reference of candidates) {
|
|
const filename = reference.localMatch.filename;
|
|
const target = workflow.nodes?.find((c) => String(c?.id ?? '') === String(reference.node_id ?? ''));
|
|
const index = Number(reference.widget_index);
|
|
if (!target || !Array.isArray(target.widgets_values) || index < 0 || index >= target.widgets_values.length) continue;
|
|
|
|
target.widgets_values[index] = filename;
|
|
const previousValue = reference.saved_value;
|
|
const localIdentity = normaliseIdentity(reference.localMatch.identity);
|
|
replaceWorkflowModelHashRecord(workflow, reference.node_id, previousValue, filename, localIdentity);
|
|
|
|
const stored = params.model_references.find((c) => sameStoredModelReference(c, reference));
|
|
if (stored) {
|
|
stored.saved_value = filename;
|
|
stored.identity = localIdentity;
|
|
}
|
|
if (params.baseModel === previousValue) params.baseModel = filename;
|
|
|
|
reference.saved_value = filename;
|
|
reference.identity = localIdentity;
|
|
reference.localMatch = null;
|
|
reference.currentAvailability = 'available';
|
|
appliedCount += 1;
|
|
}
|
|
|
|
if (appliedCount === 0) return false;
|
|
|
|
if (status) status.textContent = t('recipeApplyingAllMatches');
|
|
try {
|
|
await updateRecipeMetadata(owner, recipe, { workflow, params });
|
|
if (typeof rerender === 'function') rerender();
|
|
if (status) status.textContent = t('recipeApplyAllMatchesSuccess');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Could not apply all local model matches:', error);
|
|
if (status) status.textContent = t('recipeApplyLocalMatchError');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function updateRecipeModelNote(owner, recipe, reference, note, rerender) {
|
|
const params = JSON.parse(JSON.stringify(recipe.params || {}));
|
|
if (!Array.isArray(params.model_references)) params.model_references = [];
|
|
let stored = params.model_references.find((candidate) => sameStoredModelReference(candidate, reference));
|
|
if (!stored) {
|
|
stored = {
|
|
node_id: reference.node_id,
|
|
node_type: reference.node_type,
|
|
node_title: reference.node_title,
|
|
widget_index: reference.widget_index,
|
|
widget_name: reference.widget_name,
|
|
saved_value: reference.saved_value,
|
|
category: reference.category,
|
|
base_model: reference.base_model,
|
|
identity: normaliseIdentity(reference.identity),
|
|
};
|
|
params.model_references.push(stored);
|
|
}
|
|
const cleanNote = String(note || '').trim();
|
|
if (cleanNote) stored.user_note = cleanNote;
|
|
else delete stored.user_note;
|
|
await updateRecipeMetadata(owner, recipe, { params });
|
|
recipe.params = params;
|
|
reference.user_note = cleanNote;
|
|
rerender();
|
|
}
|
|
|