The overview's models are one compact list (preview, name, node, here under the saved name or missing, size, note, download page). The recipe's own match / apply-match flow, identity badges, advanced path and hash blocks, origin editor and recipe fingerprint are gone; Model Check finds renamed or moved models once the workflow is on the canvas, and saving the recipe again keeps it. ui_recipe_model_matching.js becomes ui_recipe_models.js; ui_recipe_detail.js drops from 776 to 292 lines. Plain wording for the save dialog, empty page and status; no per-node save-to-library buttons on the parameters page. Recipe CSS rules for classes no script uses (107 rules) are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
/**
|
|
* The models a recipe uses, on its overview: each one's preview, name, whether this
|
|
* computer has it under the saved name, its size, note and download page. Finding a model
|
|
* that is missing or renamed is Model Check's job once the workflow is open
|
|
* (model_check.js), so nothing here matches or replaces. Also the recipe cover.
|
|
*/
|
|
|
|
import { translate } from "./locales.js";
|
|
import { formatIdentitySize, normaliseIdentity, recipeReferenceKey } from "./recipe_identity.js";
|
|
import { usableSourceUrl } from "./model_source_data.js";
|
|
import { anomalousAlert, anomalousPrompt } from "./ui_dialog.js";
|
|
import { appendText, button } 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);
|
|
|
|
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] || [];
|
|
}
|
|
|
|
export function modelDisplayName(value) {
|
|
const path = String(value || '').replace(/\\/g, '/');
|
|
const filename = path.split('/').pop() || t('recipeDetailUnavailable');
|
|
return filename.replace(/\.(?:safetensors|ckpt|pt|pth|bin|sft|gguf)$/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)}`;
|
|
}
|
|
|
|
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());
|
|
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 = modelDisplayName(reference.saved_value);
|
|
image.loading = 'lazy';
|
|
preview.appendChild(image);
|
|
}
|
|
parent.appendChild(preview);
|
|
}
|
|
|
|
/** Looks each model up here by its saved name and folder type; what is not found is missing. */
|
|
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)];
|
|
reference.currentAvailability = model ? 'available' : 'missing';
|
|
reference.localModel = model || null;
|
|
reference.currentPreviewUrl = model?.preview_url || '';
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function updateRecipeModelNote(owner, recipe, reference, note) {
|
|
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;
|
|
}
|
|
|
|
const isBaseReference = (reference) => ['checkpoint', 'unet'].includes(String(reference?.category || '').toLowerCase());
|
|
|
|
const AVAILABILITY = { available: 'recipeDetailAvailable', missing: 'recipeDetailMissing' };
|
|
|
|
function modelRow(owner, recipe, reference, openModel, rerender) {
|
|
const local = Boolean(reference.localModel);
|
|
const card = document.createElement('article');
|
|
card.className = `anomalous-recipe-model-reference ${local ? 'is-local' : 'is-unresolved'}`;
|
|
const body = document.createElement('div');
|
|
body.className = 'anomalous-recipe-model-reference-body';
|
|
const open = local ? () => openModel(reference) : null;
|
|
appendModelPreview(body, owner, reference, open);
|
|
|
|
const details = document.createElement('div');
|
|
details.className = 'anomalous-recipe-model-reference-details';
|
|
const top = document.createElement('div');
|
|
top.className = 'anomalous-recipe-model-reference-top';
|
|
const name = reference.origin?.model_name || modelDisplayName(reference.saved_value);
|
|
if (local) button(top, name, 'anomalous-recipe-model-name is-resolved').onclick = open;
|
|
else appendText(top, 'strong', name, 'anomalous-recipe-model-name is-unresolved');
|
|
appendText(top, 'span', reference.node_title || reference.node_type || t('recipeDetailModel'), 'anomalous-recipe-detail-muted');
|
|
details.appendChild(top);
|
|
appendText(details, 'small', reference.saved_value || '', 'anomalous-recipe-model-subtitle').title = reference.saved_value || '';
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'anomalous-recipe-model-reference-meta';
|
|
const state = reference.currentAvailability;
|
|
appendText(meta, 'span', t(AVAILABILITY[state] || 'recipeDetailAvailabilityNotChecked'), `anomalous-recipe-model-state is-${state || 'unknown'}`);
|
|
const size = formatIdentitySize(normaliseIdentity(reference.identity).size);
|
|
if (size) appendText(meta, 'span', size);
|
|
const url = usableSourceUrl(reference.origin?.model_url);
|
|
if (url) {
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener';
|
|
link.className = 'anomalous-recipe-civitai-btn';
|
|
link.textContent = t('recipeModelDownloadPage');
|
|
meta.appendChild(link);
|
|
}
|
|
const noteText = appendText(meta, 'small', reference.user_note || t('recipeModelNoteEmpty'), 'anomalous-recipe-model-note anomalous-recipe-detail-muted');
|
|
noteText.title = reference.user_note || t('recipeModelNoteEmpty');
|
|
const noteButton = button(meta, t(reference.user_note ? 'recipeModelNoteEdit' : 'recipeModelNoteAdd'), 'anomalous-btn-ghost anomalous-recipe-model-note-btn');
|
|
noteButton.onclick = async () => {
|
|
const note = await anomalousPrompt(t('recipeModelNotePrompt'), reference.user_note || '', t('recipeModelNoteTitle'),
|
|
{ multiline: true, maxLength: 1000, rows: 6 });
|
|
if (note === null) return;
|
|
noteButton.disabled = true;
|
|
try {
|
|
await updateRecipeModelNote(owner, recipe, reference, note);
|
|
rerender();
|
|
} catch (error) {
|
|
console.error('Could not update recipe model note:', error);
|
|
noteButton.disabled = false;
|
|
await anomalousAlert(t('recipeModelNoteError'));
|
|
}
|
|
};
|
|
details.appendChild(meta);
|
|
body.appendChild(details);
|
|
card.appendChild(body);
|
|
return card;
|
|
}
|
|
|
|
/** The model list, base models first; `openModel(reference)` opens a model this computer has. */
|
|
export function renderRecipeModels(container, owner, recipe, references, openModel) {
|
|
container.replaceChildren();
|
|
if (!references.length) {
|
|
appendText(container, 'p', t('recipeDetailNoModelReferences'), 'anomalous-recipe-detail-muted');
|
|
return;
|
|
}
|
|
const rerender = () => renderRecipeModels(container, owner, recipe, references, openModel);
|
|
const list = document.createElement('div');
|
|
list.className = 'anomalous-recipe-model-reference-list';
|
|
const ordered = [...references].sort((a, b) => isBaseReference(b) - isBaseReference(a));
|
|
for (const reference of ordered) list.appendChild(modelRow(owner, recipe, reference, openModel, rerender));
|
|
container.appendChild(list);
|
|
}
|