Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_recipe_model_matching.js
T

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();
}