466 lines
21 KiB
JavaScript
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);
|
|
}
|