Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_settings_page.js
T
2026-10-10 12:48:00 +08:00

466 lines
21 KiB
JavaScript

/**
* The settings page (the rail's gear), in tabs (`owner.settingsTab`, kept while the page is
* away): Look (how the browser looks, model cards and what they cost in memory with the card
* image cache, how the browser opens), Models (model folders — a view of its own,
* ui_folder_manager.js, `owner.settingsView === 'folders'` — and where downloads and imported
* models go), Workflows, Prompts (translation with a DeepL key), Backup (ui_backup.js) and
* Help. A tool page like the scan page, with a way back to the page you came from. Display
* preferences go through `owner.displayPrefs` (ui_settings_hub.js); language, theme and opening
* mode are ComfyUI settings.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { LANGUAGE_SETTING_ID, applyLanguagePreference, setThemePreference, themePreference } from './interface_settings.js';
import { ENTRY_MODE_SETTING_ID } from './browser_entry.js';
import { showUpdateGuide } from './ui_update_guide.js';
import { startSpotlightTour } from './ui_spotlight_tour.js';
import { copyDiagnostics, openRepoPage } from './feedback.js';
import { renderFolderPage } from './ui_folder_manager.js';
import { openFeedbackDialog } from './ui_feedback_dialog.js';
import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js';
import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model_download.js';
import { saveApiKey } from './ui_scan_page.js';
import { exportBackup, importBackup } from './ui_backup.js';
import { followsSelection, setFollowsSelection } from './shell_open_rules.js';
import { FOLDER_PRESETS, expandFolder } from './download_places.js';
const t = (key, params) => translate(key, params);
// The full written guide: the README, at its Chinese half for Chinese.
const README_URL = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser#readme';
const README_ZH_URL = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser#-视频演示与教程';
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;
}
function comfySetting(id) {
try { return app.extensionManager?.setting?.get(id); } catch (_) { return undefined; }
}
async function setComfySetting(id, value) {
const settings = app.extensionManager?.setting;
if (typeof settings?.set !== 'function') return false;
try {
await settings.set(id, value);
return true;
} catch (_) {
return false;
}
}
/** A row: name and what it does on the left, its control on the right. */
function row(titleKey, helpKey, control, { help = null } = {}) {
const line = el('div', 'anomalous-scan-setting');
const copy = el('span', 'anomalous-scan-setting-copy');
copy.append(el('span', 'anomalous-scan-setting-title', t(titleKey)));
const helpText = help ?? (helpKey ? t(helpKey) : '');
if (helpText) copy.append(el('small', 'anomalous-scan-setting-help', helpText));
line.append(copy, control);
return line;
}
/** Choices side by side; `onPick(value)` when one is clicked. */
function segment(choices, current, onPick) {
const box = el('div', 'anomalous-scan-segment');
box.setAttribute('role', 'radiogroup');
for (const [value, labelKey] of choices) {
const choice = button('anomalous-scan-segment-btn', t(labelKey), () => onPick(value));
choice.setAttribute('role', 'radio');
choice.setAttribute('aria-checked', String(value === current));
box.append(choice);
}
return box;
}
function toggleSwitch(checked, onChange) {
const input = el('input', 'anomalous-scan-switch');
input.type = 'checkbox';
input.checked = checked;
input.onchange = () => onChange(input.checked);
return input;
}
/** A slider with its value shown as a percentage. */
function slider(value, { min, max, step }, onCommit) {
const box = el('label', 'anomalous-settings-slider');
const input = el('input');
input.type = 'range';
Object.assign(input, { min: String(min), max: String(max), step: String(step), value: String(value) });
const shown = el('span', 'anomalous-settings-slider-value', `${Math.round(value * 100)}%`);
input.oninput = () => { shown.textContent = `${Math.round(Number(input.value) * 100)}%`; onCommit(Number(input.value)); };
box.append(input, shown);
return box;
}
function group(titleKey, ...rows) {
const box = el('section', 'anomalous-scan-card anomalous-scan-group');
box.append(el('h3', 'anomalous-scan-group-title', t(titleKey)), ...rows);
return box;
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
/** The card image cache: how much it holds, and a way to empty it. */
function cacheRow(owner) {
const clear = button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsCacheClear'), async () => {
clear.disabled = true;
try {
await fetch('/anomalous/card_cache/clear', { method: 'POST' });
} finally {
clear.disabled = false;
refresh();
}
});
const line = row('settingsCacheTitle', null, clear, { help: t('settingsCacheLoading') });
const help = line.querySelector('.anomalous-scan-setting-help');
const refresh = async () => {
try {
const data = await (await fetch('/anomalous/card_cache')).json();
help.textContent = t('settingsCacheHelp', { count: data.count, size: formatBytes(data.bytes), limit: formatBytes(data.limit) });
clear.disabled = !data.count;
} catch (_) {
help.textContent = t('settingsCacheUnknown');
}
};
refresh();
return line;
}
function appearanceGroup(owner, redraw) {
const prefs = owner.displayPrefs.get();
const language = localStorage.getItem('anomalous_lang') || 'auto';
const rows = [
row('sidebarViewMode', 'settingsViewModeHelp', segment(
[['compact', 'sidebarViewModeCompact'], ['standard', 'sidebarViewModeStandard'], ['aesthetic', 'sidebarViewModeAesthetic']],
prefs.viewMode, mode => { owner.displayPrefs.setViewMode(mode); redraw(); })),
];
if (prefs.viewMode === 'aesthetic') {
rows.push(row('sidebarBgAtmosphere', 'settingsAtmosphereHelp',
slider(prefs.bgOpacity, { min: 0, max: 1, step: 0.05 }, value => owner.displayPrefs.setBgOpacity(value))));
}
rows.push(
row('sidebarUiScale', 'settingsScaleHelp', slider(prefs.scale, { min: 0.5, max: 1.5, step: 0.1 }, value => owner.displayPrefs.setScale(value))),
row('settingsTheme', 'settingsThemeHelp', segment(
[['auto', 'settingsThemeAuto'], ['dark', 'settingsThemeDark'], ['light', 'settingsThemeLight'], ['abyssal', 'settingsThemeAbyssal']],
themePreference(), value => { setThemePreference(value, true); redraw(); })),
row('mainLanguageSetting', null, segment([['auto', 'mainLanguageAuto'], ['zh', 'mainLanguageChinese'], ['en', 'mainLanguageEnglish']],
language, async value => {
// The ComfyUI setting's own change handler switches the language; without it, switch here.
if (!await setComfySetting(LANGUAGE_SETTING_ID, value)) applyLanguagePreference(value);
})),
);
return group('settingsAppearance', ...rows);
}
function cardsGroup(owner, redraw) {
const reload = () => { owner.loadModels(); redraw(); };
return group('settingsCards',
row('sidebarCardQuality', 'settingsCardQualityHelp', segment(
[['balanced', 'sidebarOptimizedThumbnail'], ['original', 'sidebarOriginalCover']], owner.cardThumbnailMode, value => {
owner.cardThumbnailMode = value === 'original' ? 'original' : 'balanced';
localStorage.setItem('anomalous_card_thumbnail_mode', owner.cardThumbnailMode);
reload();
})),
row('sidebarVideoPlayback', 'settingsVideoHelp', segment(
[['hover', 'sidebarHoverPlay'], ['always', 'sidebarAlwaysPlay']], owner.energySaving ? 'hover' : 'always', value => {
owner.energySaving = value === 'hover';
localStorage.setItem('anomalous_energy_saving', String(owner.energySaving));
reload();
})),
cacheRow(owner),
);
}
function foldersGroup(owner) {
return group('settingsFolders',
row('sidebarManageFolders', 'settingsFoldersHelp',
button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsOpen'), () => {
owner.settingsView = 'folders';
render(owner);
})));
}
function workflowGroup(owner) {
return group('settingsWorkflows',
row('settingsCheckOnOpen', 'settingsCheckOnOpenHelp', toggleSwitch(checkOnOpen(), on => setCheckOnOpen(owner, on))),
row('sidebarProvenance', 'sidebarProvenanceDesc', toggleSwitch(localStorage.getItem('anomalous_inject_hash') !== 'false', on => {
// Read by hash_resolver.js when a workflow is saved.
localStorage.setItem('anomalous_inject_hash', on ? 'true' : 'false');
})));
}
/** The download folder: a ready-made choice, or one typed by hand; an example path below. */
function folderChoice(current) {
const box = el('div', 'anomalous-download-folder-choice');
const select = el('select', 'anomalous-download-root');
FOLDER_PRESETS.forEach((value, index) => {
const option = el('option', '', t(`settingsDownloadFolderPreset${index}`));
option.value = value;
select.append(option);
});
const custom = el('option', '', t('settingsDownloadFolderCustom'));
custom.value = '*'; // never a folder name
select.append(custom);
const input = el('input', 'anomalous-download-folder');
input.type = 'text';
input.spellcheck = false;
input.placeholder = t('settingsDownloadFolderRoot');
const example = el('small', 'anomalous-download-folder-example');
const show = (folder) => {
const preset = FOLDER_PRESETS.includes(folder);
select.value = preset && !input.dataset.typing ? folder : custom.value;
input.hidden = select.value !== custom.value;
input.value = folder;
const sub = expandFolder(folder, 'SDXL 1.0', []);
example.textContent = t('settingsDownloadFolderExample', { path: `loras/${sub ? `${sub}/` : ''}model.safetensors` });
};
const save = (folder) => saveDownloadSettings({ folder })
.then(saved => { input.classList.remove('is-bad'); show(saved.folder); })
.catch(() => input.classList.add('is-bad'));
select.onchange = () => {
if (select.value === custom.value) {
input.dataset.typing = '1';
input.hidden = false;
input.focus();
} else {
delete input.dataset.typing;
save(select.value);
}
};
input.onchange = () => save(input.value);
show(current);
box.append(select, input, example);
return box;
}
/** Where downloads and imported models go (model_download.js); filled once the settings are read. */
function downloadGroup(owner, redraw) {
const box = group('settingsDownloads');
fetchDownloadSettings().then(settings => {
box.append(
row('settingsDownloadPlace', 'settingsDownloadPlaceHelp', segment(
[['workflow', 'settingsDownloadPlaceWorkflow'], ['folder', 'settingsDownloadPlaceFolder']], settings.place,
value => saveDownloadSettings({ place: value }).then(redraw).catch(() => {}))),
row('settingsDownloadFolder', 'settingsDownloadFolderHelp', folderChoice(settings.folder)),
row('settingsHfMirror', 'settingsHfMirrorHelp', toggleSwitch(hfMirrorOn(settings),
on => saveDownloadSettings({ hf_mirror: on }).catch(() => redraw()))),
row('settingsDownloadKey', 'settingsDownloadKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
t('settingsDownloadKeySet'), () => saveApiKey())),
);
}).catch(error => console.warn('[AMB] Settings: download settings unavailable.', error));
return box;
}
/** Why DeepL did not translate (translation_routes.py `deepl_error`), in the user's words. */
function deeplReason(error) {
const known = { bad_key: 'settingsDeeplBadKey', quota: 'settingsDeeplQuota', busy: 'settingsDeeplBusy' };
return known[error] ? t(known[error]) : String(error || '?');
}
/**
* Asks for the DeepL key and saves it, then translates one sentence to show whether it works.
* Resolves to the saved key ("" when removed), or null when nothing was saved.
*/
async function saveDeeplKey() {
const value = prompt(t('settingsDeeplKeyPrompt'), '');
if (value === null) return null;
const key = value.trim();
try {
const res = await fetch('/anomalous/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deepl_key: key }),
});
const data = await res.json();
if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`);
} catch (error) {
alert(t('settingsDeeplKeySaveFailed', { error: String(error.message || error) }));
return null;
}
if (!key) {
alert(t('settingsDeeplKeyCleared'));
return key;
}
try {
const res = await fetch('/anomalous/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'A girl reading under a cherry tree.', target_lang: 'zh-CN' }),
});
const data = await res.json();
alert(data.engine === 'deepl'
? t('settingsDeeplKeyWorks', { text: data.translated })
: t('settingsDeeplKeyFailed', { why: deeplReason(data.deepl_error || data.error) }));
} catch (error) {
alert(t('settingsDeeplKeyFailed', { why: String(error.message || error) }));
}
return key;
}
/** Prompt translation: DeepL with the user's own key, else the free services (translation_routes.py). */
function translationGroup() {
const box = group('settingsTranslation');
fetch('/anomalous/config').then(res => res.json()).then(config => {
const tag = el('span');
const show = (hasKey) => {
tag.className = `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`;
tag.textContent = t(hasKey ? 'settingsDeeplKeyOn' : 'settingsDeeplKeyOff');
};
show(Boolean(config.has_deepl_key));
// Only the tag changes: redrawing the page would lose its scroll place.
const line = row('settingsDeeplKey', 'settingsDeeplKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
t('settingsDownloadKeySet'), async () => {
const key = await saveDeeplKey();
if (key !== null) show(Boolean(key));
}));
line.querySelector('.anomalous-scan-setting-title').append(tag);
box.append(line);
}).catch(error => console.warn('[AMB] Settings: translation settings unavailable.', error));
return box;
}
/** One .zip of the user's data to keep anywhere, and putting one back (ui_backup.js). */
function backupGroup(owner) {
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
return group('settingsBackup',
row('settingsBackupExport', 'settingsBackupExportHelp', small(t('settingsBackupExportButton'), () => exportBackup())),
row('settingsBackupImport', 'settingsBackupImportHelp', small(t('settingsBackupImportButton'), () => importBackup(owner))));
}
function openingGroup(owner, redraw) {
const mode = comfySetting(ENTRY_MODE_SETTING_ID) || 'floating';
return group('settingsOpening',
row('mainEntryModeSetting', 'settingsEntryHelp', segment(
[['floating', 'mainEntryModeFloating'], ['topbar', 'mainEntryModeTopbar'], ['menu', 'mainEntryModeMenu']], mode, async value => {
await setComfySetting(ENTRY_MODE_SETTING_ID, value);
redraw();
})),
row('settingsFollowNode', 'settingsFollowNodeHelp', toggleSwitch(followsSelection(), setFollowsSelection)),
row('settingsLayout', 'settingsLayoutHelp', button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsReset'), () => {
if (!confirm(t('sidebarResetConfirm'))) return;
owner.displayPrefs.resetLayout();
redraw();
})),
);
}
function helpGroup(owner) {
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
return group('settingsHelp',
row('settingsTour', 'settingsTourHelp', small(t('settingsStart'), () => startSpotlightTour(owner))),
row('updateGuideReplay', 'settingsNewsHelp', small(t('settingsOpen'), () => showUpdateGuide(owner, { force: true }))),
row('settingsGuide', 'settingsGuideHelp', small(t('settingsOpenGuide'), () => {
window.open(window.anomalous_browser_lang === 'zh' ? README_ZH_URL : README_URL, '_blank', 'noopener');
})),
row('feedbackReport', 'settingsReportHelp', small(t('feedbackWrite'), () => openFeedbackDialog(owner, 'bug'))),
row('feedbackSuggest', 'settingsSuggestHelp', small(t('feedbackWrite'), () => openFeedbackDialog(owner, 'idea'))),
row('feedbackCopy', 'feedbackCopyHint', (() => {
const copy = small(t('feedbackCopy'), async () => {
copy.textContent = t(await copyDiagnostics(owner) ? 'feedbackCopied' : 'feedbackCopyFailed');
setTimeout(() => { copy.textContent = t('feedbackCopy'); }, 1600);
});
return copy;
})()),
row('settingsStar', 'settingsStarHelp', small(t('settingsStarOpen'), openRepoPage)),
);
}
// Tab -> its title and its cards, in order.
const TABS = [
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), openingGroup(owner, redraw)]],
['models', 'settingsTabModels', (owner, redraw) => [foldersGroup(owner), downloadGroup(owner, redraw)]],
['workflows', 'settingsTabWorkflows', (owner) => [workflowGroup(owner)]],
['prompts', 'settingsTabPrompts', () => [translationGroup()]],
['backup', 'settingsTabBackup', (owner) => [backupGroup(owner)]],
['help', 'settingsTabHelp', (owner) => [helpGroup(owner)]],
];
function tabBar(owner, current) {
const bar = el('div', 'anomalous-settings-tabs');
bar.setAttribute('role', 'tablist');
for (const [id, labelKey] of TABS) {
const tab = button('anomalous-settings-tab', t(labelKey), () => {
if (owner.settingsTab === id) return;
owner.settingsTab = id;
render(owner);
});
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', String(id === current));
bar.append(tab);
}
return bar;
}
function render(owner) {
const panel = owner.settingsPanel;
const tab = TABS.find(([id]) => id === owner.settingsTab) || TABS[0];
owner.settingsTab = tab[0];
const view = `${owner.settingsView || ''}|${tab[0]}`;
const top = panel._settingsView === view ? panel.scrollTop : 0;
panel._settingsView = view;
const redraw = () => render(owner);
const page = el('div', 'anomalous-scan-page');
if (owner.settingsView === 'folders') {
panel.replaceChildren(page);
panel.scrollTop = 0;
renderFolderPage(owner, page, () => {
owner.settingsView = '';
render(owner);
});
return;
}
page.append(
button('anomalous-scan-back', t('settingsBack'), () => leaveSettingsPage(owner)),
el('h1', 'anomalous-scan-title', t('sidebarSettings')),
tabBar(owner, tab[0]),
...tab[2](owner, redraw),
);
panel.replaceChildren(page);
panel.scrollTop = top;
}
export function isSettingsPageOpen(owner) {
return Boolean(owner.settingsPanel && owner.settingsPanel.style.display !== 'none');
}
/** Opens the page (at `tab`, else the tab seen last); `keepReturn`: drawn again in place
* (language change), Back still goes where it went. */
export function openSettingsPage(owner, { keepReturn = false, tab = '' } = {}) {
if (!keepReturn && !isSettingsPageOpen(owner)) {
owner.settingsReturn = owner.currentShellPage?.() || 'home';
owner.settingsView = '';
}
if (tab) {
owner.settingsTab = tab;
owner.settingsView = '';
}
owner.enterToolPage?.('settings');
owner.hideAllPanels();
owner.settingsPanel.style.display = 'flex';
document.getElementById('anomalous-global-settings-btn')?.classList.add('is-active');
render(owner);
}
/** Back to the page the gear was pressed on. */
export function leaveSettingsPage(owner) {
const back = owner.settingsReturn || 'home';
owner.settingsReturn = null;
if (back === 'scan') owner.openScanPage();
else if (back === 'doctor') owner.openDoctorPage();
else owner.goTo(['assistant', 'settings'].includes(back) ? 'home' : back);
}