From 22edc20d0f326b72942fe3251739b9418fa8fd6e Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:27:24 +0800 Subject: [PATCH] Scan page: pick beside the button, settings in place, components apart "Pick models to scan" sits beside the scan button; the picks are named under it and the button scans them, "clear" goes back to everything, so the all/picked switch is gone. Under the button the page says what this scan will do, each line with how many of its models are components. The settings card (ui_scan_settings.js, holding the scan options and the Civitai key prompt) unfolds in place instead of opening its own page. The summary counts and lists models in component folders (VAE, text encoders, CLIP, CLIP Vision, upscalers) apart. The counts at the top and the not found / not scanned lists leave them out; a card and a list of their own say that many are not on Civitai and that this is no fault. The not found list looks up again just the models it shows. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 14 +- CHANGELOG.md | 8 +- api/scan_summary.py | 27 ++- docs/architecture/backend.md | 7 +- docs/architecture/frontend.md | 2 +- tests/test_scan_report.py | 25 ++- web/modules/locales.js | 78 +++++---- web/modules/ui_model_download.js | 2 +- web/modules/ui_scan_lists.js | 32 +++- web/modules/ui_scan_page.js | 288 +++++++++++-------------------- web/modules/ui_scan_settings.js | 123 +++++++++++++ web/modules/ui_settings_page.js | 2 +- web/styles.css | 4 +- web/styles/18-scan-page.css | 114 ++++++++++-- web/styles/30-scan-picker.css | 2 +- 15 files changed, 461 insertions(+), 267 deletions(-) create mode 100644 web/modules/ui_scan_settings.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 254c784..cfc50a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -100,6 +100,8 @@ covered by the `styles.css` manifest. (`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also lists the model files in other formats, which scans never read, so the page can say so, the models set to "don't scan", and the new ones whose header names no drawing model kind. + Models in component folders (VAE, text encoders, CLIP, CLIP Vision, upscalers) are counted + and listed apart as well (`components`, `component_models`): many are not on Civitai. It also lists every model a scan reads for the scan settings' picker (`/anomalous/scan_pickable`), with size, cover and the card badge's metadata fields. `api/scan_choice.py` saves that choice (`POST /anomalous/scan_choice`, this computer only) @@ -376,16 +378,18 @@ covered by the `styles.css` manifest. `ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return, including Esc on the workspace panel (`nbPanel`, below the header and right of the rail; the list column steps aside while it is open). - `ui_scan_page.js` is the scan page (a tool page like the doctor): counts, the scan - button, a card for how the next scan goes (opening the scan settings page), the last - scan's summary; each count and the summary open a + `ui_scan_page.js` is the scan page (a tool page like the doctor): counts without the + components, a card for the components, the scan button with "pick models to scan" beside it + and what the scan will do, the settings card (`ui_scan_settings.js`: the scan options, + folded into short words or unfolded in place), the last scan's summary; each count, the + components and the summary open a list page from `ui_scan_lists.js` (not scanned, unmatched, other formats, the last scan's models) with Back, a button for the whole list (scan these / look them up again) and rows that open a model, whose Back returns to the list, or scan it; `scan_results.js` holds the words for scan results, shared with the activity page. - `ui_scan_picker.js` is the scan settings' model picker: every model a scan reads, by folder, + `ui_scan_picker.js` is the scan page's model picker: every model a scan reads, by folder, state (not scanned, not found, damaged, found, don't scan) and search, as a list or covers; - the settings page keeps its picks as items and says what a scan of all models will do. + the page keeps its picks as items (none: the button scans everything). `scan_runner.js` starts and follows scans (every folder, picked or listed models, or one model from its card), shows the result and refreshes node drop-downs, hashes and the grid afterwards; folder visibility/order lives in diff --git a/CHANGELOG.md b/CHANGELOG.md index 72776b8..43f62e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,11 @@ - A model whose header looks like no drawing model (a voice model such as F5-TTS kept in checkpoints, say) is no longer just "not scanned" forever. The scan page asks about them: **都不扫**, **都照常扫**, or **逐个看**. Set to not be scanned, a model shows **不扫描** instead of 未扫描, a scan of everything skips it (picked by hand it is still scanned), and the scan page lists these models to change it back. A model's details have the same button. Nothing is decided for you: scanning still keeps a fingerprint that repairs workflows. - **Not on Civitai is no fault (Civitai 上没有不再像出错)**: the count of models Civitai did not find warns only when some file is damaged or a lookup is still to do; each such model says its fingerprint is kept for repairing workflows. -### 🎯 Scan settings say what will happen; a new model picker (扫描设置更具体,挑选模型重做) -- **All models** now says what the next scan does, in numbers: how many models there are, how many will be looked up on Civitai (not scanned yet, and those last scanned without a connection), how many are already found and keep their record, how many Civitai does not have and are not looked up again, and how many are set to never be scanned. Turning on offline or Fetch again changes the lines at once. -- **Only ones I pick** lists the picks by name, each with a ✕, with their count and total size, and says when some of them are already found (a scan only reads their record again unless Fetch again is on). +### 🎯 The scan page, rearranged: pick models beside the button, settings in place, components apart (扫描页重排) +- **Pick models to scan… sits beside the scan button.** The picks are named under it (each with a ✕, with their count and total size) and the button scans just them; **Clear, scan everything again** goes back. There is no "all / only ones I pick" switch any more. When some picks are already found, it says so: a scan only reads their record again unless Fetch again is on. +- **Under the button, what this scan will do**, in numbers: how many will be looked up on Civitai (not scanned yet, and those last scanned without a connection; how many of them are components), how many are already found and keep their record, how many Civitai does not have and are not looked up again, how many are set to never be scanned. Turning on offline or Fetch again changes the lines at once. +- **The scan settings unfold in place** instead of opening a page of their own: folded they stay one row of short words; unfolded, the switches and the Civitai key are right there. +- **Components on their own (基础组件)**: VAE, text encoders, CLIP, CLIP Vision and upscalers get a card of their own — found, not found, not scanned yet — saying that many of them are not on Civitai at all and that this does not matter (the fingerprint is still kept for Model Check). The counts above, and the Not found and Not scanned yet lists, leave them out; their own list shows all of them. - **The model picker is new**: every model a scan reads, as a list (name, folder, state, base model, size) or as covers. Chips by state, the same words as the scan page — **Not scanned yet, Not found, Damaged, Found, Never scan** — with counts; a search; the folders on the left with how many are picked in each, two folders of one type told apart by where they are. **Pick these N** picks everything shown (so "the 12 not scanned yet in LoRA" is two clicks), shift-click picks a run, **Picked only** shows the picks. The footer says how many are picked and how big they are. - Only files a scan can read (.safetensors) are listed; the old picker also offered .ckpt and other files a scan never opens. diff --git a/api/scan_summary.py b/api/scan_summary.py index 8bc5221..29f3a86 100644 --- a/api/scan_summary.py +++ b/api/scan_summary.py @@ -2,8 +2,10 @@ new, and which ones the last two are (located like the scan report's models, so the page can open them); also the model files a scan never reads (other formats than .safetensors), so the page can say why they are not there, the models the user set to "don't scan", and the new ones -whose header looks like no drawing model (a voice model, say), for the page to ask about. And -for the scan settings' picker, every model a scan reads with its name, size, cover and state.""" +whose header looks like no drawing model (a voice model, say), for the page to ask about. The +components a drawing model loads (VAE, text encoders…) are counted and listed on their own too, +since many are not on Civitai and that is no fault. And for the scan page's picker, every model +a scan reads with its name, size, cover and state.""" import asyncio import os @@ -26,6 +28,8 @@ MAX_LISTED = 2000 # models listed per kind; the counts stay exact MAX_PICKABLE = 5000 # models the picker lists; its total stays exact # Model files in other formats: listed as "not scanned" (scans read only .safetensors). OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine") +# Folders of the parts a drawing model loads: many are not on Civitai, and that is no fault. +COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models") # What the picker needs of a model's metadata: its card badge (model_source.js) and its name. PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name") @@ -57,9 +61,11 @@ def _summarize(): which the next online scan looks up. `skipped`: model files in other formats. `excluded`: models set to "don't scan", not in the other counts. A new model not chosen about whose header reads but names no drawing model kind carries `not_drawing` (counted too); only new - models' headers are read.""" + models' headers are read. Models in a component folder carry `component` (counted too) and + are listed again, matched ones as well, in `component_models` with their `state` + (matched | unmatched | new); `components` counts them.""" total = matched = 0 - unmatched, new, skipped, excluded = [], [], [], [] + unmatched, new, skipped, excluded, components = [], [], [], [], [] for located, base_dir, path, other in _walk(): item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path), "rel": os.path.relpath(path, base_dir).replace(os.sep, "/")} @@ -73,16 +79,21 @@ def _summarize(): continue total += 1 info = sidecar_info(base) + if located[0] in COMPONENT_TYPES: + item["component"] = True + components.append(item) if info is not None and not is_unmatched(info): matched += 1 + item["state"] = "matched" continue if info is None: found = inspect_file(path) if not choice and found["format"] == "safetensors" and not found["kind"]: item["not_drawing"] = True + item["state"] = "new" new.append(item) else: - item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "") + item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "") unmatched.append(item) return { "total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new), @@ -92,6 +103,12 @@ def _summarize(): "excluded": len(excluded), "excluded_models": excluded[:MAX_LISTED], "unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED], "skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED], + "components": { + "total": len(components), + **{state: sum(item["state"] == state for item in components) for state in ("matched", "unmatched", "new")}, + "pending": sum(item.get("reason") in ("offline", "network") for item in components), + }, + "component_models": components[:MAX_LISTED], } diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 6f4cd5a..78aa4c3 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -158,7 +158,12 @@ a voice model say) as `not_drawing`, and the scan page asks. `POST /anomalous/sc (`api/scan_choice.py`, this computer only) takes the picked models as scan `targets`, at most 2000, and writes only `.safetensors` files' layers that are readable. -The scan settings' picker reads `GET /anomalous/scan_pickable` (`api/scan_summary.py`): the +The summary also counts the models in component folders (`COMPONENT_TYPES`: VAE, text +encoders, CLIP, CLIP Vision, upscalers) as `components` and lists them, found ones too, in +`component_models` with their `state`; they stay in the other counts, and their list items carry +`component`, so the page can show them apart. Folder type decides, not the file's header. + +The scan page's picker reads `GET /anomalous/scan_pickable` (`api/scan_summary.py`): the same files the summary counts (the scan folders' `.safetensors`, each once), the "don't scan" ones included, up to 5000 with the exact `total`. Each carries its locator (`type`, `path_idx`, `rel`), size, cover URL and only the metadata fields its badge needs, not the diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 09997e1..76459a2 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -73,7 +73,7 @@ The main surfaces are: own the scan page, folder manager, and help dialog. - Grid and model detail: model browsing plus coordinated detail display in `ui_detail.js`, metadata editing in `ui_model_editor.js`; `model_widget_path.js` puts a - model path into a loader's widget. The scan settings' model picker is `ui_scan_picker.js`. + model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`. - Gallery: generated outputs and the full-screen Image Detail Studio Workbench. `ui_gallery_card.js` builds each output's card; its star and the workbench's Keep open `ui_keep_menu.js`, which keeps the image as a Workflow Recipe, a combo or its prompts diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py index 7a24c5c..428999a 100644 --- a/tests/test_scan_report.py +++ b/tests/test_scan_report.py @@ -155,10 +155,33 @@ class ScanReportTests(unittest.TestCase): mock.patch.object(scan_summary, "locate_root", return_value=("loras", 1)): summary = scan_summary._summarize() self.assertEqual((summary["total"], summary["matched"], summary["unmatched"], summary["new"], summary["pending"]), (4, 1, 2, 1, 1)) - self.assertEqual(summary["new_models"], [{"type": "loras", "path_idx": 1, "filename": "new.safetensors", "rel": "new.safetensors"}]) + self.assertEqual(summary["new_models"], [{"type": "loras", "path_idx": 1, "filename": "new.safetensors", "rel": "new.safetensors", "state": "new"}]) reasons = {item["filename"]: (item["reason"], item["base"]) for item in summary["unmatched_models"]} self.assertEqual(reasons, {"gone.safetensors": ("not_found", "SD 1.5"), "off.safetensors": ("offline", "")}) + def test_components_are_counted_and_listed_on_their_own(self): + loras, vae = self.dir / "loras", self.dir / "vae" + for folder in (loras, vae): + folder.mkdir() + top = self.dir + self.dir = loras + self.model("style", {"id": 3}) + self.dir = vae + self.model("found_vae", {"id": 4}) + self.model("odd_vae", {"id": -1, "anomalous_unmatched_reason": "not_found"}) + self.model("new_vae") + self.dir = top + roots = {str(loras): ("loras", 0), str(vae): ("vae", 0)} + with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[str(loras), str(vae)]), \ + mock.patch.object(scan_summary, "locate_root", side_effect=lambda path: roots[path]): + summary = scan_summary._summarize() + self.assertEqual((summary["total"], summary["matched"], summary["unmatched"], summary["new"]), (4, 2, 1, 1)) + self.assertEqual(summary["components"], {"total": 3, "matched": 1, "unmatched": 1, "new": 1, "pending": 0}) + states = {item["filename"]: (item["state"], item.get("reason", "")) for item in summary["component_models"]} + self.assertEqual(states, {"found_vae.safetensors": ("matched", ""), "odd_vae.safetensors": ("unmatched", "not_found"), + "new_vae.safetensors": ("new", "")}) + self.assertTrue(all(item.get("component") for item in summary["unmatched_models"] + summary["new_models"])) + def test_picker_lists_every_model_a_scan_reads_with_its_badge_fields(self): self.model("new") self.model("found", {**FOUND, "description": "

long text

"}) diff --git a/web/modules/locales.js b/web/modules/locales.js index a84f290..cca1ad7 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -231,12 +231,9 @@ export const i18n = { scanPageScanPicked: "扫描挑选的 {count} 个模型", scanPageScanning: "正在扫描…", scanPageRetry: "把 {count} 个没查到的再联网查一遍", - scanPageHint: "联网扫描先去 Civitai 查,查不到的从文件本身猜底模;连不上 Civitai 时自动改成只看文件,下次联网再补查。扫描在后台进行,可以关掉这个窗口继续用 ComfyUI。", - scanPageHintPicked: "只扫描在扫描设置里挑选的模型。", scanSettingsTitle: "扫描设置", scanSettingsChange: "更改 ›", scanSettingsLead: "下一次扫描按这里的设置进行。刷新页面后恢复默认;“修改文件名”和“重新获取”扫完一次就自动关掉。", - scanChipPicked: "只扫挑选的 {count} 个", scanChipOnline: "联网查 Civitai", scanChipDisplayName: "用 Civitai 名称显示", scanChipFileRename: "会改文件名", @@ -244,20 +241,30 @@ export const i18n = { scanChipAutoFix: "扫完修复工作流", scanChipKeyOn: "API Key 已设置", scanChipKeyOff: "没设 API Key", - scanPageScope: "扫描哪些模型", - scanPageScopeAll: "全部模型", - scanPageScopePicked: "只扫我挑的", scanPageScopeAllHelp: "所有模型文件夹,设置里“文件夹管理”隐藏的除外。", - scanPagePickModels: "挑选模型…", - scanScopeTotal: "共 {count} 个模型", - scanScopeLookup: "{count} 个这次会去 Civitai 查:还没扫过的,和上次没连上网的", - scanScopeInfer: "{count} 个还没扫过的只从文件推断类型(离线,不联网)", + scanPagePickModels: "挑选模型扫描…", + scanPickBackToAll: "清空,改回扫描全部", + scanPageBackground: "扫描在后台进行,可以关掉这个窗口继续用 ComfyUI。", + scanSettingsFold: "收起", + scanScopeWithParts: "(含 {count} 个基础组件)", + scanPartsTitle: "基础组件", + scanPartsKinds: "VAE、文本编码器、CLIP、CLIP Vision、放大模型", + scanPartsOpen: "查看 ›", + scanPartsTotal: "共 {count} 个", + scanPartsMatched: "Civitai 查到 {count}", + scanPartsUnmatched: "没查到 {count}", + scanPartsNew: "还没扫过 {count}", + scanPartsNote: "这些是绘图模型要配合加载的部件,很多本来就不在 Civitai 上,没查到不影响使用。扫描会顺便记下文件指纹,模型检查靠它在别人的工作流里认出这些文件;扫不扫都可以,上面的数字也不算它们。", + scanListParts: "基础组件 {count} 个", + scanListPartsHint: "这些是绘图模型要配合加载的部件(VAE、文本编码器、CLIP、CLIP Vision、放大模型),很多本来就不在 Civitai 上,没查到不影响使用。扫描会顺便记下文件指纹,模型检查靠它在别人的工作流里认出这些文件;扫不扫都可以。", + scanScopeLookup: "{count} 个{parts}这次会去 Civitai 查:还没扫过的,和上次没连上网的", + scanScopeInfer: "{count} 个还没扫过的{parts}只从文件推断类型(离线,不联网)", scanScopeNothingNew: "没有还没扫过的模型,这次不会联网查", - scanScopeMatched: "{count} 个已经查到,只用已有记录,不再联网", - scanScopeNotFound: "{count} 个 Civitai 上没有,不重查(要重查,在“没查到”列表里再联网查一遍)", + scanScopeMatched: "{count} 个{parts}已经查到,只用已有记录,不再联网", + scanScopeNotFound: "{count} 个{parts}Civitai 上没有,不重查(要重查,在“没查到”列表里再联网查一遍)", scanScopeExcluded: "{count} 个设为不扫描,跳过", - scanScopeWaiting: "{count} 个上次没连上网、还没查的,等下次联网扫描再查", - scanScopeForce: "开着“重新获取”:{count} 个都会重新联网查", + scanScopeWaiting: "{count} 个{parts}上次没连上网、还没查的,等下次联网扫描再查", + scanScopeForce: "开着“重新获取”:{count} 个{parts}都会重新联网查", scanPickTitle: "挑选要扫描的模型", scanPickSearch: "搜索名字或文件名", scanPickAllFolders: "全部文件夹", @@ -279,8 +286,7 @@ export const i18n = { scanPickFailed: "读不到模型列表:{error}", scanPickEmpty: "没有符合条件的模型。", scanPickTruncated: "模型太多,只列出了前 {shown} 个(共 {total} 个)。", - scanPickFoundNote: "其中 {count} 个已经查到,这次只用已有记录,不会重新联网;要重新查,打开下面的“重新获取已扫描的模型”。", - scanPageNothingPicked: "还没有挑选模型。", + scanPickFoundNote: "其中 {count} 个已经查到,这次只用已有记录,不会重新联网;要重新查,在下面的扫描设置里打开“重新获取已扫描的模型”。", scanPageHow: "怎么扫描", scanPageOnline: "联网扫描时", scanPageOnlineOff: "联网扫描时(现在是离线扫描,用不到)", @@ -383,7 +389,6 @@ export const i18n = { scanReasonNotImage: "也看不出是哪类绘图模型,可能是语音、放大之类的模型", scanPageShowList: "查看是哪些模型", scanPageScanNewOffline: "离线扫描 {count} 个新模型", - scanPageHintOffline: "离线扫描不联网,只看模型文件本身猜底模,没有封面和触发词。之后联网扫描会自动补查这些模型。", scanProgressShowResult: "查看结果", modelSourceLabel: "来源", modelSourceCivitai: "Civitai", @@ -2525,12 +2530,9 @@ export const i18n = { scanPageScanPicked: "Scan the {count} picked models", scanPageScanning: "Scanning…", scanPageRetry: "Look up the {count} not found again", - scanPageHint: "An online scan asks Civitai first and guesses the base model from the file for what Civitai does not know; when Civitai cannot be reached it only reads the files and looks again next time. Scans run in the background; you can close this window and keep using ComfyUI.", - scanPageHintPicked: "Only the models picked in the scan settings are scanned.", scanSettingsTitle: "Scan settings", scanSettingsChange: "Change ›", scanSettingsLead: "The next scan follows these settings. Reloading the page resets them; renaming files and fetching again turn themselves off after one scan.", - scanChipPicked: "Only the {count} picked", scanChipOnline: "Online, asks Civitai", scanChipDisplayName: "Civitai names shown", scanChipFileRename: "Renames files", @@ -2538,20 +2540,30 @@ export const i18n = { scanChipAutoFix: "Fixes the workflow after", scanChipKeyOn: "API key set", scanChipKeyOff: "No API key", - scanPageScope: "Which models", - scanPageScopeAll: "All models", - scanPageScopePicked: "Only ones I pick", scanPageScopeAllHelp: "Every model folder except those hidden in Settings → Folder manager.", - scanPagePickModels: "Pick models…", - scanScopeTotal: "{count} models", - scanScopeLookup: "{count} are looked up on Civitai this time: the ones not scanned yet, and those last scanned without a connection", - scanScopeInfer: "{count} not scanned yet are only read from their files (offline)", + scanPagePickModels: "Pick models to scan…", + scanPickBackToAll: "Clear, scan everything again", + scanPageBackground: "Scans run in the background; you can close this window and keep using ComfyUI.", + scanSettingsFold: "Fold", + scanScopeWithParts: " ({count} of them components)", + scanPartsTitle: "Components", + scanPartsKinds: "VAE, text encoders, CLIP, CLIP Vision, upscalers", + scanPartsOpen: "See ›", + scanPartsTotal: "{count} in all", + scanPartsMatched: "found on Civitai {count}", + scanPartsUnmatched: "not found {count}", + scanPartsNew: "not scanned yet {count}", + scanPartsNote: "These are the parts a drawing model loads with it. Many are not on Civitai at all, and not being found does not matter. A scan still notes each file's fingerprint, which Model Check uses to recognize them in other people's workflows; scanning them is up to you, and the counts above leave them out.", + scanListParts: "{count} components", + scanListPartsHint: "These are the parts a drawing model loads with it (VAE, text encoders, CLIP, CLIP Vision, upscalers). Many are not on Civitai at all, and not being found does not matter. A scan still notes each file's fingerprint, which Model Check uses to recognize them in other people's workflows; scanning them is up to you.", + scanScopeLookup: "{count}{parts} are looked up on Civitai this time: the ones not scanned yet, and those last scanned without a connection", + scanScopeInfer: "{count} not scanned yet{parts} are only read from their files (offline)", scanScopeNothingNew: "Every model has been scanned: nothing is looked up online this time", - scanScopeMatched: "{count} already found keep their record and are not looked up again", - scanScopeNotFound: "{count} not on Civitai are not looked up again (the Not found list can look them up again)", + scanScopeMatched: "{count} already found{parts} keep their record and are not looked up again", + scanScopeNotFound: "{count} not on Civitai{parts} are not looked up again (the Not found list can look them up again)", scanScopeExcluded: "{count} set to never be scanned are left out", - scanScopeWaiting: "{count} last scanned without a connection wait for the next online scan", - scanScopeForce: "Fetch scanned models again is on: all {count} are looked up again", + scanScopeWaiting: "{count}{parts} last scanned without a connection wait for the next online scan", + scanScopeForce: "Fetch scanned models again is on: all {count}{parts} are looked up again", scanPickTitle: "Pick models to scan", scanPickSearch: "Search name or file name", scanPickAllFolders: "All folders", @@ -2573,8 +2585,7 @@ export const i18n = { scanPickFailed: "Could not read the models: {error}", scanPickEmpty: "No model matches.", scanPickTruncated: "Too many models: only the first {shown} of {total} are listed.", - scanPickFoundNote: "{count} of them are already found: their record is used again, not looked up; to look them up again, turn on Fetch scanned models again below.", - scanPageNothingPicked: "No models picked yet.", + scanPickFoundNote: "{count} of them are already found: their record is used again, not looked up; to look them up again, turn on Fetch scanned models again in the scan settings below.", scanPageHow: "How to scan", scanPageOnline: "When scanning online", scanPageOnlineOff: "When scanning online (not used by an offline scan)", @@ -2677,7 +2688,6 @@ export const i18n = { scanReasonNotImage: "and the file is no known image model: maybe a voice, upscale or similar model", scanPageShowList: "Show which models", scanPageScanNewOffline: "Scan {count} new models offline", - scanPageHintOffline: "An offline scan stays off the internet and only reads the files to guess the base model: no covers or trigger words. The next online scan looks these models up.", scanProgressShowResult: "Show the result", modelSourceLabel: "Source", modelSourceCivitai: "Civitai", diff --git a/web/modules/ui_model_download.js b/web/modules/ui_model_download.js index 18907b6..fc68a64 100644 --- a/web/modules/ui_model_download.js +++ b/web/modules/ui_model_download.js @@ -14,7 +14,7 @@ import { cancelDownload, defaultRoot, doneCount, downloadFor, lookupDownloads, rememberRoot, startDownload, syncDownloads, watchDownloads, } from './model_download.js'; -import { saveApiKey } from './ui_scan_page.js'; +import { saveApiKey } from './ui_scan_settings.js'; import { openSettingsPage } from './ui_settings_page.js'; const ACTIVE = ['queued', 'running', 'verifying']; diff --git a/web/modules/ui_scan_lists.js b/web/modules/ui_scan_lists.js index 661229a..e772d0d 100644 --- a/web/modules/ui_scan_lists.js +++ b/web/modules/ui_scan_lists.js @@ -1,7 +1,9 @@ /** * The scan page's lists, each a page of its own behind a count on the scan page: the models - * not scanned yet, the unmatched ones, the model files a scan never reads (other formats), and - * each model the last scan changed (GET /anomalous/scan_summary, /anomalous/last_scan). + * not scanned yet, the unmatched ones (both without the components), the components (VAE, text + * encoders…: found, not found and not scanned, with why not being found is fine), the model + * files a scan never reads (other formats), and each model the last scan changed + * (GET /anomalous/scan_summary, /anomalous/last_scan). * On the scan page itself the last scan is one summary card with a way to its list. * Every row opens its model; rows offer "scan" / "look up again" and a Civitai search. Two * more lists: the new models whose header looks like no drawing model ("don't scan" / "scan @@ -131,7 +133,7 @@ export function renderLastScan(result, onDetails) { /** A list's models, title, why they are here and the row for one of them. */ function listOf(view, summary, last, actions) { if (view === 'new') { - const items = summary?.new_models || []; + const items = (summary?.new_models || []).filter(item => !item.component); return { items, title: t('scanListNew', { count: items.length }), @@ -170,6 +172,24 @@ function listOf(view, summary, last, actions) { ]), }; } + if (view === 'components') { + const order = { new: 0, unmatched: 1, matched: 2 }; + const items = [...(summary?.component_models || [])].sort((a, b) => order[a.state] - order[b.state]); + const fresh = items.filter(item => item.state === 'new'); + const detail = item => (item.state === 'matched' ? t('scanPageMatched') : item.state === 'new' ? t('scanPageNew') + : [reasonText(item.reason), item.reason === 'not_found' ? t('scanFingerprintKept') : ''].filter(Boolean).join(' · ')); + return { + items, + title: t('scanListParts', { count: items.length }), + hint: t('scanListPartsHint'), + all: fresh.length ? [t('scanListScanAll', { count: fresh.length }), () => actions.scan(fresh)] : null, + render: item => row(item.reason === 'damaged' ? { ...item, status: 'damaged' } : item, detail(item), + item.state === 'unmatched' ? unmatchedButtons(item, actions) + : item.state === 'new' ? [[t('scanRowOpen'), () => actions.open(item)], [t('scanRowScan'), () => actions.scan([item]), actions.busy]] + : [[t('scanRowOpen'), () => actions.open(item)]], + item.state === 'matched' ? '✓' : item.reason === 'damaged' ? '⚠' : item.state === 'unmatched' ? '≈' : ''), + }; + } if (view === 'skipped') { const items = summary?.skipped_models || []; return { @@ -191,14 +211,14 @@ function listOf(view, summary, last, actions) { } // Unmatched: damaged files first (to download again), then those the next online scan looks up by itself. const rank = item => (item.reason === 'damaged' ? 2 : isPending(item.reason) ? 1 : 0); - const items = [...(summary?.unmatched_models || [])].sort((a, b) => rank(b) - rank(a)); + const items = (summary?.unmatched_models || []).filter(item => !item.component).sort((a, b) => rank(b) - rank(a)); // Civitai does not know it and the file is no known image model: say what it may be. const guess = item => (item.base ? `≈ ${item.base}` : item.reason === 'not_found' ? t('scanReasonNotImage') : ''); return { items, title: t('scanListUnmatched', { count: items.length }), hint: t(actions.offline ? 'scanListUnmatchedHintOffline' : 'scanListUnmatchedHint'), - all: items.length && !actions.offline ? [t('scanPageRetry', { count: items.length }), actions.retryAll] : null, + all: items.length && !actions.offline ? [t('scanPageRetry', { count: items.length }), () => actions.scan(items, { retry: true })] : null, render: item => row(item.reason === 'damaged' ? { ...item, status: 'damaged' } : item, [reasonText(item.reason), guess(item), item.reason === 'not_found' ? t('scanFingerprintKept') : ''].filter(Boolean).join(' · '), unmatchedButtons(item, actions), item.reason === 'damaged' ? '⚠' : '≈'), @@ -206,7 +226,7 @@ function listOf(view, summary, last, actions) { } /** - * One list as a page ('new' | 'unmatched' | 'skipped' | 'result' | 'notdrawing' | 'excluded'): back to the scan page, what + * One list as a page ('new' | 'unmatched' | 'components' | 'skipped' | 'result' | 'notdrawing' | 'excluded'): back to the scan page, what * these models are and why they are here, a button for all of them, then the rows. A scan * started here shows its progress in `progressHost`, above the rows. */ diff --git a/web/modules/ui_scan_page.js b/web/modules/ui_scan_page.js index 08b9871..d7a675d 100644 --- a/web/modules/ui_scan_page.js +++ b/web/modules/ui_scan_page.js @@ -1,11 +1,14 @@ /** - * The scan page: how many models are matched on Civitai, unmatched or not scanned - * (GET /anomalous/scan_summary), one button to scan, how the next scan goes, progress in - * the page and the last scan in one card. Each count and the last scan open their list - * as a page of its own (ui_scan_lists.js), the settings card the settings page; each - * has a way back. The settings page says, in the summary's counts, what a scan of all models - * will do, or lists the models picked in ui_scan_picker.js. New models whose header looks like no drawing model are asked about - * (don't scan them, scan them, see them); models set to "don't scan" have a list of their own. + * The scan page (GET /anomalous/scan_summary): how many models are matched on Civitai, + * unmatched or not scanned, without the components a drawing model loads (VAE, text + * encoders…), which have a card of their own saying that not being on Civitai is no fault. + * Then the scan itself: one button, "pick models to scan" beside it (ui_scan_picker.js; the + * picks are named under it, and cleared the button scans everything again), and what this + * scan will do in the summary's counts. Under it the settings, unfolded in place + * (ui_scan_settings.js), progress in the page and the last scan in one card. Each count, the + * components and the last scan open their list as a page of its own (ui_scan_lists.js) with a + * way back. New models whose header looks like no drawing model are asked about (don't scan + * them, scan them, see them); models set to "don't scan" have a list of their own. * Scans run through scan_runner.js; the progress box is scan_progress.js's panel, * hosted here while the page is shown. */ @@ -15,22 +18,13 @@ import { isScanRunning, startScan, targetsForItems } from './scan_runner.js'; import { setScanProgressHost } from './scan_progress.js'; import { openListedModel, renderLastScan, renderListPage } from './ui_scan_lists.js'; import { openScanPicker, pickKey, picksLine } from './ui_scan_picker.js'; +import { renderScanSettings, scanOptions as options } from './ui_scan_settings.js'; -// Choices kept while the browser is open. The two risky ones go back off after each scan. -const options = { - scope: 'all', // 'all' | 'picked' - picked: [], // the picker's items - offline: false, - virtualRename: true, - physicalRename: false, - forceOverwrite: false, - autoFix: true, -}; -const SHOWN_PICKS = 12; // picked models named on the settings page; the rest are counted -// The view shown: '' the page itself, 'settings', or a list: 'new' | 'unmatched' | 'skipped' | 'result' -// | 'notdrawing' | 'excluded'. -// `scroll`: where to put a list shown again after a model's Back. -const pageState = { view: '', scroll: 0 }; +const SHOWN_PICKS = 12; // picked models named on the page; the rest are counted +// The view shown: '' the page itself, or a list: 'new' | 'unmatched' | 'skipped' | 'result' +// | 'notdrawing' | 'excluded' | 'components'. `scroll`: where to put a list shown again after a +// model's Back. `settingsOpen`: the settings card is unfolded. +const pageState = { view: '', scroll: 0, settingsOpen: false }; function el(tag, className, text) { const node = document.createElement(tag); @@ -67,99 +61,67 @@ function stat(value, label, tone, onClick) { return box; } -/** One setting: what it is and does on the left, its switch on the right; `tag` warns beside the title. */ -function toggle(key, titleKey, helpKey, { tag = '', onChange = null } = {}) { - const row = el('label', 'anomalous-scan-setting'); - const copy = el('span', 'anomalous-scan-setting-copy'); - const title = el('span', 'anomalous-scan-setting-title', t(titleKey)); - if (tag) title.append(el('span', 'anomalous-scan-tag', t(tag))); - copy.append(title, el('small', 'anomalous-scan-setting-help', t(helpKey))); - const input = el('input', 'anomalous-scan-switch'); - input.type = 'checkbox'; - input.checked = Boolean(options[key]); - input.onchange = () => { - options[key] = input.checked; - onChange?.(); +/** The counts without the components (they have their own card). */ +function mainCounts(summary) { + const parts = summary.components || { total: 0, matched: 0, unmatched: 0, new: 0 }; + const unmatched = (summary.unmatched_models || []).filter(item => !item.component); + return { + total: summary.total - parts.total, matched: summary.matched - parts.matched, + unmatched: summary.unmatched - parts.unmatched, new: summary.new - parts.new, + // Not on Civitai is no fault (the fingerprint is kept): only damaged files and lookups still to do warn. + warn: unmatched.some(item => item.reason === 'damaged' || item.reason === 'offline' || item.reason === 'network'), }; - row.append(copy, input); - return row; } -/** A titled card of the settings page. */ -function group(title, ...children) { - const box = el('section', 'anomalous-scan-card anomalous-scan-group'); - box.append(el('h3', 'anomalous-scan-group-title', title), ...children); - return box; -} - -/** How the next scan will go, in short words; `true` marks the ones that change files or take long. */ -function currentChoices(hasKey) { - const online = !options.offline; - return [ - [options.scope === 'picked' ? t('scanChipPicked', { count: options.picked.length }) : t('scanPageScopeAll')], - [t(online ? 'scanChipOnline' : 'scanPageOffline')], - online && options.virtualRename && [t('scanChipDisplayName')], - online && options.physicalRename && [t('scanChipFileRename'), true], - online && options.forceOverwrite && [t('scanChipForce'), true], - options.autoFix && [t('scanChipAutoFix')], - [t(hasKey ? 'scanChipKeyOn' : 'scanChipKeyOff')], - ].filter(Boolean); -} - -/** The scan page's card for the settings: how the next scan goes, and a way to change it. */ -function settingsCard(owner, panel, hasKey) { - const card = button('anomalous-scan-card anomalous-scan-settings-card', undefined, () => go(owner, panel, 'settings')); - const head = el('span', 'anomalous-scan-settings-head'); - head.append(el('span', 'anomalous-scan-card-title', t('scanSettingsTitle')), el('span', 'anomalous-scan-settings-change', t('scanSettingsChange'))); - const chips = el('span', 'anomalous-scan-chips'); - for (const [text, warn] of currentChoices(hasKey)) chips.append(el('span', `anomalous-scan-chip${warn ? ' is-warn' : ''}`, text)); - card.append(head, chips); +/** The components (VAE, text encoders…): their counts, that missing on Civitai is fine, and a way to their list. */ +function componentsCard(summary, open) { + const parts = summary?.components; + if (!parts?.total) return null; + const card = button('anomalous-scan-card anomalous-scan-parts', undefined, open); + const head = el('span', 'anomalous-scan-parts-head'); + head.append(el('span', 'anomalous-scan-card-title', t('scanPartsTitle')), el('span', 'anomalous-scan-parts-kinds', t('scanPartsKinds')), + el('span', 'anomalous-scan-settings-change', t('scanPartsOpen'))); + const counts = [t('scanPartsTotal', { count: parts.total }), + parts.matched && t('scanPartsMatched', { count: parts.matched }), + parts.unmatched && t('scanPartsUnmatched', { count: parts.unmatched }), + parts.new && t('scanPartsNew', { count: parts.new })].filter(Boolean).join(' · '); + card.append(head, el('span', 'anomalous-scan-parts-counts', counts), el('span', 'anomalous-scan-parts-note', t('scanPartsNote'))); return card; } -/** What a scan of every model does this time, in the summary's counts and the current settings. */ +/** + * What a scan of every model does this time, in the summary's counts and the current settings; + * a line says how many of its models are components (the counts above leave them out). + */ function wholeScanFacts(summary) { const box = el('div', 'anomalous-scan-scope-facts'); if (summary) { const online = !options.offline; const force = online && options.forceOverwrite; - const pending = online ? summary.pending : 0; - const lookup = online ? summary.new + pending : summary.new; - const lines = force ? [['scanScopeForce', summary.total], ['scanScopeExcluded', summary.excluded]] : [ - [online ? 'scanScopeLookup' : 'scanScopeInfer', lookup], - ['scanScopeNothingNew', lookup ? 0 : 1], - ['scanScopeMatched', summary.matched], - ['scanScopeNotFound', summary.unmatched - summary.pending], - ['scanScopeWaiting', online ? 0 : summary.pending], - ['scanScopeExcluded', summary.excluded], + const parts = { total: 0, matched: 0, unmatched: 0, new: 0, pending: 0, ...summary.components }; + const lookup = online ? summary.new + summary.pending : summary.new; + const lines = force ? [['scanScopeForce', summary.total, parts.total], ['scanScopeExcluded', summary.excluded, 0]] : [ + [online ? 'scanScopeLookup' : 'scanScopeInfer', lookup, online ? parts.new + parts.pending : parts.new], + ['scanScopeNothingNew', lookup ? 0 : 1, 0], + ['scanScopeMatched', summary.matched, parts.matched], + ['scanScopeNotFound', summary.unmatched - summary.pending, parts.unmatched - parts.pending], + ['scanScopeWaiting', online ? 0 : summary.pending, parts.pending], + ['scanScopeExcluded', summary.excluded, 0], ]; const list = el('ul', 'anomalous-scan-scope-list'); - for (const [key, count] of lines) { - if (count) list.append(el('li', '', t(key, { count: count.toLocaleString() }))); + for (const [key, count, ofParts] of lines) { + if (count) list.append(el('li', '', t(key, { count: count.toLocaleString(), parts: ofParts ? t('scanScopeWithParts', { count: ofParts }) : '' }))); } - box.append(el('strong', 'anomalous-scan-scope-total', t('scanScopeTotal', { count: summary.total.toLocaleString() })), list); + box.append(list); } box.append(el('small', 'anomalous-scan-setting-help', t('scanPageScopeAllHelp'))); return box; } -/** The picks: how many and how big, their names (each with a ✕), a way to pick, and a word on the found ones. */ +/** The picks: how many and how big, their names (each with a ✕), and a word on the found ones. */ function pickedFacts(summary, refresh) { const box = el('div', 'anomalous-scan-pick'); - const head = el('div', 'anomalous-scan-pick-head'); - head.append(el('span', 'anomalous-scan-pick-count', picksLine(options.picked)), - button('anomalous-scan-secondary anomalous-scan-small-btn', t(options.picked.length ? 'scanPickChange' : 'scanPagePickModels'), () => openScanPicker(options.picked, (next) => { - options.picked = next; - refresh(); - }))); - if (options.picked.length) { - head.append(button('anomalous-pick-link', t('scanPickClear'), () => { - options.picked = []; - refresh(); - })); - } - box.append(head); - if (!options.picked.length) return box; + box.append(el('span', 'anomalous-scan-pick-count', picksLine(options.picked))); const names = el('div', 'anomalous-scan-pick-names'); for (const item of options.picked.slice(0, SHOWN_PICKS)) { const chip = el('span', 'anomalous-scan-pick-name'); @@ -183,84 +145,40 @@ function pickedFacts(summary, refresh) { return box; } -/** The settings as a page of their own, like the lists: back, then one card per question. */ -function settingsPage(owner, panel, { summary, hasKey }, progressHost) { - const refresh = () => go(owner, panel, 'settings'); - const page = el('div', 'anomalous-scan-page'); - page.append(button('anomalous-scan-back', t('scanBack'), () => go(owner, panel, '')), - el('h1', 'anomalous-scan-title', t('scanSettingsTitle')), el('p', 'anomalous-scan-lead', t('scanSettingsLead')), progressHost); - - // Which models: two choices side by side; "picked" shows the picker. - const segment = el('div', 'anomalous-scan-segment'); - segment.setAttribute('role', 'radiogroup'); - for (const [value, label] of [['all', t('scanPageScopeAll')], ['picked', t('scanPageScopePicked')]]) { - const choice = button('anomalous-scan-segment-btn', label, () => { - options.scope = value; - refresh(); - }); - choice.setAttribute('role', 'radio'); - choice.setAttribute('aria-checked', String(options.scope === value)); - segment.append(choice); - } - const scopeNote = options.scope === 'picked' ? pickedFacts(summary, refresh) : wholeScanFacts(summary); - - // What only an online scan does: under its own heading, faded while scanning offline. - const online = el('div', `anomalous-scan-online${options.offline ? ' is-disabled' : ''}`); - online.append( - el('div', 'anomalous-scan-subtitle', t(options.offline ? 'scanPageOnlineOff' : 'scanPageOnline')), - toggle('virtualRename', 'scanPageDisplayName', 'scanPageDisplayNameHelp'), - toggle('physicalRename', 'scanPageFileRename', 'scanPageFileRenameHelp', { tag: 'scanPageTagFiles' }), - toggle('forceOverwrite', 'scanPageForce', 'scanPageForceHelp', { tag: 'scanPageTagSlow', onChange: refresh }), - ); - - const key = el('div', 'anomalous-scan-setting'); - const keyCopy = el('span', 'anomalous-scan-setting-copy'); - const keyTitle = el('span', 'anomalous-scan-setting-title', t('scanPageApiKey')); - keyTitle.append(el('span', `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`, t(hasKey ? 'scanPageApiKeyOn' : 'scanPageApiKeyOff'))); - keyCopy.append(keyTitle, el('small', 'anomalous-scan-setting-help', t('scanPageApiKeyHelp'))); - key.append(keyCopy, button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPageApiKeySet'), async () => { - if (await saveApiKey()) renderScanPage(owner, panel); +/** The scan: its button, picking models beside it, and what it will do. */ +function scanCard(owner, panel, summary, busy) { + const refresh = () => go(owner, panel, ''); + const picked = options.picked.length; + // An online scan also looks up the models earlier scans could not ask Civitai about. + const waiting = (summary?.new || 0) + (options.offline ? 0 : summary?.pending || 0); + const label = busy ? t('scanPageScanning') + : picked ? t('scanPageScanPicked', { count: picked }) + : waiting ? t(options.offline ? 'scanPageScanNewOffline' : 'scanPageScanNew', { count: waiting }) + : t('scanPageScanAgain'); + const primary = button('anomalous-scan-primary', label, () => run(owner, panel)); + primary.disabled = busy; + const pick = button('anomalous-scan-secondary', t(picked ? 'scanPickChange' : 'scanPagePickModels'), () => openScanPicker(options.picked, (next) => { + options.picked = next; + refresh(); })); - - page.append( - group(t('scanPageScope'), segment, scopeNote), - group(t('scanPageHow'), toggle('offline', 'scanPageOffline', 'scanPageOfflineHelp', { onChange: refresh }), online), - group(t('scanPageAfter'), toggle('autoFix', 'scanPageAutoFix', 'scanPageAutoFixHelp')), - group(t('scanPageAccount'), key), - ); - return page; -} - -/** Asks for the Civitai key and saves it; true when saved (also Model Check's downloads). */ -export async function saveApiKey() { - const value = prompt(t('scanPageApiKeyPrompt'), ''); - if (value === null) return false; - try { - const res = await fetch('/anomalous/save_config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ api_key: value.trim() }), - }); - const data = await res.json(); - if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`); - alert(t(value.trim() ? 'scanPageApiKeySaved' : 'scanPageApiKeyCleared')); - return true; - } catch (error) { - alert(t('scanPageApiKeyFailed', { error: String(error.message || error) })); - return false; + const actions = el('div', 'anomalous-scan-actions'); + actions.append(primary, pick); + if (picked) { + actions.append(button('anomalous-pick-link', t('scanPickBackToAll'), () => { + options.picked = []; + refresh(); + })); } + const card = el('section', 'anomalous-scan-card anomalous-scan-run'); + card.append(actions, picked ? pickedFacts(summary, refresh) : wholeScanFacts(summary), el('p', 'anomalous-scan-muted', t('scanPageBackground'))); + return card; } -/** A scan from this page. `extra`: { retryUnmatched } for every unmatched model, or - * { targets, retryUnmatched } for the models of a list row or a whole list. */ +/** A scan from this page: every model, the picks, or `extra.targets` (a list's models; `retryUnmatched` to look them up again). */ async function run(owner, panel, extra = {}) { - const whole = !extra.retryUnmatched && !extra.targets; - if (whole && options.scope === 'picked' && !options.picked.length) { - alert(t('scanPageNothingPicked')); - return; - } + const whole = !extra.targets; if (whole && options.forceOverwrite && !confirm(t('scanPageForceConfirm'))) return; - const targets = whole && options.scope === 'picked' ? targetsForItems(options.picked) : extra.targets; + const targets = whole && options.picked.length ? targetsForItems(options.picked) : extra.targets; const started = startScan(owner, { ...options, ...extra, targets }); renderScanPage(owner, panel); // buttons show "scanning" await started; @@ -323,25 +241,26 @@ function listActions(owner, panel, busy) { }, scan: (items, { retry = false } = {}) => run(owner, panel, { targets: targetsForItems(items), retryUnmatched: retry }), choose: (items, choice) => setChoice(owner, panel, items, choice), - retryAll: () => run(owner, panel, { retryUnmatched: true }), }; } -/** The scan page itself: what a scan is, the counts (each opening its list), one button, the last scan. */ +/** The scan page itself: what a scan is, the counts (each opening its list), the components, the scan, its settings, the last scan. */ function mainPage(owner, panel, { summary, config, last }, busy, progressHost) { const page = el('div', 'anomalous-scan-page'); page.append(el('h1', 'anomalous-scan-title', t('scanPageTitle')), el('p', 'anomalous-scan-lead', t('scanPageLead'))); const open = view => () => go(owner, panel, view); + const counts = summary ? mainCounts(summary) : {}; const stats = el('div', 'anomalous-scan-stats'); stats.append( - stat(summary?.total, t('scanPageTotal')), - stat(summary?.matched, t('scanPageMatched'), 'ok'), - // Not on Civitai is no fault (the fingerprint is kept): only damaged files and lookups still to do warn. - stat(summary?.unmatched, t('scanPageUnmatched'), summary?.damaged || summary?.pending ? 'warn' : '', summary?.unmatched ? open('unmatched') : null), - stat(summary?.new, t('scanPageNew'), summary?.new ? 'new' : '', summary?.new ? open('new') : null), + stat(counts.total, t('scanPageTotal')), + stat(counts.matched, t('scanPageMatched'), 'ok'), + stat(counts.unmatched, t('scanPageUnmatched'), counts.warn ? 'warn' : '', counts.unmatched ? open('unmatched') : null), + stat(counts.new, t('scanPageNew'), counts.new ? 'new' : '', counts.new ? open('new') : null), ); page.append(stats); + const parts = componentsCard(summary, open('components')); + if (parts) page.append(parts); if (summary?.damaged) page.append(button('anomalous-scan-damaged-note', t('scanPageDamagedNote', { count: summary.damaged }), open('unmatched'))); const notDrawing = (summary?.new_models || []).filter(item => item.not_drawing); if (notDrawing.length) page.append(notDrawingCard(owner, panel, notDrawing, open('notdrawing'))); @@ -349,20 +268,12 @@ function mainPage(owner, panel, { summary, config, last }, busy, progressHost) { if (summary?.skipped) page.append(button('anomalous-scan-skipped-note', t('scanPageSkippedNote', { count: summary.skipped }), open('skipped'))); if (!summary) page.append(el('p', 'anomalous-scan-muted', t('scanPageSummaryFailed'))); - const actions = el('div', 'anomalous-scan-actions'); - const picked = options.scope === 'picked'; - // An online scan also looks up the models earlier scans could not ask Civitai about. - const waiting = (summary?.new || 0) + (options.offline ? 0 : summary?.pending || 0); - const label = busy ? t('scanPageScanning') - : picked ? t('scanPageScanPicked', { count: options.picked.length }) - : waiting ? t(options.offline ? 'scanPageScanNewOffline' : 'scanPageScanNew', { count: waiting }) - : t('scanPageScanAgain'); - const primary = button('anomalous-scan-primary', label, () => run(owner, panel)); - primary.disabled = busy; - actions.append(primary); - page.append(actions); - const hint = picked ? t('scanPageHintPicked') : t(options.offline ? 'scanPageHintOffline' : 'scanPageHint'); - page.append(el('p', 'anomalous-scan-muted', hint), settingsCard(owner, panel, Boolean(config?.has_api_key)), progressHost); + page.append(scanCard(owner, panel, summary, busy), renderScanSettings(Boolean(config?.has_api_key), { + open: pageState.settingsOpen, + onFold: (isOpen) => { pageState.settingsOpen = isOpen; }, + onChange: () => go(owner, panel, ''), + onKey: () => renderScanPage(owner, panel), + }), progressHost); const result = renderLastScan(last, open('result')); if (result) page.append(result); @@ -376,8 +287,7 @@ function paint(owner, panel, data) { pageState.scroll = 0; const busy = isScanRunning(); const progressHost = el('div', 'anomalous-scan-progress-host'); - const page = view === 'settings' ? settingsPage(owner, panel, { summary: data.summary, hasKey: Boolean(data.config?.has_api_key) }, progressHost) - : view ? renderListPage(view, data.summary, data.last, listActions(owner, panel, busy), progressHost) + const page = view ? renderListPage(view, data.summary, data.last, listActions(owner, panel, busy), progressHost) : mainPage(owner, panel, data, busy, progressHost); panel.replaceChildren(page); panel._scanView = view; diff --git a/web/modules/ui_scan_settings.js b/web/modules/ui_scan_settings.js new file mode 100644 index 0000000..a634085 --- /dev/null +++ b/web/modules/ui_scan_settings.js @@ -0,0 +1,123 @@ +/** + * How the next scan goes (`scanOptions`, kept while the browser is open; the two risky ones go + * back off after each scan) and the scan page's settings card that changes it: folded, a row + * of short words for the current choices; unfolded, in place, the switches (offline, the + * online-only ones, what happens after the scan) and the Civitai key. Which models a scan + * reads is the scan page's own (its picks), not a setting. + */ + +import { translate as t } from './locales.js'; + +export const scanOptions = { + picked: [], // the picker's items; none: every model + offline: false, + virtualRename: true, + physicalRename: false, + forceOverwrite: false, + autoFix: true, +}; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +/** One setting: what it is and does on the left, its switch on the right; `tag` warns beside the title. */ +function toggle(key, titleKey, helpKey, onChange, tag = '') { + const row = el('label', 'anomalous-scan-setting'); + const copy = el('span', 'anomalous-scan-setting-copy'); + const title = el('span', 'anomalous-scan-setting-title', t(titleKey)); + if (tag) title.append(el('span', 'anomalous-scan-tag', t(tag))); + copy.append(title, el('small', 'anomalous-scan-setting-help', t(helpKey))); + const input = el('input', 'anomalous-scan-switch'); + input.type = 'checkbox'; + input.checked = Boolean(scanOptions[key]); + input.onchange = () => { + scanOptions[key] = input.checked; + onChange(); + }; + row.append(copy, input); + return row; +} + +/** How the next scan will go, in short words; `true` marks the ones that change files or take long. */ +function currentChoices(hasKey) { + const online = !scanOptions.offline; + return [ + [t(online ? 'scanChipOnline' : 'scanPageOffline')], + online && scanOptions.virtualRename && [t('scanChipDisplayName')], + online && scanOptions.physicalRename && [t('scanChipFileRename'), true], + online && scanOptions.forceOverwrite && [t('scanChipForce'), true], + scanOptions.autoFix && [t('scanChipAutoFix')], + [t(hasKey ? 'scanChipKeyOn' : 'scanChipKeyOff')], + ].filter(Boolean); +} + +/** Asks for the Civitai key and saves it; true when saved (also Model Check's downloads). */ +export async function saveApiKey() { + const value = prompt(t('scanPageApiKeyPrompt'), ''); + if (value === null) return false; + try { + const res = await fetch('/anomalous/save_config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ api_key: value.trim() }), + }); + const data = await res.json(); + if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`); + alert(t(value.trim() ? 'scanPageApiKeySaved' : 'scanPageApiKeyCleared')); + return true; + } catch (error) { + alert(t('scanPageApiKeyFailed', { error: String(error.message || error) })); + return false; + } +} + +/** + * The settings card. `open`: unfolded; `onFold(open)` when it is folded or unfolded; `onChange()` + * after each switch (the page says what the next scan does); `onKey()` after the key was saved. + */ +export function renderScanSettings(hasKey, { open, onFold, onChange, onKey }) { + const card = el('details', 'anomalous-scan-card anomalous-scan-settings'); + card.open = open; + card.ontoggle = () => onFold(card.open); + const head = el('summary', 'anomalous-scan-settings-head'); + const chips = el('span', 'anomalous-scan-chips'); + for (const [text, warn] of currentChoices(hasKey)) chips.append(el('span', `anomalous-scan-chip${warn ? ' is-warn' : ''}`, text)); + head.append(el('span', 'anomalous-scan-card-title', t('scanSettingsTitle')), chips, + el('span', 'anomalous-scan-settings-change', t('scanSettingsChange')), el('span', 'anomalous-scan-settings-fold', t('scanSettingsFold'))); + + // What only an online scan does: under its own heading, faded while scanning offline. + const online = el('div', `anomalous-scan-online${scanOptions.offline ? ' is-disabled' : ''}`); + online.append( + el('div', 'anomalous-scan-subtitle', t(scanOptions.offline ? 'scanPageOnlineOff' : 'scanPageOnline')), + toggle('virtualRename', 'scanPageDisplayName', 'scanPageDisplayNameHelp', onChange), + toggle('physicalRename', 'scanPageFileRename', 'scanPageFileRenameHelp', onChange, 'scanPageTagFiles'), + toggle('forceOverwrite', 'scanPageForce', 'scanPageForceHelp', onChange, 'scanPageTagSlow'), + ); + + const key = el('div', 'anomalous-scan-setting'); + const keyCopy = el('span', 'anomalous-scan-setting-copy'); + const keyTitle = el('span', 'anomalous-scan-setting-title', t('scanPageApiKey')); + keyTitle.append(el('span', `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`, t(hasKey ? 'scanPageApiKeyOn' : 'scanPageApiKeyOff'))); + keyCopy.append(keyTitle, el('small', 'anomalous-scan-setting-help', t('scanPageApiKeyHelp'))); + const set = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('scanPageApiKeySet')); + set.type = 'button'; + set.onclick = async () => { + if (await saveApiKey()) onKey(); + }; + key.append(keyCopy, set); + + const part = (titleKey, ...rows) => { + const box = el('div', 'anomalous-scan-settings-part'); + box.append(el('h3', 'anomalous-scan-group-title', t(titleKey)), ...rows); + return box; + }; + card.append(head, el('p', 'anomalous-scan-setting-help', t('scanSettingsLead')), + part('scanPageHow', toggle('offline', 'scanPageOffline', 'scanPageOfflineHelp', onChange), online), + part('scanPageAfter', toggle('autoFix', 'scanPageAutoFix', 'scanPageAutoFixHelp', onChange)), + part('scanPageAccount', key)); + return card; +} diff --git a/web/modules/ui_settings_page.js b/web/modules/ui_settings_page.js index 1a3b72b..b99704a 100644 --- a/web/modules/ui_settings_page.js +++ b/web/modules/ui_settings_page.js @@ -20,7 +20,7 @@ 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 { saveApiKey } from './ui_scan_settings.js'; import { exportBackup, importBackup } from './ui_backup.js'; import { followsSelection, setFollowsSelection } from './shell_open_rules.js'; import { FOLDER_PRESETS, expandFolder } from './download_places.js'; diff --git a/web/styles.css b/web/styles.css index 071a0b7..162c235 100644 --- a/web/styles.css +++ b/web/styles.css @@ -16,7 +16,7 @@ @import url("./styles/15-shell-rail.css?v=20261007-theme-2"); @import url("./styles/16-activity.css?v=20261007-theme-2"); @import url("./styles/17-model-types.css?v=20261007-theme-2"); -@import url("./styles/18-scan-page.css?v=20261011-pick"); +@import url("./styles/18-scan-page.css?v=20261011-scan"); @import url("./styles/19-model-doctor.css?v=20261007-theme-2"); @import url("./styles/20-current-node.css?v=20261007-theme-2"); @import url("./styles/21-prompt-studio.css?v=20261007-theme-2"); @@ -25,4 +25,4 @@ @import url("./styles/24-backup.css?v=20261007-theme-2"); @import url("./styles/25-model-import.css?v=20261008-import"); @import url("./styles/26-model-tidy.css?v=20261008-tidy"); -@import url("./styles/30-scan-picker.css?v=20261011-pick-2"); +@import url("./styles/30-scan-picker.css?v=20261011-scan"); diff --git a/web/styles/18-scan-page.css b/web/styles/18-scan-page.css index 0bff366..f60ead4 100644 --- a/web/styles/18-scan-page.css +++ b/web/styles/18-scan-page.css @@ -213,8 +213,70 @@ button.anomalous-scan-stat:hover { border-bottom: 1px solid var(--amb-border); } -/* The scan page's settings card: how the next scan goes; it opens the settings page. */ -.anomalous-scan-settings-card { +/* The scan page's settings card: folded, how the next scan goes in short words; unfolded in + place, the switches. A
, so a block (not the cards' flex column). */ +.anomalous-scan-card.anomalous-scan-settings { + display: block; + padding: 0; +} + +.anomalous-scan-settings-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 14px 16px; + border-radius: 12px; + list-style: none; + cursor: pointer; +} + +.anomalous-scan-settings-head::-webkit-details-marker { + display: none; +} + +.anomalous-scan-settings-head:hover { + background: rgba(var(--amb-ink-rgb), 0.04); +} + +.anomalous-scan-settings-head > .anomalous-scan-chips { + flex: 1 1 260px; +} + +.anomalous-scan-settings-change, +.anomalous-scan-settings-fold { + margin-left: auto; + color: var(--amb-link); + font-size: 12.5px; + white-space: nowrap; +} + +.anomalous-scan-settings-fold, +.anomalous-scan-settings[open] .anomalous-scan-settings-change { + display: none; +} + +.anomalous-scan-settings[open] .anomalous-scan-settings-fold { + display: inline; +} + +.anomalous-scan-settings > .anomalous-scan-setting-help { + margin: 0 16px 4px; +} + +.anomalous-scan-settings-part { + margin: 0 16px; + padding: 12px 0; + border-top: 1px solid var(--amb-border); +} + +.anomalous-scan-settings-part:last-child { + padding-bottom: 16px; +} + +/* The components (VAE, text encoders…) under the counts: a card to their list. */ +.anomalous-scan-card.anomalous-scan-parts { + gap: 6px; width: 100%; color: inherit; font: inherit; @@ -223,22 +285,47 @@ button.anomalous-scan-stat:hover { transition: border-color 0.15s ease, background 0.15s ease; } -.anomalous-scan-settings-card:hover { +.anomalous-scan-card.anomalous-scan-parts:hover { border-color: color-mix(in srgb, var(--amb-link) 40%, transparent); background: rgba(var(--amb-ink-rgb), 0.04); } -.anomalous-scan-settings-head { +.anomalous-scan-parts-head { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; } -.anomalous-scan-settings-change { - color: var(--amb-link); +.anomalous-scan-parts-kinds { + color: var(--amb-text-dim); + font-size: 12px; +} + +.anomalous-scan-parts-head > .anomalous-scan-settings-change { + margin-left: auto; +} + +.anomalous-scan-parts-counts { + color: var(--amb-text-main); + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.anomalous-scan-parts-note { + color: var(--amb-text-muted); font-size: 12.5px; - white-space: nowrap; + line-height: 1.55; +} + +/* The scan: its buttons, then the picks or what a scan of everything does. */ +.anomalous-scan-run > .anomalous-scan-actions { + align-items: center; + margin-top: 0; +} + +.anomalous-scan-run > .anomalous-scan-muted { + font-size: 12px; } .anomalous-scan-chips { @@ -329,13 +416,6 @@ button.anomalous-scan-stat:hover { line-height: 1.5; } -.anomalous-scan-pick-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; -} - .anomalous-scan-pick-count { color: var(--amb-text-main); font-size: 14px; diff --git a/web/styles/30-scan-picker.css b/web/styles/30-scan-picker.css index 552fc93..ced20b5 100644 --- a/web/styles/30-scan-picker.css +++ b/web/styles/30-scan-picker.css @@ -262,7 +262,7 @@ cursor: pointer; } -.anomalous-scan-pick-head .anomalous-pick-link { +.anomalous-scan-actions .anomalous-pick-link { margin-left: 0; }