Files
DemonGatanjieuandClaude Opus 5.5 103ee6a25d First-run setup on Home: check Civitai, optional key, scan everything, then the tour
New users did not know to scan first. Until anything is scanned, Home shows a card with four
steps and "Set up for me": GET /anomalous/civitai_check (api/network_check.py, local requests
only) says whether Civitai answers and whether a set key is refused; when it does not, the card
offers Retry (with a proxy) or an offline scan. The scan never renames files or refetches,
whatever the scan page was set to; the card follows its progress. Never a popup; "Later" hides it
per browser and Home's "New here?" line brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:13 +08:00

278 lines
11 KiB
JavaScript

/**
* The first-run setup on Home (开始使用): while no model has been scanned yet, a card above the
* task cards with four steps and "Set up for me": check that Civitai answers (GET
* /anomalous/civitai_check; a proxy is often needed, else scan offline), the optional Civitai
* key (ui_scan_settings.js), a scan of every model (scan_runner.js; the card shows how far it
* is) and the tour. Never a popup: it shows on Home only, "Later" hides it in this browser, and
* Home's "New here?" line brings it back. Its state lives here, so Home drawn again (another
* visit, the language) keeps where it was.
*/
import { translate as t } from './locales.js';
import { isScanRunning, startScan } from './scan_runner.js';
import { resultLine } from './scan_results.js';
import { saveApiKey } from './ui_scan_settings.js';
import { startSpotlightTour } from './ui_spotlight_tour.js';
const HIDDEN_KEY = 'anomalous_first_run_hidden';
const POLL_MS = 2000;
const state = {
net: 'idle', // idle | checking | ok | fail
netError: '',
netMs: 0,
keyRefused: false,
hasKey: false,
scan: 'idle', // idle | running | done | failed
progress: null, // { current, total } while scanning
result: null,
counts: null, // { total, waiting }
shown: false, // this page session: keep showing it (a step was taken, or it was asked for)
};
let card = null; // the drawn card; replaced on each draw
let owner = null;
function readHidden() {
try {
return localStorage.getItem(HIDDEN_KEY) === '1';
} catch {
return false;
}
}
function writeHidden(hidden) {
try {
if (hidden) localStorage.setItem(HIDDEN_KEY, '1');
else localStorage.removeItem(HIDDEN_KEY);
} catch { /* this session only */ }
}
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
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;
}
/** Whether the card belongs on Home: nothing scanned yet (one walk of the folders, only then). */
async function wanted() {
if (state.shown) return true;
if (readHidden()) return false;
const last = await getJson('/anomalous/last_scan').catch(() => ({}));
if (last && Object.keys(last).length) return false;
const [summary, config] = await Promise.all([getJson('/anomalous/scan_summary'), getJson('/anomalous/config').catch(() => ({}))]);
if ((summary.matched || 0) + (summary.unmatched || 0) > 0) return false; // scanned with an older version
state.counts = { total: summary.total || 0, waiting: summary.new || 0 };
state.hasKey = Boolean(config.has_api_key);
return true;
}
async function refreshCounts() {
try {
const summary = await getJson('/anomalous/scan_summary');
state.counts = { total: summary.total || 0, waiting: summary.new || 0 };
} catch { /* keep the last counts */ }
}
function step(icon, title, body, aside, extra = null) {
const row = el('div', 'anomalous-first-run-step');
const copy = el('div', 'anomalous-first-run-copy');
copy.append(el('span', 'anomalous-first-run-step-title', title), el('span', 'anomalous-first-run-step-body', body));
if (extra) copy.append(extra);
row.append(el('span', 'anomalous-first-run-icon', icon), copy, aside);
return row;
}
const pill = (text, tone = '') => el('span', `anomalous-first-run-pill${tone ? ` is-${tone}` : ''}`, text);
const small = (label, onClick) => button('anomalous-first-run-btn', label, onClick);
function actions(...nodes) {
const box = el('div', 'anomalous-first-run-aside');
box.append(...nodes);
return box;
}
function netStep() {
const body = state.net === 'fail' ? t('firstRunNetFail', { error: state.netError || '—' }) : t('firstRunNetBody');
const aside = state.net === 'checking' ? actions(pill(t('firstRunNetChecking')))
: state.net === 'ok' ? actions(pill(t('firstRunNetOk', { ms: state.netMs }), 'ok'))
: state.net === 'fail' ? actions(pill(t('firstRunNetDown'), 'bad'), small(t('firstRunRetry'), () => void checkNet()),
small(t('firstRunScanOffline'), () => void runScan(true)))
: actions(small(t('firstRunCheck'), () => void checkNet()));
return step('🌐', t('firstRunNetTitle'), body, aside);
}
function keyStep() {
const body = state.keyRefused ? t('firstRunKeyRefused') : t('firstRunKeyBody');
const label = state.hasKey ? t('firstRunKeyChange') : t('firstRunKeySet');
const aside = actions(...(state.hasKey && !state.keyRefused ? [pill(t('firstRunKeyHas'), 'ok')] : []), small(label, () => void setKey()));
return step('🔑', t('firstRunKeyTitle'), body, aside);
}
function scanStep() {
const total = state.counts?.total || 0;
if (state.scan === 'idle' && !total) return step('📦', t('firstRunScanTitle'), t('firstRunScanNone'), actions());
if (state.scan === 'running') {
const bar = el('div', 'anomalous-first-run-track');
bar.append(el('div', 'anomalous-first-run-fill'));
const line = el('span', 'anomalous-first-run-count');
const row = step('📦', t('firstRunScanTitle'), t('firstRunScanRunning'), actions(line), bar);
row.classList.add('is-current');
paintProgress(row);
return row;
}
if (state.scan === 'done') {
return step('📦', t('firstRunScanTitle'), resultLine(state.result) || t('firstRunScanDoneBody'), actions(pill(t('firstRunScanDone'), 'ok')));
}
const waiting = state.counts?.waiting || 0;
const body = state.scan === 'failed' ? t('firstRunScanFailed') : waiting ? t('firstRunScanBody', { count: waiting }) : t('firstRunScanNothingNew');
return step('📦', t('firstRunScanTitle'), body, actions(small(t(state.scan === 'failed' ? 'firstRunRetry' : 'firstRunScanStart'), () => void runScan(false))));
}
/** The running scan's bar and count, in place (no redraw). */
function paintProgress(row = card?.querySelector('.anomalous-first-run-step.is-current')) {
if (!row) return;
const { current = 0, total = 0 } = state.progress || {};
const fill = row.querySelector('.anomalous-first-run-fill');
const track = row.querySelector('.anomalous-first-run-track');
track?.classList.toggle('is-waiting', !total);
if (fill) fill.style.width = total ? `${Math.min(100, Math.round((current / total) * 100))}%` : '';
const count = row.querySelector('.anomalous-first-run-count');
if (count) count.textContent = total ? `${Math.min(current, total)} / ${total}` : t('firstRunScanPreparing');
}
function tourStep() {
return step('🧭', t('firstRunTourTitle'), t('firstRunTourBody'), actions(small(t('firstRunTourStart'), () => startSpotlightTour(owner))));
}
function draw() {
if (!card?.isConnected) return;
const fresh = el('section', 'anomalous-first-run');
fresh.setAttribute('aria-label', t('firstRunTitle'));
const head = el('div', 'anomalous-first-run-head');
const copy = el('div', 'anomalous-first-run-headcopy');
copy.append(el('h2', 'anomalous-first-run-title', t('firstRunTitle')), el('p', 'anomalous-first-run-lead', t('firstRunLead')));
head.append(copy, button('anomalous-first-run-later', t(state.scan === 'done' ? 'firstRunDone' : 'firstRunLater'), hide));
const steps = el('div', 'anomalous-first-run-steps');
steps.append(netStep(), keyStep(), scanStep(), tourStep());
fresh.append(head, steps);
if (state.scan !== 'done') {
const foot = el('div', 'anomalous-first-run-foot');
const go = button('anomalous-first-run-go', t('firstRunGo'), () => void setUp());
go.disabled = state.scan === 'running' || state.net === 'checking';
foot.append(go, el('span', 'anomalous-first-run-note', t('firstRunGoNote')));
fresh.append(foot);
}
card.replaceWith(fresh);
card = fresh;
}
function hide() {
writeHidden(true);
state.shown = false;
card?.remove();
card = null;
}
async function checkNet() {
state.shown = true;
state.net = 'checking';
draw();
try {
const answer = await getJson('/anomalous/civitai_check');
state.net = answer.reachable ? 'ok' : 'fail';
state.netError = answer.error || '';
state.netMs = answer.ms || 0;
state.keyRefused = answer.key === 'refused';
} catch (error) {
state.net = 'fail';
state.netError = String(error.message || error);
}
draw();
}
async function setKey() {
state.shown = true;
if (!await saveApiKey()) return;
state.hasKey = Boolean((await getJson('/anomalous/config').catch(() => ({}))).has_api_key);
state.keyRefused = false;
draw();
}
async function followProgress() {
while (state.scan === 'running') {
try {
const status = await getJson('/anomalous/global_scan_status');
state.progress = { current: Number(status.current) || 0, total: Number(status.total) || 0 };
paintProgress();
} catch { /* the next poll tries again */ }
await new Promise(resolve => setTimeout(resolve, POLL_MS));
}
}
/** Scans every model (offline: from the files only); the card follows it. */
async function runScan(offline) {
if (state.scan === 'running' || isScanRunning()) return;
state.shown = true;
state.scan = 'running';
state.progress = null;
draw();
void followProgress();
// Never renames files or fetches everything again, whatever the scan page was set to last.
const result = await startScan(owner, { offline, virtualRename: true, physicalRename: false, forceOverwrite: false, autoFix: true });
state.scan = result ? 'done' : 'failed';
state.result = result;
if (!result) await refreshCounts();
draw();
}
/** "Set up for me": the network, then the scan, then the tour; stops where it needs a choice. */
async function setUp() {
if (state.net !== 'ok') await checkNet();
if (state.net !== 'ok') return; // the step says what to do: retry with a proxy, or scan offline
await runScan(false);
if (state.scan === 'done' && owner?.modal?.classList.contains('visible')) startSpotlightTour(owner);
}
/** Puts the card before `before` on Home's `page` when it is wanted. */
export async function renderFirstRun(browser, page, before) {
owner = browser;
let show = false;
try {
show = await wanted();
} catch (error) {
console.warn('[AMB] First-run setup: could not read the scan state.', error);
}
if (!show || !page.isConnected || !before.isConnected) return;
card = el('section', 'anomalous-first-run');
page.insertBefore(card, before);
draw();
}
/** Home's "New here?" link: the card again, even when hidden or after a scan. */
export async function showFirstRun(browser, page, before) {
writeHidden(false);
state.shown = true;
if (!state.counts) await refreshCounts();
if (!state.hasKey) state.hasKey = Boolean((await getJson('/anomalous/config').catch(() => ({}))).has_api_key);
if (card?.isConnected) {
card.scrollIntoView({ block: 'start', behavior: 'smooth' });
return;
}
await renderFirstRun(browser, page, before);
card?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}