Privacy mode left the AI blind twice over: the whole prompt was a token, so it could not help with light, lens or style, and model and LoRA file names still went out as written. - The prompt box has a first field, the gist: the picture in words the user writes for the AI to read. seal_answer hands it on as written beside the tokens of the private prompts; either alone can be handed in. - ai_sealed_models.py: while the Gates window's "Model names in a private task" is on (the default), each model of the library is one token ([M1], sealed as its model_id) however an answer writes it: model_id, loader name, file name in a text, card name. A model's own entry drops name, file, folder, links and hash and keeps type, base model, the author's settings and the user's note. expand writes a model token back as the model_id, as the loader's name among a node's values, as the card's name inside the AI's words. The task's menu lists what each token stands for. - The model editor has "Note for the AI" (ai_note, note_for_ai in the tools, searched by search_models): what a model is for, in words the AI may read. - A combo's name is sealed too: unless renamed it is made of its model's name and the start of its prompt. - The privacy skill says how to write around a gist and how to pick and pass on model tokens. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
178 lines
9.2 KiB
JavaScript
178 lines
9.2 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', ai_note: 'modelSourceFieldAiNote' };
|
|
|
|
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;
|
|
}
|