Files
DemonGatanjieu-Anomalous_Mo…/web/modules/model_source.js
T
DemonGatanjieuandClaude Opus 5.5 4b5f385cdd Lay out the model details like a profile
The name big and its Civitai version split off under it in small grey; one small
accent line of type, size, base and the Civitai link (the Size/Base labels were
English in the Chinese window); Add node the one filled button, the rest plain
words. Trigger words, the author's settings, the files it needs, its versions and
its file are one list of rows parted by thin lines; its author's text starts
folded. Size and space carry it, not boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:34:43 +08:00

178 lines
9.1 KiB
JavaScript

/**
* Where a model's information came from, shown on cards and in the detail header.
* Matched on Civitai is the normal case and looks as before; only the exceptions are
* marked: base model inferred from the file ("推断"), a damaged file, not scanned yet, and set to
* "don't scan" (metadata.scan_choice). The detail header also names the fields the user set
* (metadata.user_fields, from <model>.anomalous.json), and for a model not scanned whose header
* looks like no drawing model, or one set to "don't scan", a button to change that. Its links
* open the source link (named after its site) and Civitai's page; a model Civitai does not know
* gets a note under it saying what was read from the file.
*/
import { translate as t } from './locales.js';
import { escapeHtml } from './safe_dom.js';
import { isPending, reasonText } from './scan_results.js';
import { detectPlatform } from './model_source_links.js';
import { usableSourceUrl } from './model_source_data.js';
const FIELD_KEYS = { custom_name: 'modelSourceFieldName', custom_notes: 'modelSourceFieldNotes', source_url: 'modelSourceFieldLink' };
function colourClass(baseModel) {
const lower = baseModel.toLowerCase();
if (lower.includes('flux')) return 'badge-flux';
if (lower.includes('pony') || lower.includes('illustrious') || lower.includes('anime')) return 'badge-rose';
if (lower.includes('sdxl') || lower.includes('xl')) return 'badge-gold';
return 'badge-amber';
}
/** Only .safetensors files are scanned, so only they can be "not scanned yet". */
function scannable(model) {
return /\.safetensors$/i.test(String(model?.filename || ''));
}
/** The card's top-left badge, or null. */
export function renderSourceBadge(model) {
const meta = model?.metadata || {};
const baseModel = String(meta.baseModel || '');
const badge = document.createElement('div');
badge.className = 'anomalous-card-badge';
if (meta.info_source === 'local' && meta.unmatched_reason === 'damaged') {
badge.classList.add('is-inferred', 'is-damaged');
badge.textContent = `⚠ ${t('modelSourceDamagedShort')}`;
badge.title = reasonText('damaged');
} else if (meta.info_source === 'local') {
badge.classList.add('is-inferred', ...(baseModel ? [colourClass(baseModel)] : []));
badge.textContent = baseModel ? t('modelSourceInferredBadge', { base: baseModel }) : t('modelSourceLocalShort');
badge.title = t('modelSourceLocalHint', { reason: reasonText(meta.unmatched_reason) });
} else if (!meta.info_source && scannable(model)) {
const skipped = meta.scan_choice === 'skip';
badge.classList.add('is-unscanned');
badge.textContent = t(skipped ? 'modelSourceSkipped' : 'modelSourceNotScanned');
badge.title = t(skipped ? 'modelSourceSkippedHint' : meta.not_drawing ? 'modelSourceNotDrawingHint' : 'modelSourceNotScannedHint');
} else if (baseModel) {
badge.classList.add(colourClass(baseModel));
badge.textContent = baseModel;
} else {
return null;
}
return badge;
}
/** The detail header's line: size, base model, where the information came from, what the user set. */
/** "3.89 GB", or "151 MB" under a gigabyte. */
export function sizeWords(megabytes) {
const mb = Number(megabytes) || 0;
return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${Math.round(mb)} MB`;
}
export function renderSourceLine(span, model, meta) {
const parts = [escapeHtml(sizeWords(model.size_mb))];
if (meta.baseModel) parts.push(`${escapeHtml(t('modelBaseLabel'))} ${meta.info_source === 'local' ? '≈ ' : ''}${escapeHtml(meta.baseModel)}`);
const skipped = meta.scan_choice === 'skip';
const source = meta.info_source === 'civitai' ? t('modelSourceCivitai')
: meta.info_source === 'local' ? t('modelSourceLocal')
: scannable(model) ? t(skipped ? 'modelSourceSkipped' : 'modelSourceNotScanned') : '';
// Civitai's own link beside the line names it already.
if (source && !(meta.info_source === 'civitai' && openable(meta.civitai_url))) parts.push(`${escapeHtml(t('modelSourceLabel'))} ${escapeHtml(source)}`);
if (skipped && meta.info_source) parts.push(escapeHtml(t('modelSourceSkipped')));
if (meta.info_source === 'local' && meta.unmatched_reason === 'damaged') {
parts.push(`<span class="anomalous-source-damaged">⚠ ${escapeHtml(t('modelSourceDamaged'))}</span>`);
}
const edited = (meta.user_fields || []).map(field => t(FIELD_KEYS[field] || field));
if (edited.length) parts.push(escapeHtml(t('modelSourceEdited', { fields: edited.join(t('modelSourceListSep')) })));
span.innerHTML = parts.join('<span class="anomalous-source-sep"> · </span>');
span.title = meta.info_source === 'local' ? t('modelSourceLocalHint', { reason: reasonText(meta.unmatched_reason) })
: skipped ? t('modelSourceSkippedHint')
: !meta.info_source && scannable(model) ? t(meta.not_drawing ? 'modelSourceNotDrawingHint' : 'modelSourceNotScannedHint') : '';
if (scannable(model) && (skipped || (!meta.info_source && meta.not_drawing))) span.append(choiceButton(span, model, meta));
}
/** A link the page may open: http(s) only. */
function openable(url) {
const clean = usableSourceUrl(url);
return /^https?:\/\//i.test(clean) ? clean : '';
}
/** The details' links: the source link (its site named, its colour as a dot) and Civitai's page. */
export function renderDetailLinks(meta) {
const box = document.createElement('span');
box.className = 'anomalous-detail-links';
const civitai = openable(meta.civitai_url);
const source = openable(meta.source_url);
const link = (url, label, colour) => {
const node = document.createElement('a');
node.className = 'anomalous-detail-link';
Object.assign(node, { href: url, target: '_blank', rel: 'noopener noreferrer', title: url });
const dot = document.createElement('span');
dot.className = 'anomalous-detail-link-dot';
if (colour) dot.style.background = colour;
node.append(dot, `${label} ↗`);
box.append(node);
};
if (source && source !== civitai) {
const platform = detectPlatform(source);
link(source, platform.name === 'Web Link' ? t('modelLinkSource') : platform.name, platform.name === 'Web Link' ? '' : platform.color);
}
if (civitai) link(civitai, 'Civitai', 'rgb(56, 189, 248)');
return box;
}
/**
* Under the detail header of a model Civitai does not know: why, that the base model was read
* from the file (and may be off), and a button to add a name and a source link. Null for others.
*/
export function renderInferredNote(meta, onEdit) {
if (meta.info_source !== 'local') return null;
const damaged = meta.unmatched_reason === 'damaged';
const note = document.createElement('div');
note.className = `anomalous-inferred-note${damaged ? ' is-damaged' : ''}`;
const mark = document.createElement('span');
mark.className = 'anomalous-inferred-mark';
mark.textContent = damaged ? '⚠' : '≈';
const text = document.createElement('div');
text.className = 'anomalous-inferred-text';
const title = document.createElement('strong');
title.textContent = damaged ? t('modelSourceDamaged') : reasonText(meta.unmatched_reason);
const body = document.createElement('span');
body.textContent = damaged ? reasonText('damaged')
: [t(meta.baseModel ? 'modelInferredBase' : 'modelInferredNoBase', { base: meta.baseModel }),
isPending(meta.unmatched_reason) ? t('modelInferredPending') : ''].filter(Boolean).join(' ');
text.append(title, body);
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'anomalous-inferred-edit';
edit.textContent = t(meta.source_url ? 'modelInferredEdit' : 'modelInferredFill');
edit.onclick = onEdit;
note.append(mark, text, edit);
return note;
}
/** "Don't scan" / "Scan it after all" in the detail header: saves the choice, then draws the line again. */
function choiceButton(span, model, meta) {
const skip = meta.scan_choice !== 'skip';
const node = document.createElement('button');
node.type = 'button';
node.className = 'anomalous-source-choice';
node.textContent = t(skip ? 'modelSourceSetSkip' : 'modelSourceSetScan');
node.onclick = async () => {
node.disabled = true;
const choice = skip ? 'skip' : '';
try {
const res = await fetch('/anomalous/scan_choice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ choice, targets: [{ type: model.type, path_idx: model.path_idx, subfolder: model.subfolder || '/', files: [model.filename] }] }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.status !== 'success' || data.changed !== 1) throw new Error(data.message || `HTTP ${res.status}`);
meta.scan_choice = choice;
renderSourceLine(span, model, meta);
} catch (error) {
console.error('Could not save the scan choice:', error);
node.disabled = false;
alert(t('scanChoiceFailed', { count: 1 }));
}
};
return node;
}