Files
DemonGatanjieuandClaude Opus 5.5 a93ed042d2 Slim the recipe detail and leave model recovery to Model Check
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>
2026-10-03 16:41:51 +08:00

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