Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_doctor.js
T
DemonGatanjieuandClaude Opus 5.5 0b13f3b431 Mark fingerprint matches in Model Check as sure
Every match waits for a press, so the fingerprint ones get the top of the
same scale: ●●● sure (green), and ●●○ likely moves to the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:56:39 +08:00

247 lines
12 KiB
JavaScript

/**
* Model Check, the tool page: the models the open workflow uses and what is wrong with each
* (model_check.js), with what can be done about it: put in the same file found under
* another name or folder, take or leave a file of the same name or size, pick one by hand (the
* node model picker) or look it up on Civitai. Nothing in the workflow changes without a
* press here. Uses the scan page's layout classes (18-scan-page.css).
*/
import { translate as t } from './locales.js';
import { applyModelFix, checkWorkflowModels, fixWorkflowModels, isProblem, markReplaced, replacedFrom } from './model_check.js';
import { updateDoctorBanner } from './ui_doctor_banner.js';
const MODEL_EXT = /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i;
const CANDIDATE_WHY = { size: 'doctorWhyCandidate', name: 'doctorWhySameName', 'name-size': 'doctorWhyLikely' };
// How sure a match is, next to the name: each one waits for a press, so each says how sure.
const LEVELS = {
sure: ['●●●', 'doctorLevelSure'], likely: ['●●○', 'doctorLevelLikely'], maybe: ['●○○', 'doctorLevelMaybe'],
ambiguous: ['○○○', 'doctorLevelUnclear'], conflict: ['○○○', 'doctorLevelConflict'], missing: ['○○○', 'doctorLevelNone'],
};
const ORDER = ['fixable', 'candidate', 'conflict', 'ambiguous', 'missing', 'changed'];
const MARKS = { fixable: '↻', candidate: '?', conflict: '✕', ambiguous: '?', missing: '✕', changed: '!', ready: '✓' };
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
const fileName = (value) => String(value).split(/[\\/]/).pop();
const nodeLabel = (entry) => `#${entry.node.id} ${entry.node.title || entry.node.type}`;
function stat(value, label, tone) {
const box = el('div', `anomalous-scan-stat${tone ? ` is-${tone}` : ''}`);
box.append(el('strong', 'anomalous-scan-stat-value', String(value)), el('span', 'anomalous-scan-stat-label', label));
return box;
}
function reason(entry) {
switch (entry.state) {
case 'fixable': return t(entry.via === 'spelling' ? 'doctorWhySpelling' : 'doctorWhyFound', { file: entry.target });
case 'candidate': return t(CANDIDATE_WHY[entry.via], { file: entry.target });
case 'conflict': return t('doctorWhyConflict');
case 'ambiguous': return t('doctorWhyAmbiguous');
case 'changed': return t('doctorWhyChanged');
default: return t(entry.record ? 'doctorWhyNotHere' : 'doctorWhyNoRecord');
}
}
function levelOf(entry) {
if (entry.state === 'fixable') return 'sure';
if (entry.state === 'candidate') return entry.via === 'name-size' ? 'likely' : 'maybe';
return Object.hasOwn(LEVELS, entry.state) ? entry.state : '';
}
function title(entry) {
const line = el('span', 'anomalous-doctor-title');
line.append(el('span', 'anomalous-scan-row-name', fileName(entry.value)));
const level = levelOf(entry);
if (level) {
const [dots, key] = LEVELS[level];
line.append(el('span', `anomalous-doctor-level is-${level}`, `${dots} ${t(key)}`));
}
return line;
}
/** The hashes behind the verdict, folded. */
function proof(entry) {
const line = (label, record) => {
const parts = [record?.hash, record?.size && `${Number(record.size).toLocaleString()} B`].filter(Boolean);
return el('div', '', `${label}: ${parts.join(' · ') || t('doctorProofNone')}`);
};
const details = el('details', 'anomalous-doctor-proof');
details.append(el('summary', '', t('doctorProof')), line(t('doctorProofSaved'), entry.record), line(t('doctorProofLocal'), entry.local));
return details;
}
/** The model's Civitai page by the workflow's hash, else a search by its file name. */
async function openOnCivitai(entry) {
const tab = window.open('', '_blank'); // opened now: a window opened after the lookup is blocked
let url = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(fileName(entry.value).replace(MODEL_EXT, ''))}`;
if (entry.record?.hash) {
try {
const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(entry.record.hash)}`);
const data = res.ok ? await res.json() : null;
if (data?.modelId) {
const domain = data.model?.nsfw || (data.nsfwLevel || 1) > 1 ? 'civitai.red' : 'civitai.com';
url = `https://${domain}/models/${data.modelId}${data.id ? `?modelVersionId=${data.id}` : ''}`;
}
} catch (error) {
console.warn('[AMB] Doctor: Civitai lookup by hash failed, searching by name.', error);
}
}
if (tab) tab.location.href = url;
else window.open(url, '_blank');
}
function pickByHand(owner, panel, entry) {
const { node, widget } = entry;
const from = widget.value;
owner._openGalleryReplacer(node, widget, {
onApplied: () => {
markReplaced(widget, from, widget.value);
renderDoctorPage(owner, panel);
},
});
}
function problemRow(owner, panel, entry) {
const row = el('div', `anomalous-scan-row anomalous-doctor-row is-${entry.state}`);
const copy = el('div', 'anomalous-scan-row-copy');
copy.append(
title(entry),
el('span', 'anomalous-scan-row-where', nodeLabel(entry)),
el('span', 'anomalous-doctor-why', reason(entry)),
);
if (entry.record || entry.local) copy.append(proof(entry));
const actions = el('div', 'anomalous-scan-row-actions');
const put = (labelKey) => button('anomalous-scan-row-btn is-main', t(labelKey), () => {
if (!applyModelFix(entry)) alert(t('doctorChangedMeanwhile'));
renderDoctorPage(owner, panel);
});
if (entry.state === 'fixable') actions.append(put('doctorPutIn'));
if (entry.state === 'candidate') actions.append(put('doctorUseCandidate'));
if (entry.state !== 'fixable') {
actions.append(button('anomalous-scan-row-btn', t('doctorPick'), () => pickByHand(owner, panel, entry)));
}
if (!['fixable', 'candidate', 'changed'].includes(entry.state)) {
actions.append(button('anomalous-scan-row-btn', 'Civitai', () => openOnCivitai(entry)));
}
row.append(el('span', 'anomalous-scan-row-mark', MARKS[entry.state]), copy, actions);
return row;
}
function simpleRow(entry, detail, state = 'ready') {
const row = el('div', `anomalous-scan-row anomalous-doctor-row is-${state}`);
const copy = el('div', 'anomalous-scan-row-copy');
copy.append(el('span', 'anomalous-scan-row-name', fileName(entry.value)), el('span', 'anomalous-scan-row-where', nodeLabel(entry)));
if (detail) copy.append(el('span', 'anomalous-doctor-why', detail));
row.append(el('span', 'anomalous-scan-row-mark', MARKS.ready), copy);
return row;
}
function card(title, rows) {
const box = el('div', 'anomalous-scan-card');
const list = el('div', 'anomalous-scan-rows');
list.append(...rows);
box.append(el('div', 'anomalous-scan-card-title', title), list);
return box;
}
/** Models the workflow carries a hash for may be among the files no scan has hashed yet. */
async function scanHint(owner, entries) {
if (!entries.some(entry => entry.state === 'missing' && entry.record?.hash)) return null;
const summary = await fetch('/anomalous/scan_summary').then(res => (res.ok ? res.json() : null)).catch(() => null);
if (!summary?.new) return null;
const box = el('div', 'anomalous-scan-card');
box.append(
el('p', 'anomalous-scan-note is-warn', t('doctorScanHint', { count: summary.new })),
button('anomalous-scan-secondary anomalous-doctor-to-scan', t('doctorToScan'), () => owner.openScanPage()),
);
return box;
}
function head(page) {
page.append(el('h1', 'anomalous-scan-title', t('sidebarDoctor')), el('p', 'anomalous-scan-lead', t('doctorLead')));
}
/** Checks the open workflow and renders the page into `panel`; `refresh` reloads the model lists first. */
export async function renderDoctorPage(owner, panel, { refresh = false } = {}) {
const token = (panel._doctorRender = (panel._doctorRender || 0) + 1);
if (refresh || !panel.firstChild) {
const waiting = el('div', 'anomalous-scan-page');
head(waiting);
waiting.append(el('p', 'anomalous-scan-muted', t('doctorChecking')));
panel.replaceChildren(waiting);
}
const entries = await checkWorkflowModels({ refresh });
if (token !== panel._doctorRender) return;
updateDoctorBanner(owner, entries);
const hint = await scanHint(owner, entries);
if (token !== panel._doctorRender || panel.style.display === 'none') return;
const problems = entries.filter(entry => isProblem(entry) || entry.state === 'changed')
.sort((a, b) => ORDER.indexOf(a.state) - ORDER.indexOf(b.state) || (b.via === 'name-size') - (a.via === 'name-size'));
const fixable = entries.filter(entry => entry.state === 'fixable');
const here = entries.filter(entry => entry.state === 'ready' || entry.state === 'changed');
const replaced = here.filter(entry => replacedFrom(entry.widget));
const page = el('div', 'anomalous-scan-page');
head(page);
const stats = el('div', 'anomalous-scan-stats');
const missing = entries.filter(isProblem).length - fixable.length;
stats.append(
stat(entries.length, t('doctorStatUsed')),
stat(here.length, t('doctorStatHere'), 'ok'),
stat(fixable.length, t('doctorStatFixable'), fixable.length ? 'new' : ''),
stat(missing, t('doctorStatMissing'), missing ? 'bad' : ''),
);
page.append(stats);
const actions = el('div', 'anomalous-scan-actions');
if (fixable.length) {
actions.append(button('anomalous-scan-primary', t('doctorPutInAll', { count: fixable.length }), () => {
fixWorkflowModels(fixable);
renderDoctorPage(owner, panel);
}));
}
actions.append(button('anomalous-scan-secondary', t('doctorRecheck'), () => renderDoctorPage(owner, panel, { refresh: true })),
button('anomalous-scan-secondary', t('doctorSources'), () => owner.showModelSources('workflow')));
page.append(actions);
const verdict = !entries.length ? t('doctorNoModels')
: !entries.some(isProblem) ? t('doctorAllHere', { count: entries.length })
: t('doctorHowItWorks');
page.append(el('p', 'anomalous-scan-muted', verdict));
if (problems.length) page.append(card(t('doctorToHandle'), problems.map(entry => problemRow(owner, panel, entry))));
if (hint) page.append(hint);
if (replaced.length) {
page.append(card(t('doctorReplaced'), replaced.map(entry => simpleRow(entry, t('doctorWas', { file: replacedFrom(entry.widget) })))));
}
const ready = here.filter(entry => entry.state === 'ready' && !replacedFrom(entry.widget));
if (ready.length) {
const fold = el('details', 'anomalous-scan-advanced');
const list = el('div', 'anomalous-scan-rows');
list.append(...ready.map(entry => simpleRow(entry)));
fold.append(el('summary', '', t('doctorHereList', { count: ready.length })), list);
page.append(fold);
}
panel.replaceChildren(page);
}
/** The rail's doctor button, Home's card and the canvas banner's "Show". */
export function openDoctorPage(owner) {
owner.enterToolPage?.('doctor');
owner.hideAllPanels();
owner.doctorPanel.style.display = 'flex';
renderDoctorPage(owner, owner.doctorPanel, { refresh: true });
}