Merge branch 'feature/model-import' into feature/param-compare
# Conflicts: # web/styles.css
This commit is contained in:
+6
-1
@@ -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.
|
||||
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)
|
||||
into each model's `<model>.anomalous.json`.
|
||||
- `api/mcp_server.py` serves `/anomalous/mcp`, the Model Context Protocol endpoint AI apps
|
||||
@@ -381,6 +383,9 @@ covered by the `styles.css` manifest.
|
||||
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,
|
||||
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.
|
||||
`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
|
||||
@@ -456,7 +461,7 @@ covered by the `styles.css` manifest.
|
||||
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
coordinates detail display, `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
|
||||
`model_widget_path.js` puts a model path into a loader's widget. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
- 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 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.
|
||||
|
||||
### ⚙️ Settings in tabs (设置分组)
|
||||
- The settings page is split into tabs: **Look, Models, Workflows, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
|
||||
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
|
||||
|
||||
+1
-1
@@ -38,7 +38,6 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
|
||||
app.router.add_get('/anomalous/type_models', model_type_listing.api_get_type_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', model_catalog.api_batch_select)
|
||||
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
|
||||
app.router.add_get('/anomalous/card_cache', media_routes.api_card_cache)
|
||||
app.router.add_post('/anomalous/card_cache/clear', media_routes.api_clear_card_cache)
|
||||
@@ -113,6 +112,7 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
|
||||
app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
|
||||
app.router.add_get('/anomalous/scan_pickable', scan_summary.api_scan_pickable)
|
||||
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
|
||||
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
|
||||
app.router.add_post('/anomalous/scan_choice', scan_choice.api_set_scan_choice)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Model folder discovery, listing, search, and presentation lookup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
@@ -601,91 +600,3 @@ async def api_get_all_scan_models(request):
|
||||
limit = int(request.query.get('limit', 0))
|
||||
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
async def api_batch_select(request):
|
||||
folder_key = request.query.get('folderKey', 'ALL')
|
||||
action = request.query.get('action', 'all')
|
||||
|
||||
def matches_condition(file_path, root, base_name):
|
||||
if action == 'all':
|
||||
return True
|
||||
elif action == 'no_preview':
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
return False
|
||||
return True
|
||||
elif action == 'no_desc':
|
||||
info_file = file_path + '.info'
|
||||
civitai_info = os.path.join(root, base_name + '.civitai.info')
|
||||
if os.path.exists(civitai_info):
|
||||
try:
|
||||
with open(civitai_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
return True
|
||||
return False
|
||||
|
||||
results = {}
|
||||
|
||||
if folder_key == 'ALL':
|
||||
target_types = get_active_folder_types()
|
||||
seen_dirs = set()
|
||||
for t in target_types:
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: continue
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if is_model_file(f):
|
||||
file_path = os.path.join(root, f)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, root, base_name):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
fkey = f"{t}|{path_idx}|{rel_subfolder}"
|
||||
if fkey not in results: results[fkey] = []
|
||||
results[fkey].append(f)
|
||||
else:
|
||||
parts = folder_key.split('|')
|
||||
if len(parts) >= 3:
|
||||
t = parts[0]
|
||||
path_idx = int(parts[1])
|
||||
subfolder = parts[2]
|
||||
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: paths = []
|
||||
|
||||
if paths and path_idx < len(paths):
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"selected": {}})
|
||||
|
||||
if os.path.exists(target_dir):
|
||||
try: entries = os.listdir(target_dir)
|
||||
except: entries = []
|
||||
for f in entries:
|
||||
if is_model_file(f):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if os.path.isfile(file_path):
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, target_dir, base_name):
|
||||
if folder_key not in results: results[folder_key] = []
|
||||
results[folder_key].append(f)
|
||||
|
||||
return web.json_response({"selected": results})
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ from .model_catalog import (
|
||||
_allowed_folder_types, _collect_all_scan_models, _collect_folder_models,
|
||||
_collect_folders, _find_model_sync, _iter_search_models, _model_info_for_path,
|
||||
_resolve_paths_to_model_info_sync, _resolve_paths_to_previews_sync,
|
||||
api_base_models, api_batch_select, api_compatible_models, api_find_model,
|
||||
api_base_models, api_compatible_models, api_find_model,
|
||||
api_get_all_scan_models, api_get_folders, api_get_models,
|
||||
api_resolve_paths_to_previews,
|
||||
)
|
||||
|
||||
+81
-34
@@ -2,7 +2,8 @@
|
||||
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."""
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -10,6 +11,8 @@ import os
|
||||
from aiohttp import web
|
||||
|
||||
from .folder_types import get_active_scan_paths
|
||||
from .metadata import get_metadata
|
||||
from .model_media import _preview_url_for_model
|
||||
from .scan_report import locate_root
|
||||
|
||||
try:
|
||||
@@ -20,20 +23,18 @@ except ImportError: # loaded outside the package (tests)
|
||||
from model_kind import inspect_file
|
||||
|
||||
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")
|
||||
# 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")
|
||||
|
||||
|
||||
def _summarize():
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
which the next online scan looks up. `skipped`: model files in other formats, outside hidden
|
||||
folders (tools keep their working files there). `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."""
|
||||
def _walk():
|
||||
"""Each model file in the active scan folders once, as ((type, path_idx), base_dir, path,
|
||||
other): the .safetensors files scans read, and (`other`) model files in other formats outside
|
||||
hidden folders (tools keep their working files there)."""
|
||||
seen = set()
|
||||
total = matched = 0
|
||||
unmatched, new, skipped, excluded = [], [], [], []
|
||||
for base_dir in get_active_scan_paths():
|
||||
located = locate_root(base_dir) or ("", 0)
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
@@ -47,30 +48,42 @@ def _summarize():
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
if other:
|
||||
skipped.append({"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")})
|
||||
continue
|
||||
base = os.path.splitext(path)[0]
|
||||
item = {"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
||||
choice = scan_choice(base)
|
||||
if choice == "skip":
|
||||
excluded.append(item)
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(base)
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
continue
|
||||
if info is None:
|
||||
found = inspect_file(path)
|
||||
if not choice and found["format"] == "safetensors" and not found["kind"]:
|
||||
item["not_drawing"] = True
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
yield located, base_dir, path, other
|
||||
|
||||
|
||||
def _summarize():
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
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."""
|
||||
total = matched = 0
|
||||
unmatched, new, skipped, excluded = [], [], [], []
|
||||
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, "/")}
|
||||
if other:
|
||||
skipped.append(item)
|
||||
continue
|
||||
base = os.path.splitext(path)[0]
|
||||
choice = scan_choice(base)
|
||||
if choice == "skip":
|
||||
excluded.append(item)
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(base)
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
continue
|
||||
if info is None:
|
||||
found = inspect_file(path)
|
||||
if not choice and found["format"] == "safetensors" and not found["kind"]:
|
||||
item["not_drawing"] = True
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
return {
|
||||
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
|
||||
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
|
||||
@@ -82,6 +95,40 @@ def _summarize():
|
||||
}
|
||||
|
||||
|
||||
def _pickable():
|
||||
"""Every model a scan reads (the models set to "don't scan" too), located like the summary's,
|
||||
with its size, cover and what its card badge needs; `roots`: the name of each models folder
|
||||
they are in and of the folder holding it, to tell two folders of one type apart."""
|
||||
models, roots, total = [], {}, 0
|
||||
for located, base_dir, path, other in _walk():
|
||||
if other:
|
||||
continue
|
||||
total += 1
|
||||
if len(models) >= MAX_PICKABLE:
|
||||
continue
|
||||
key = f"{located[0]}|{located[1]}"
|
||||
if key not in roots:
|
||||
roots[key] = {"type": located[0], "path_idx": located[1], "name": os.path.basename(os.path.normpath(base_dir)),
|
||||
"parent": os.path.basename(os.path.dirname(os.path.normpath(base_dir)))}
|
||||
meta = get_metadata(path)
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
models.append({
|
||||
"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/"), "size": size,
|
||||
"preview_url": _preview_url_for_model(located[0], located[1], base_dir, path),
|
||||
"metadata": {field: meta.get(field) for field in PICKER_FIELDS},
|
||||
})
|
||||
return {"models": models, "total": total, "roots": list(roots.values())}
|
||||
|
||||
|
||||
async def api_scan_summary(request):
|
||||
"""GET /anomalous/scan_summary - counts, and the unmatched and new models."""
|
||||
return web.json_response(await asyncio.to_thread(_summarize))
|
||||
|
||||
|
||||
async def api_scan_pickable(request):
|
||||
"""GET /anomalous/scan_pickable - the models the scan settings' picker lists."""
|
||||
return web.json_response(await asyncio.to_thread(_pickable))
|
||||
|
||||
@@ -158,6 +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
|
||||
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
|
||||
descriptions. Its picks go to `/anomalous/scan_all` as `targets`, like a list page's.
|
||||
|
||||
## MCP endpoint
|
||||
|
||||
`POST /anomalous/mcp` (`api/mcp_server.py`) answers one JSON-RPC message per POST with
|
||||
|
||||
@@ -72,8 +72,8 @@ The main surfaces are:
|
||||
- Sidebar shell: navigation and persistent scan controls; focused child modules
|
||||
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`, and advanced selection
|
||||
in `ui_model_selector.js`.
|
||||
`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`.
|
||||
- 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
|
||||
|
||||
@@ -159,6 +159,37 @@ class ScanReportTests(unittest.TestCase):
|
||||
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_picker_lists_every_model_a_scan_reads_with_its_badge_fields(self):
|
||||
self.model("new")
|
||||
self.model("found", {**FOUND, "description": "<p>long text</p>"})
|
||||
(self.dir / "found.preview.png").write_bytes(b"png")
|
||||
self.model("skipped")
|
||||
(self.dir / "skipped.anomalous.json").write_text(json.dumps({"format": 1, "scan": "skip"}), encoding="utf-8")
|
||||
(self.dir / "sub").mkdir()
|
||||
self.dir, top = self.dir / "sub", self.dir
|
||||
self.model("deep", {"id": -1, "anomalous_unmatched_reason": "damaged"})
|
||||
self.dir = top
|
||||
(self.dir / "old.ckpt").write_bytes(b"never read")
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[str(self.dir)]), \
|
||||
mock.patch.object(scan_summary, "locate_root", return_value=("loras", 1)):
|
||||
listed = scan_summary._pickable()
|
||||
with mock.patch.object(scan_summary, "MAX_PICKABLE", 2):
|
||||
capped = scan_summary._pickable()
|
||||
models = {item["rel"]: item for item in listed["models"]}
|
||||
self.assertEqual(sorted(models), ["found.safetensors", "new.safetensors", "skipped.safetensors", "sub/deep.safetensors"])
|
||||
self.assertEqual(listed["total"], 4)
|
||||
self.assertEqual(listed["roots"], [{"type": "loras", "path_idx": 1, "name": self.dir.name, "parent": self.dir.parent.name}])
|
||||
found = models["found.safetensors"]
|
||||
self.assertEqual((found["type"], found["path_idx"], found["filename"]), ("loras", 1, "found.safetensors"))
|
||||
self.assertIn("filename=found.preview.png", found["preview_url"])
|
||||
self.assertEqual(found["size"], (self.dir / "found.safetensors").stat().st_size)
|
||||
self.assertEqual(set(found["metadata"]), set(scan_summary.PICKER_FIELDS)) # no description
|
||||
self.assertEqual((found["metadata"]["info_source"], found["metadata"]["baseModel"]), ("civitai", "SDXL 1.0"))
|
||||
self.assertEqual(models["skipped.safetensors"]["metadata"]["scan_choice"], "skip")
|
||||
self.assertEqual(models["sub/deep.safetensors"]["metadata"]["unmatched_reason"], "damaged")
|
||||
self.assertEqual(models["new.safetensors"]["metadata"]["info_source"], "")
|
||||
self.assertEqual((len(capped["models"]), capped["total"]), (2, 4))
|
||||
|
||||
|
||||
class EditDetailTests(unittest.TestCase):
|
||||
def test_editing_a_model_logs_what_changed_from_what(self):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { showDetail } from './ui_detail.js';
|
||||
import { showEditModal } from './ui_model_editor.js';
|
||||
import { _openAdvancedModelSelector, setWidgetValuePath } from './ui_model_selector.js';
|
||||
import { setWidgetValuePath } from './model_widget_path.js';
|
||||
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
|
||||
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
|
||||
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
|
||||
@@ -233,5 +233,4 @@ AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
|
||||
|
||||
AnomalousBrowser.prototype.showDetail = showDetail;
|
||||
AnomalousBrowser.prototype.showEditModal = showEditModal;
|
||||
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
|
||||
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
|
||||
|
||||
+60
-38
@@ -249,7 +249,36 @@ export const i18n = {
|
||||
scanPageScopePicked: "只扫我挑的",
|
||||
scanPageScopeAllHelp: "所有模型文件夹,设置里“文件夹管理”隐藏的除外。",
|
||||
scanPagePickModels: "挑选模型…",
|
||||
scanPagePickedCount: "已选 {count} 个",
|
||||
scanScopeTotal: "共 {count} 个模型",
|
||||
scanScopeLookup: "{count} 个这次会去 Civitai 查:还没扫过的,和上次没连上网的",
|
||||
scanScopeInfer: "{count} 个还没扫过的只从文件推断类型(离线,不联网)",
|
||||
scanScopeNothingNew: "没有还没扫过的模型,这次不会联网查",
|
||||
scanScopeMatched: "{count} 个已经查到,只用已有记录,不再联网",
|
||||
scanScopeNotFound: "{count} 个 Civitai 上没有,不重查(要重查,在“没查到”列表里再联网查一遍)",
|
||||
scanScopeExcluded: "{count} 个设为不扫描,跳过",
|
||||
scanScopeForce: "开着“重新获取”:{count} 个都会重新联网查",
|
||||
scanPickTitle: "挑选要扫描的模型",
|
||||
scanPickSearch: "搜索名字或文件名",
|
||||
scanPickAllFolders: "全部文件夹",
|
||||
scanPickViewList: "列表",
|
||||
scanPickViewCovers: "封面",
|
||||
scanPickStateAll: "全部",
|
||||
scanPickFound: "已查到",
|
||||
scanPickShown: "选中这 {count} 个",
|
||||
scanPickUnpickShown: "取消选中这些",
|
||||
scanPickOnlyPicked: "只看已选",
|
||||
scanPickClear: "清空已选",
|
||||
scanPickChange: "改选…",
|
||||
scanPickRemove: "不扫这个",
|
||||
scanPickMore: "还有 {count} 个",
|
||||
scanPickNone: "还没选模型",
|
||||
scanPickSummary: "已选 {count} 个 · 共 {size}",
|
||||
scanPickRangeHint: "按住 Shift 点击,可以一次选中一段",
|
||||
scanPickLoading: "正在读取模型…",
|
||||
scanPickFailed: "读不到模型列表:{error}",
|
||||
scanPickEmpty: "没有符合条件的模型。",
|
||||
scanPickTruncated: "模型太多,只列出了前 {shown} 个(共 {total} 个)。",
|
||||
scanPickFoundNote: "其中 {count} 个已经查到,这次只用已有记录,不会重新联网;要重新查,打开下面的“重新获取已扫描的模型”。",
|
||||
scanPageNothingPicked: "还没有挑选模型。",
|
||||
scanPageHow: "怎么扫描",
|
||||
scanPageOnline: "联网扫描时",
|
||||
@@ -770,24 +799,6 @@ export const i18n = {
|
||||
detailCancel: '取消',
|
||||
detailSaveChanges: '保存更改',
|
||||
detailSaving: '保存中...',
|
||||
detailSelectModels: '选择模型',
|
||||
detailFolders: '目录列表',
|
||||
detailSelectedItems: '已选 {count} 项',
|
||||
detailSelectedItemsPrefix: '已选 ',
|
||||
detailSelectedItemsSuffix: ' 项',
|
||||
detailNothingSelected: '暂未选择',
|
||||
detailLoadedModels: '已加载 {loaded} / {total} 个模型',
|
||||
detailComputing: '计算中...',
|
||||
detailSelectNoPreview: '选无预览图',
|
||||
detailSelectNoDesc: '选无介绍',
|
||||
detailSelectAll: '全选',
|
||||
detailClear: '清空',
|
||||
detailConfirm: '确定',
|
||||
detailLoading: '加载中 (Loading)...',
|
||||
detailAllLoaded: '✅ 已加载全部',
|
||||
detailScrollMore: '滑动加载更多...',
|
||||
detailNoModels: '没有找到模型。',
|
||||
detailApiRestart: '❌ 请求后端 API 失败!\n\n你必须重启 ComfyUI 进程,刚写入后端的代码才能生效。',
|
||||
doctorGraphUnavailable: '当前画布或节点不可用。',
|
||||
doctorMissingChainInputs: "这个节点没有 MODEL 输入,LoRA 插不到它前面。",
|
||||
doctorUnconnectedChainInputs: "这个节点的 MODEL 输入还没连上。",
|
||||
@@ -2586,7 +2597,36 @@ export const i18n = {
|
||||
scanPageScopePicked: "Only ones I pick",
|
||||
scanPageScopeAllHelp: "Every model folder except those hidden in Settings → Folder manager.",
|
||||
scanPagePickModels: "Pick models…",
|
||||
scanPagePickedCount: "{count} picked",
|
||||
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)",
|
||||
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)",
|
||||
scanScopeExcluded: "{count} set to never be scanned are left out",
|
||||
scanScopeForce: "Fetch scanned models again is on: all {count} are looked up again",
|
||||
scanPickTitle: "Pick models to scan",
|
||||
scanPickSearch: "Search name or file name",
|
||||
scanPickAllFolders: "All folders",
|
||||
scanPickViewList: "List",
|
||||
scanPickViewCovers: "Covers",
|
||||
scanPickStateAll: "All",
|
||||
scanPickFound: "Found",
|
||||
scanPickShown: "Pick these {count}",
|
||||
scanPickUnpickShown: "Unpick these",
|
||||
scanPickOnlyPicked: "Picked only",
|
||||
scanPickClear: "Clear picks",
|
||||
scanPickChange: "Change…",
|
||||
scanPickRemove: "Don't scan this one",
|
||||
scanPickMore: "and {count} more",
|
||||
scanPickNone: "Nothing picked yet",
|
||||
scanPickSummary: "{count} picked · {size} in all",
|
||||
scanPickRangeHint: "Shift-click picks a run at once",
|
||||
scanPickLoading: "Reading the models…",
|
||||
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.",
|
||||
scanPageHow: "How to scan",
|
||||
scanPageOnline: "When scanning online",
|
||||
@@ -3107,24 +3147,6 @@ export const i18n = {
|
||||
detailCancel: 'Cancel',
|
||||
detailSaveChanges: 'Save Changes',
|
||||
detailSaving: 'Saving...',
|
||||
detailSelectModels: 'Select Models',
|
||||
detailFolders: 'Folders',
|
||||
detailSelectedItems: 'Selected {count} items',
|
||||
detailSelectedItemsPrefix: 'Selected ',
|
||||
detailSelectedItemsSuffix: ' items',
|
||||
detailNothingSelected: 'Nothing selected',
|
||||
detailLoadedModels: 'Loaded {loaded} / {total} models',
|
||||
detailComputing: 'Computing...',
|
||||
detailSelectNoPreview: 'Select No Preview',
|
||||
detailSelectNoDesc: 'Select No Desc',
|
||||
detailSelectAll: 'Select All',
|
||||
detailClear: 'Clear',
|
||||
detailConfirm: 'Confirm',
|
||||
detailLoading: 'Loading...',
|
||||
detailAllLoaded: '✅ All models loaded',
|
||||
detailScrollMore: 'Scroll for more...',
|
||||
detailNoModels: 'No models found.',
|
||||
detailApiRestart: '❌ Backend API request failed!\n\nYou must restart the ComfyUI process for the newly written backend code to take effect.',
|
||||
doctorGraphUnavailable: 'The current graph or node is unavailable.',
|
||||
doctorMissingChainInputs: "This node has no MODEL input, so a LoRA cannot go before it.",
|
||||
doctorUnconnectedChainInputs: "This node’s MODEL input is not connected yet.",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Puts a model path into a loader node's model widget: the drop-down's own spelling of it when
|
||||
* the list has it (either slash), else the path as given.
|
||||
*/
|
||||
|
||||
export function setWidgetValuePath(node, relPath) {
|
||||
if (!node.widgets || node.widgets.length === 0) return;
|
||||
const w = node.widgets.find(wg => wg.type === 'combo');
|
||||
const targetWidget = w || node.widgets[0];
|
||||
if (!targetWidget) return;
|
||||
|
||||
if (targetWidget.options && targetWidget.options.values) {
|
||||
const normalizedTarget = relPath.replace(/\\/g, '/');
|
||||
const match = targetWidget.options.values.find(v => {
|
||||
return String(v).replace(/\\/g, '/') === normalizedTarget;
|
||||
});
|
||||
if (match) {
|
||||
targetWidget.value = match;
|
||||
return;
|
||||
}
|
||||
}
|
||||
targetWidget.value = relPath;
|
||||
}
|
||||
@@ -67,18 +67,6 @@ export function targetsForItems(items) {
|
||||
return [...folders.values()];
|
||||
}
|
||||
|
||||
/** The model picker's selection (Map "type|path_idx|subfolder" -> Set of file names) as `targets`. */
|
||||
function targetsForSelection(selection) {
|
||||
const targets = [];
|
||||
for (const [folderKey, files] of selection.entries()) {
|
||||
const [type, pathIdx, ...rest] = folderKey.split('|');
|
||||
if (type && pathIdx !== undefined && rest.length && files.size) {
|
||||
targets.push({ type, path_idx: Number(pathIdx), subfolder: rest.join('|'), files: [...files] });
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -143,17 +131,16 @@ export function showScanResult(owner, result) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one scan from the scan page: every active model folder, `options.selection` (the model
|
||||
* picker) or `options.targets` (listed models). Resolves with the scan's result when it has
|
||||
* ended, or null when it could not start (the reason shown).
|
||||
* Runs one scan from the scan page: every active model folder, or `options.targets` (picked or
|
||||
* listed models, targetsForItems). Resolves with the scan's result when it has ended, or null
|
||||
* when it could not start (the reason shown).
|
||||
*/
|
||||
export async function startScan(owner, options) {
|
||||
if (running) return null;
|
||||
running = true;
|
||||
setActiveScanButtonState(true);
|
||||
const body = scanRequestBody(options);
|
||||
const targets = options.targets || (options.selection ? targetsForSelection(options.selection) : null);
|
||||
if (targets) body.targets = targets;
|
||||
if (options.targets) body.targets = options.targets;
|
||||
try {
|
||||
const data = await postJson('/anomalous/scan_all', body);
|
||||
updateScanProgress({ scanning: true, phase: 'preparing', recovered: data.recovered });
|
||||
|
||||
@@ -1,766 +0,0 @@
|
||||
/**
|
||||
* Advanced model selector and model-widget path helpers.
|
||||
*/
|
||||
|
||||
import { translate } from './locales.js';
|
||||
import { escapeHtml } from './safe_dom.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export function _openAdvancedModelSelector(initialSelectedMap, onConfirm) {
|
||||
let localSelection = new Map();
|
||||
for (const [k, v] of initialSelectedMap.entries()) {
|
||||
localSelection.set(k, new Set(v));
|
||||
}
|
||||
|
||||
const getFolderSelectionCount = (folderKey) => {
|
||||
let nKey = folderKey;
|
||||
const parts = folderKey.split('|');
|
||||
if (parts.length >= 3 && parts[2].startsWith('/')) {
|
||||
parts[2] = parts[2].substring(1);
|
||||
nKey = parts.join('|');
|
||||
}
|
||||
return localSelection.has(nKey) ? localSelection.get(nKey).size : 0;
|
||||
};
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.top = '0';
|
||||
modal.style.left = '0';
|
||||
modal.style.width = '100vw';
|
||||
modal.style.height = '100vh';
|
||||
modal.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
modal.style.zIndex = '9999999';
|
||||
modal.style.display = 'flex';
|
||||
modal.style.justifyContent = 'center';
|
||||
modal.style.alignItems = 'center';
|
||||
modal.style.fontFamily = 'Roboto, Inter, sans-serif';
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.background = 'var(--amb-bg-card)';
|
||||
contentDiv.style.borderRadius = '8px';
|
||||
contentDiv.style.width = '92vw';
|
||||
contentDiv.style.height = '88vh';
|
||||
contentDiv.style.display = 'flex';
|
||||
contentDiv.style.flexDirection = 'column';
|
||||
contentDiv.style.boxShadow = '0 24px 38px 3px rgba(0,0,0,0.4), 0 9px 46px 8px rgba(0,0,0,0.3), 0 11px 15px -7px rgba(0,0,0,0.5)';
|
||||
contentDiv.style.overflow = 'hidden';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.padding = '16px 24px';
|
||||
header.style.borderBottom = '1px solid var(--amb-border-strong)';
|
||||
header.style.display = 'flex';
|
||||
header.style.justifyContent = 'space-between';
|
||||
header.style.alignItems = 'center';
|
||||
|
||||
const titleContainer = document.createElement('div');
|
||||
titleContainer.style.display = 'flex';
|
||||
titleContainer.style.alignItems = 'center';
|
||||
titleContainer.style.gap = '12px';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = t('detailSelectModels');
|
||||
title.style.margin = '0';
|
||||
title.style.color = 'var(--amb-text-main)';
|
||||
title.style.fontSize = '20px';
|
||||
title.style.fontWeight = '500';
|
||||
|
||||
titleContainer.appendChild(title);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.style.background = 'transparent';
|
||||
closeBtn.style.color = 'var(--amb-text-muted)';
|
||||
closeBtn.style.border = 'none';
|
||||
closeBtn.style.fontSize = '18px';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.onclick = () => document.body.removeChild(modal);
|
||||
|
||||
header.appendChild(titleContainer);
|
||||
header.appendChild(closeBtn);
|
||||
contentDiv.appendChild(header);
|
||||
|
||||
// Body area
|
||||
const body = document.createElement('div');
|
||||
body.style.display = 'flex';
|
||||
body.style.flex = '1';
|
||||
body.style.overflow = 'hidden';
|
||||
|
||||
// Left Panel
|
||||
const leftPanel = document.createElement('div');
|
||||
leftPanel.style.width = '280px';
|
||||
leftPanel.style.borderRight = '1px solid var(--amb-border-strong)';
|
||||
leftPanel.style.display = 'flex';
|
||||
leftPanel.style.flexDirection = 'column';
|
||||
leftPanel.style.background = 'var(--amb-bg-panel)';
|
||||
|
||||
const leftTitle = document.createElement('div');
|
||||
leftTitle.textContent = t('detailFolders');
|
||||
leftTitle.style.padding = '16px 24px';
|
||||
leftTitle.style.fontWeight = '500';
|
||||
leftTitle.style.color = 'var(--amb-text-muted)';
|
||||
leftTitle.style.fontSize = '14px';
|
||||
leftTitle.style.letterSpacing = '0.5px';
|
||||
leftPanel.appendChild(leftTitle);
|
||||
|
||||
const treeContainer = document.createElement('div');
|
||||
treeContainer.style.flex = '1';
|
||||
treeContainer.style.overflowY = 'auto';
|
||||
treeContainer.style.padding = '0 12px 12px 12px';
|
||||
leftPanel.appendChild(treeContainer);
|
||||
|
||||
// Right Panel
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.style.flex = '1';
|
||||
rightPanel.style.display = 'flex';
|
||||
rightPanel.style.flexDirection = 'column';
|
||||
rightPanel.style.background = 'var(--amb-bg-card)';
|
||||
|
||||
// Toolbar
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.style.padding = '12px 24px';
|
||||
toolbar.style.borderBottom = '1px solid var(--amb-border-strong)';
|
||||
toolbar.style.display = 'flex';
|
||||
toolbar.style.gap = '12px';
|
||||
toolbar.style.alignItems = 'center';
|
||||
toolbar.style.flexWrap = 'wrap';
|
||||
|
||||
const createTBtn = (text, onClick, primary = false) => {
|
||||
const b = document.createElement('button');
|
||||
b.innerText = text;
|
||||
b.style.padding = '6px 16px';
|
||||
b.style.background = primary ? 'rgba(var(--amb-ink-rgb), 0.12)' : 'rgba(var(--amb-ink-rgb), 0.04)';
|
||||
b.style.color = 'var(--amb-text-main)';
|
||||
b.style.border = primary ? '1px solid rgba(var(--amb-ink-rgb), 0.25)' : '1px solid var(--amb-border-strong)';
|
||||
b.style.borderRadius = '4px';
|
||||
b.style.cursor = 'pointer';
|
||||
b.style.fontSize = '14px';
|
||||
b.style.fontWeight = '500';
|
||||
b.style.transition = 'background-color 0.15s, box-shadow 0.15s';
|
||||
b.onmouseover = () => {
|
||||
b.style.background = primary ? 'rgba(138,180,248,0.25)' : 'rgba(var(--amb-ink-rgb), 0.1)';
|
||||
};
|
||||
b.onmouseout = () => {
|
||||
b.style.background = primary ? 'rgba(138,180,248,0.15)' : 'rgba(var(--amb-ink-rgb), 0.04)';
|
||||
};
|
||||
b.onclick = onClick;
|
||||
return b;
|
||||
};
|
||||
|
||||
let currentModels = [];
|
||||
let currentTotal = 0;
|
||||
let currentFolderKey = 'ALL';
|
||||
let currentBaseUrl = '';
|
||||
let currentPage = 1;
|
||||
let isLoading = false;
|
||||
|
||||
const updateSummaryAndBadges = () => {
|
||||
let total = 0;
|
||||
for (const set of localSelection.values()) total += set.size;
|
||||
|
||||
if (total > 0) {
|
||||
const countLabel = document.createElement('span');
|
||||
countLabel.textContent = total;
|
||||
countLabel.style.color = 'var(--amb-text-main)';
|
||||
countLabel.style.fontSize = '16px';
|
||||
countLabel.style.fontWeight = '500';
|
||||
summaryText.replaceChildren(
|
||||
document.createTextNode(t('detailSelectedItemsPrefix')),
|
||||
countLabel,
|
||||
document.createTextNode(t('detailSelectedItemsSuffix')),
|
||||
);
|
||||
} else {
|
||||
summaryText.textContent = t('detailNothingSelected');
|
||||
}
|
||||
|
||||
treeContainer.querySelectorAll('.folder-item').forEach(fBtn => {
|
||||
const fKey = fBtn.dataset.folderKey;
|
||||
const count = getFolderSelectionCount(fKey);
|
||||
const badge = fBtn.querySelector('.selection-badge');
|
||||
if (badge) {
|
||||
if (count > 0) {
|
||||
badge.innerText = count;
|
||||
badge.style.display = 'inline-flex';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also update total models text in grid
|
||||
const statusDiv = grid.querySelector('.status-text');
|
||||
if (statusDiv && !isLoading) {
|
||||
statusDiv.textContent = t('detailLoadedModels', { loaded: currentModels.length, total: currentTotal });
|
||||
}
|
||||
};
|
||||
|
||||
const updateGridCheckboxes = () => {
|
||||
grid.querySelectorAll('.model-card').forEach(card => {
|
||||
const filename = card.dataset.filename;
|
||||
const mType = card.dataset.mtype;
|
||||
const mPathIdx = card.dataset.mpathidx;
|
||||
const mSubfolder = card.dataset.msubfolder;
|
||||
|
||||
const fKey = `${mType}|${mPathIdx}|${mSubfolder}`;
|
||||
const set = localSelection.get(fKey) || new Set();
|
||||
const isChecked = set.has(filename);
|
||||
|
||||
const cbWrapper = card.querySelector('.cb-wrapper');
|
||||
const cb = card.querySelector('input[type="checkbox"]');
|
||||
cb.checked = isChecked;
|
||||
|
||||
if (isChecked) {
|
||||
|
||||
|
||||
card.style.border = '2px solid var(--amb-text-main)';
|
||||
|
||||
|
||||
card.style.background = 'rgba(var(--amb-ink-rgb), 0.1)';
|
||||
|
||||
|
||||
cbWrapper.style.background = 'var(--amb-text-main)';
|
||||
|
||||
|
||||
cbWrapper.style.border = '2px solid var(--amb-text-main)';
|
||||
|
||||
|
||||
cbWrapper.querySelector('span').style.color = 'var(--amb-bg-card)';
|
||||
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
card.style.border = '1px solid var(--amb-border-strong)';
|
||||
|
||||
|
||||
card.style.background = 'var(--amb-bg-card-hover)';
|
||||
|
||||
|
||||
cbWrapper.style.background = 'var(--amb-bg-sunken)';
|
||||
|
||||
|
||||
cbWrapper.style.border = '2px solid var(--amb-border-strong)';
|
||||
|
||||
|
||||
cbWrapper.querySelector('span').style.color = 'transparent';
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
updateSummaryAndBadges();
|
||||
};
|
||||
|
||||
const handleBatchSelect = async (action) => {
|
||||
if (action === 'none') {
|
||||
if (currentFolderKey === 'ALL') {
|
||||
localSelection.clear();
|
||||
} else {
|
||||
let nCurKey = currentFolderKey;
|
||||
const cParts = currentFolderKey.split('|');
|
||||
if (cParts.length >= 3 && cParts[2].startsWith('/')) {
|
||||
cParts[2] = cParts[2].substring(1);
|
||||
nCurKey = cParts.join('|');
|
||||
}
|
||||
localSelection.delete(nCurKey);
|
||||
}
|
||||
updateGridCheckboxes();
|
||||
return;
|
||||
}
|
||||
|
||||
summaryText.textContent = t('detailComputing');
|
||||
try {
|
||||
const res = await fetch(`/anomalous/batch_select?folderKey=${encodeURIComponent(currentFolderKey)}&action=${action}`);
|
||||
if (!res.ok) throw new Error('API failed');
|
||||
const data = await res.json();
|
||||
|
||||
for (const [fKey, files] of Object.entries(data.selected)) {
|
||||
let normalizedKey = fKey;
|
||||
const parts = fKey.split('|');
|
||||
if (parts.length >= 3 && parts[2].startsWith('/')) {
|
||||
parts[2] = parts[2].substring(1);
|
||||
normalizedKey = parts.join('|');
|
||||
}
|
||||
const set = localSelection.get(normalizedKey) || new Set();
|
||||
files.forEach(f => set.add(f));
|
||||
localSelection.set(normalizedKey, set);
|
||||
}
|
||||
updateGridCheckboxes();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
summaryText.textContent = t('detailUploadError') + e.message;
|
||||
setTimeout(() => updateGridCheckboxes(), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
toolbar.appendChild(createTBtn(t('detailSelectNoPreview'), () => handleBatchSelect('no_preview'), true));
|
||||
toolbar.appendChild(createTBtn(t('detailSelectNoDesc'), () => handleBatchSelect('no_desc'), true));
|
||||
|
||||
const divi = document.createElement('div');
|
||||
divi.style.width = '1px'; divi.style.height = '24px'; divi.style.background = 'var(--amb-bg-card-hover)'; divi.style.margin = '0 8px';
|
||||
toolbar.appendChild(divi);
|
||||
|
||||
toolbar.appendChild(createTBtn(t('detailSelectAll'), () => handleBatchSelect('all')));
|
||||
toolbar.appendChild(createTBtn(t('detailClear'), () => handleBatchSelect('none')));
|
||||
|
||||
const summaryText = document.createElement('div');
|
||||
summaryText.style.marginLeft = 'auto';
|
||||
summaryText.style.color = 'var(--amb-text-muted)';
|
||||
summaryText.style.fontWeight = '400';
|
||||
toolbar.appendChild(summaryText);
|
||||
|
||||
rightPanel.appendChild(toolbar);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.style.flex = '1';
|
||||
grid.style.overflowY = 'auto';
|
||||
grid.style.padding = '24px';
|
||||
grid.style.display = 'grid';
|
||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(180px, 1fr))';
|
||||
grid.style.gridAutoRows = 'max-content';
|
||||
grid.style.gap = '20px';
|
||||
grid.style.alignContent = 'start';
|
||||
grid.style.background = 'var(--amb-bg-panel)';
|
||||
rightPanel.appendChild(grid);
|
||||
|
||||
body.appendChild(leftPanel);
|
||||
body.appendChild(rightPanel);
|
||||
contentDiv.appendChild(body);
|
||||
|
||||
// Footer (Confirm)
|
||||
const footer = document.createElement('div');
|
||||
footer.style.padding = '12px 24px';
|
||||
footer.style.borderTop = '1px solid var(--amb-border-strong)';
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.gap = '12px';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = t('detailCancel');
|
||||
cancelBtn.style.padding = '8px 24px';
|
||||
cancelBtn.style.background = 'transparent';
|
||||
cancelBtn.style.color = 'var(--amb-text-muted)';
|
||||
cancelBtn.style.border = 'none';
|
||||
cancelBtn.style.borderRadius = '4px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.fontWeight = '500';
|
||||
cancelBtn.style.fontSize = '14px';
|
||||
cancelBtn.onmouseover = () => cancelBtn.style.background = 'rgba(var(--amb-ink-rgb), 0.08)';
|
||||
cancelBtn.onmouseout = () => cancelBtn.style.background = 'transparent';
|
||||
cancelBtn.onclick = () => document.body.removeChild(modal);
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = t('detailConfirm');
|
||||
confirmBtn.style.padding = '8px 24px';
|
||||
confirmBtn.style.background = 'var(--amb-btn-primary-bg)';
|
||||
confirmBtn.style.color = 'var(--amb-btn-primary-text)';
|
||||
confirmBtn.style.border = 'none';
|
||||
confirmBtn.style.borderRadius = '4px';
|
||||
confirmBtn.style.cursor = 'pointer';
|
||||
confirmBtn.style.fontWeight = '600';
|
||||
confirmBtn.style.fontSize = '14px';
|
||||
confirmBtn.style.boxShadow = '0 1px 2px 0 rgba(0,0,0,.3), 0 1px 3px 1px rgba(0,0,0,.15)';
|
||||
confirmBtn.onmouseover = () => { confirmBtn.style.background = 'var(--amb-btn-primary-hover)'; confirmBtn.style.boxShadow = '0 1px 3px 0 rgba(0,0,0,.3), 0 4px 8px 3px rgba(0,0,0,.15)'; };
|
||||
confirmBtn.onmouseout = () => { confirmBtn.style.background = 'var(--amb-btn-primary-bg)'; confirmBtn.style.boxShadow = '0 1px 2px 0 rgba(0,0,0,.3), 0 1px 3px 1px rgba(0,0,0,.15)'; };
|
||||
confirmBtn.onclick = () => {
|
||||
onConfirm(localSelection);
|
||||
document.body.removeChild(modal);
|
||||
};
|
||||
|
||||
footer.appendChild(cancelBtn);
|
||||
footer.appendChild(confirmBtn);
|
||||
contentDiv.appendChild(footer);
|
||||
|
||||
modal.appendChild(contentDiv);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Infinite Scroll Logic
|
||||
const renderCards = (modelsToRender) => {
|
||||
const frag = document.createDocumentFragment();
|
||||
modelsToRender.forEach(m => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filename = m.filename;
|
||||
card.dataset.mtype = m.type;
|
||||
card.dataset.mpathidx = m.path_idx;
|
||||
card.dataset.msubfolder = m.subfolder;
|
||||
|
||||
card.style.background = 'var(--amb-bg-card-hover)';
|
||||
card.style.borderRadius = '8px';
|
||||
card.style.overflow = 'hidden';
|
||||
card.style.position = 'relative';
|
||||
card.style.border = '1px solid var(--amb-border-strong)';
|
||||
card.style.display = 'flex';
|
||||
card.style.flexDirection = 'column';
|
||||
card.style.aspectRatio = '2 / 3';
|
||||
card.style.height = 'auto';
|
||||
card.style.cursor = 'pointer';
|
||||
card.style.boxShadow = '0 1px 2px 0 rgba(0,0,0,.3), 0 1px 3px 1px rgba(0,0,0,.15)';
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.style.flex = '1';
|
||||
imgContainer.style.background = 'var(--amb-bg-card)';
|
||||
imgContainer.style.display = 'flex';
|
||||
imgContainer.style.justifyContent = 'center';
|
||||
imgContainer.style.alignItems = 'center';
|
||||
imgContainer.style.overflow = 'hidden';
|
||||
|
||||
if (m.preview_url) {
|
||||
|
||||
|
||||
const isVid = m.preview_url.match(/\.(mp4|webm|mov|avi)(?:&|$)/i);
|
||||
|
||||
|
||||
if (isVid) {
|
||||
|
||||
|
||||
const video = document.createElement('video');
|
||||
|
||||
|
||||
video.src = m.preview_url;
|
||||
|
||||
|
||||
video.style.width = '100%';
|
||||
|
||||
|
||||
video.style.height = '100%';
|
||||
|
||||
|
||||
video.style.objectFit = 'cover';
|
||||
|
||||
|
||||
video.muted = true;
|
||||
|
||||
|
||||
video.loop = true;
|
||||
|
||||
|
||||
video.playsInline = true;
|
||||
|
||||
|
||||
card.addEventListener('mouseenter', () => video.play().catch(e => {}));
|
||||
|
||||
|
||||
card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; });
|
||||
|
||||
|
||||
imgContainer.appendChild(video);
|
||||
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
const img = document.createElement('img');
|
||||
|
||||
|
||||
img.src = m.preview_url;
|
||||
|
||||
|
||||
img.style.width = '100%';
|
||||
|
||||
|
||||
img.style.height = '100%';
|
||||
|
||||
|
||||
img.style.objectFit = 'cover';
|
||||
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
imgContainer.innerHTML = '<span style="display:inline-block;opacity:0.25;"><svg style="width:40px;height:40px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>';
|
||||
}
|
||||
card.appendChild(imgContainer);
|
||||
|
||||
const nameBar = document.createElement('div');
|
||||
nameBar.style.padding = '12px';
|
||||
nameBar.style.background = 'var(--amb-bg-card-hover)';
|
||||
nameBar.style.fontSize = '13px';
|
||||
nameBar.style.color = 'var(--amb-text-main)';
|
||||
nameBar.style.whiteSpace = 'nowrap';
|
||||
nameBar.style.overflow = 'hidden';
|
||||
nameBar.style.textOverflow = 'ellipsis';
|
||||
nameBar.style.borderTop = '1px solid var(--amb-border-strong)';
|
||||
nameBar.innerText = m.filename;
|
||||
card.appendChild(nameBar);
|
||||
|
||||
const cbWrapper = document.createElement('div');
|
||||
cbWrapper.className = 'cb-wrapper';
|
||||
cbWrapper.style.position = 'absolute';
|
||||
cbWrapper.style.top = '8px';
|
||||
cbWrapper.style.right = '8px';
|
||||
cbWrapper.style.width = '20px';
|
||||
cbWrapper.style.height = '20px';
|
||||
cbWrapper.style.borderRadius = '50%';
|
||||
cbWrapper.style.background = 'var(--amb-bg-sunken)';
|
||||
cbWrapper.style.border = '2px solid var(--amb-border-strong)';
|
||||
cbWrapper.style.display = 'flex';
|
||||
cbWrapper.style.justifyContent = 'center';
|
||||
cbWrapper.style.alignItems = 'center';
|
||||
cbWrapper.style.pointerEvents = 'none';
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.style.display = 'none';
|
||||
|
||||
const checkIcon = document.createElement('span');
|
||||
checkIcon.innerHTML = '✓';
|
||||
checkIcon.style.color = 'transparent';
|
||||
checkIcon.style.fontSize = '12px';
|
||||
checkIcon.style.fontWeight = 'bold';
|
||||
cbWrapper.appendChild(checkIcon);
|
||||
cbWrapper.appendChild(cb);
|
||||
card.appendChild(cbWrapper);
|
||||
|
||||
card.onclick = () => {
|
||||
const mKey = `${m.type}|${m.path_idx}|${m.subfolder}`;
|
||||
const cset = localSelection.get(mKey) || new Set();
|
||||
if (!cset.has(m.filename)) {
|
||||
cset.add(m.filename);
|
||||
} else {
|
||||
cset.delete(m.filename);
|
||||
}
|
||||
localSelection.set(mKey, cset);
|
||||
updateGridCheckboxes();
|
||||
};
|
||||
|
||||
frag.appendChild(card);
|
||||
});
|
||||
return frag;
|
||||
};
|
||||
|
||||
const fetchModelsPage = async (isLoadMore = false) => {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
|
||||
const prevStatus = grid.querySelector('.status-text');
|
||||
if (prevStatus) grid.removeChild(prevStatus);
|
||||
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'status-text';
|
||||
loadingIndicator.style.gridColumn = '1 / -1';
|
||||
loadingIndicator.style.padding = '40px';
|
||||
loadingIndicator.style.textAlign = 'center';
|
||||
loadingIndicator.style.color = 'var(--amb-text-muted)';
|
||||
loadingIndicator.style.fontSize = '16px';
|
||||
loadingIndicator.textContent = t('detailLoading');
|
||||
grid.appendChild(loadingIndicator);
|
||||
|
||||
try {
|
||||
const sep = currentBaseUrl.includes('?') ? '&' : '?';
|
||||
const url = `${currentBaseUrl}${sep}page=${currentPage}&limit=50`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const apiError = document.createElement('div');
|
||||
apiError.className = 'status-text';
|
||||
apiError.style.gridColumn = '1 / -1';
|
||||
apiError.style.padding = '60px';
|
||||
apiError.style.textAlign = 'center';
|
||||
apiError.style.color = 'var(--amb-danger-text)';
|
||||
apiError.style.fontSize = '16px';
|
||||
apiError.style.whiteSpace = 'pre-line';
|
||||
apiError.textContent = t('detailApiRestart');
|
||||
grid.replaceChildren(apiError);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
const fetchedModels = data.models || [];
|
||||
currentTotal = data.total || 0;
|
||||
|
||||
if (isLoadMore) {
|
||||
currentModels = currentModels.concat(fetchedModels);
|
||||
} else {
|
||||
currentModels = fetchedModels;
|
||||
// Keep the loading indicator temporarily, we clear grid except it
|
||||
Array.from(grid.children).forEach(c => {
|
||||
if (c !== loadingIndicator) grid.removeChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
grid.removeChild(loadingIndicator);
|
||||
|
||||
if (currentModels.length === 0 && !isLoadMore) {
|
||||
const emptyModels = document.createElement('div');
|
||||
emptyModels.className = 'status-text';
|
||||
emptyModels.style.gridColumn = '1 / -1';
|
||||
emptyModels.style.padding = '60px';
|
||||
emptyModels.style.textAlign = 'center';
|
||||
emptyModels.style.color = 'var(--amb-text-muted)';
|
||||
emptyModels.style.fontSize = '16px';
|
||||
emptyModels.textContent = t('detailNoModels');
|
||||
grid.replaceChildren(emptyModels);
|
||||
} else {
|
||||
grid.appendChild(renderCards(fetchedModels));
|
||||
updateGridCheckboxes();
|
||||
|
||||
// Show status at bottom
|
||||
const statusText = document.createElement('div');
|
||||
statusText.className = 'status-text';
|
||||
statusText.style.gridColumn = '1 / -1';
|
||||
statusText.style.padding = '20px';
|
||||
statusText.style.textAlign = 'center';
|
||||
statusText.style.color = 'var(--amb-text-dim)';
|
||||
statusText.style.fontSize = '14px';
|
||||
if (currentModels.length >= currentTotal) {
|
||||
statusText.textContent = t('detailAllLoaded');
|
||||
} else {
|
||||
statusText.textContent = t('detailScrollMore');
|
||||
}
|
||||
grid.appendChild(statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
grid.removeChild(loadingIndicator);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
grid.onscroll = () => {
|
||||
if (isLoading || currentModels.length >= currentTotal) return;
|
||||
// Near bottom detection
|
||||
if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 150) {
|
||||
currentPage++;
|
||||
fetchModelsPage(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Render Folders
|
||||
const renderFolders = async () => {
|
||||
treeContainer.innerHTML = '';
|
||||
|
||||
const allBtn = document.createElement('div');
|
||||
allBtn.className = 'folder-item';
|
||||
allBtn.dataset.folderKey = 'ALL';
|
||||
allBtn.style.padding = '10px 16px';
|
||||
allBtn.style.cursor = 'pointer';
|
||||
allBtn.style.color = 'var(--amb-text-main)';
|
||||
allBtn.style.fontSize = '14px';
|
||||
allBtn.style.fontWeight = '500';
|
||||
allBtn.style.borderRadius = '0 16px 16px 0';
|
||||
allBtn.style.marginBottom = '8px';
|
||||
allBtn.style.display = 'flex';
|
||||
allBtn.style.justifyContent = 'space-between';
|
||||
allBtn.style.alignItems = 'center';
|
||||
allBtn.textContent = `🌟 ${t('models')}`;
|
||||
|
||||
let loadFolder = async (fBtn, folderKey, fetchUrl) => {
|
||||
treeContainer.querySelectorAll('.folder-item').forEach(d => {
|
||||
d.style.background = 'transparent';
|
||||
d.style.color = 'var(--amb-text-muted)';
|
||||
d.style.fontWeight = '400';
|
||||
});
|
||||
fBtn.style.background = 'rgba(var(--amb-ink-rgb), 0.1)';
|
||||
fBtn.style.color = 'var(--amb-text-main)';
|
||||
fBtn.style.fontWeight = '500';
|
||||
|
||||
currentFolderKey = folderKey;
|
||||
currentBaseUrl = fetchUrl;
|
||||
currentPage = 1;
|
||||
currentModels = [];
|
||||
currentTotal = 0;
|
||||
grid.innerHTML = '';
|
||||
|
||||
await fetchModelsPage(false);
|
||||
};
|
||||
|
||||
allBtn.onclick = () => loadFolder(allBtn, 'ALL', '/anomalous/all_scan_models');
|
||||
treeContainer.appendChild(allBtn);
|
||||
|
||||
if (!this.foldersData) {
|
||||
const res = await fetch('/anomalous/folders');
|
||||
const data = await res.json();
|
||||
this.foldersData = data.folders || [];
|
||||
}
|
||||
this.foldersData.forEach(tData => {
|
||||
const typeItem = document.createElement('div');
|
||||
typeItem.style.marginBottom = '4px';
|
||||
|
||||
const tTitle = document.createElement('div');
|
||||
tTitle.innerText = tData.label;
|
||||
tTitle.style.color = 'var(--amb-text-muted)';
|
||||
tTitle.style.fontWeight = '500';
|
||||
tTitle.style.padding = '8px 16px';
|
||||
tTitle.style.fontSize = '12px';
|
||||
tTitle.style.marginTop = '8px';
|
||||
typeItem.appendChild(tTitle);
|
||||
|
||||
for (const [path, fData] of Object.entries(tData.folders)) {
|
||||
if (fData.model_count === 0 && !fData.has_models) continue;
|
||||
|
||||
const folderKey = `${tData.type}|${tData.path_idx}|${path}`;
|
||||
const fBtn = document.createElement('div');
|
||||
fBtn.className = 'folder-item';
|
||||
fBtn.dataset.folderKey = folderKey;
|
||||
|
||||
const depth = path.split('/').length - 1;
|
||||
fBtn.style.padding = `8px 16px 8px ${16 + depth * 16}px`;
|
||||
fBtn.style.cursor = 'pointer';
|
||||
fBtn.style.color = 'var(--amb-text-muted)';
|
||||
fBtn.style.fontSize = '14px';
|
||||
fBtn.style.borderRadius = '0 16px 16px 0';
|
||||
fBtn.style.display = 'flex';
|
||||
fBtn.style.justifyContent = 'space-between';
|
||||
fBtn.style.alignItems = 'center';
|
||||
|
||||
const leftPart = document.createElement('div');
|
||||
leftPart.style.display = 'flex';
|
||||
leftPart.style.alignItems = 'center';
|
||||
leftPart.style.gap = '8px';
|
||||
leftPart.innerHTML = `<span style="display:inline-flex;align-items:center;color:var(--amb-text-muted)"><svg style="width:14px;height:14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span> <span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:140px;">${escapeHtml(fData.name)}</span> <span style="color:var(--amb-text-dim);font-size:12px">(${escapeHtml(fData.model_count)})</span>`;
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'selection-badge';
|
||||
badge.style.background = 'var(--amb-btn-primary-bg)';
|
||||
badge.style.color = 'var(--amb-btn-primary-text)';
|
||||
badge.style.fontSize = '11px';
|
||||
badge.style.fontWeight = '500';
|
||||
badge.style.padding = '1px 6px';
|
||||
badge.style.borderRadius = '10px';
|
||||
badge.style.display = 'none';
|
||||
|
||||
fBtn.appendChild(leftPart);
|
||||
fBtn.appendChild(badge);
|
||||
|
||||
fBtn.onmouseover = () => { if (currentFolderKey !== folderKey) fBtn.style.background = 'rgba(var(--amb-ink-rgb), 0.04)'; };
|
||||
fBtn.onmouseout = () => { if (currentFolderKey !== folderKey) fBtn.style.background = 'transparent'; };
|
||||
|
||||
fBtn.onclick = () => {
|
||||
const params = new URLSearchParams({ type: tData.type, path_idx: tData.path_idx, subfolder: path });
|
||||
loadFolder(fBtn, folderKey, '/anomalous/models?' + params.toString());
|
||||
};
|
||||
typeItem.appendChild(fBtn);
|
||||
}
|
||||
treeContainer.appendChild(typeItem);
|
||||
});
|
||||
|
||||
allBtn.onclick();
|
||||
};
|
||||
renderFolders();
|
||||
}
|
||||
|
||||
|
||||
export function setWidgetValuePath(node, relPath) {
|
||||
if (!node.widgets || node.widgets.length === 0) return;
|
||||
const w = node.widgets.find(wg => wg.type === 'combo');
|
||||
const targetWidget = w || node.widgets[0];
|
||||
if (!targetWidget) return;
|
||||
|
||||
if (targetWidget.options && targetWidget.options.values) {
|
||||
const normalizedTarget = relPath.replace(/\\/g, '/');
|
||||
const match = targetWidget.options.values.find(v => {
|
||||
return String(v).replace(/\\/g, '/') === normalizedTarget;
|
||||
});
|
||||
if (match) {
|
||||
targetWidget.value = match;
|
||||
return;
|
||||
}
|
||||
}
|
||||
targetWidget.value = relPath;
|
||||
}
|
||||
+79
-27
@@ -3,7 +3,8 @@
|
||||
* (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. New models whose header looks like no drawing model are asked about
|
||||
* 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.
|
||||
* Scans run through scan_runner.js; the progress box is scan_progress.js's panel,
|
||||
* hosted here while the page is shown.
|
||||
@@ -13,17 +14,19 @@ import { translate as t } from './locales.js';
|
||||
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';
|
||||
|
||||
// Choices kept while the browser is open. The two risky ones go back off after each scan.
|
||||
const options = {
|
||||
scope: 'all', // 'all' | 'picked'
|
||||
selection: new Map(),
|
||||
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.
|
||||
@@ -43,12 +46,6 @@ function button(className, label, onClick) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function pickedCount() {
|
||||
let count = 0;
|
||||
for (const files of options.selection.values()) count += files.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
@@ -99,7 +96,7 @@ function group(title, ...children) {
|
||||
function currentChoices(hasKey) {
|
||||
const online = !options.offline;
|
||||
return [
|
||||
[options.scope === 'picked' ? t('scanChipPicked', { count: pickedCount() }) : t('scanPageScopeAll')],
|
||||
[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],
|
||||
@@ -120,8 +117,73 @@ function settingsCard(owner, panel, hasKey) {
|
||||
return card;
|
||||
}
|
||||
|
||||
/** What a scan of every model does this time, in the summary's counts and the current settings. */
|
||||
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 - pending],
|
||||
['scanScopeExcluded', summary.excluded],
|
||||
];
|
||||
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() })));
|
||||
}
|
||||
box.append(el('strong', 'anomalous-scan-scope-total', t('scanScopeTotal', { count: summary.total.toLocaleString() })), 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. */
|
||||
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;
|
||||
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');
|
||||
chip.title = item.rel;
|
||||
const remove = button('anomalous-scan-pick-remove', '✕', () => {
|
||||
options.picked = options.picked.filter(each => each !== item);
|
||||
refresh();
|
||||
});
|
||||
remove.title = t('scanPickRemove');
|
||||
chip.append(el('span', '', item.name), remove);
|
||||
names.append(chip);
|
||||
}
|
||||
if (options.picked.length > SHOWN_PICKS) names.append(el('span', 'anomalous-scan-pick-more', t('scanPickMore', { count: options.picked.length - SHOWN_PICKS })));
|
||||
box.append(names);
|
||||
// Found ones are read from their record again, not looked up, unless fetching again is on.
|
||||
if (summary && !(options.forceOverwrite && !options.offline)) {
|
||||
const open = new Set([...summary.new_models, ...summary.unmatched_models, ...summary.excluded_models].map(pickKey));
|
||||
const found = options.picked.filter(item => !open.has(pickKey(item))).length;
|
||||
if (found) box.append(el('small', 'anomalous-scan-setting-help', t('scanPickFoundNote', { count: found })));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** The settings as a page of their own, like the lists: back, then one card per question. */
|
||||
function settingsPage(owner, panel, hasKey, progressHost) {
|
||||
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, '')),
|
||||
@@ -139,17 +201,7 @@ function settingsPage(owner, panel, hasKey, progressHost) {
|
||||
choice.setAttribute('aria-checked', String(options.scope === value));
|
||||
segment.append(choice);
|
||||
}
|
||||
let scopeNote = el('small', 'anomalous-scan-setting-help', t('scanPageScopeAllHelp'));
|
||||
if (options.scope === 'picked') {
|
||||
scopeNote = el('div', 'anomalous-scan-pick');
|
||||
scopeNote.append(
|
||||
button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPagePickModels'), () => owner._openAdvancedModelSelector(options.selection, (next) => {
|
||||
options.selection = next;
|
||||
refresh();
|
||||
})),
|
||||
el('span', 'anomalous-scan-muted', t('scanPagePickedCount', { count: pickedCount() })),
|
||||
);
|
||||
}
|
||||
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' : ''}`);
|
||||
@@ -157,7 +209,7 @@ function settingsPage(owner, panel, hasKey, progressHost) {
|
||||
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' }),
|
||||
toggle('forceOverwrite', 'scanPageForce', 'scanPageForceHelp', { tag: 'scanPageTagSlow', onChange: refresh }),
|
||||
);
|
||||
|
||||
const key = el('div', 'anomalous-scan-setting');
|
||||
@@ -202,13 +254,13 @@ export async function saveApiKey() {
|
||||
* { targets, retryUnmatched } for the models of a list row or a whole list. */
|
||||
async function run(owner, panel, extra = {}) {
|
||||
const whole = !extra.retryUnmatched && !extra.targets;
|
||||
if (whole && options.scope === 'picked' && pickedCount() === 0) {
|
||||
if (whole && options.scope === 'picked' && !options.picked.length) {
|
||||
alert(t('scanPageNothingPicked'));
|
||||
return;
|
||||
}
|
||||
if (whole && options.forceOverwrite && !confirm(t('scanPageForceConfirm'))) return;
|
||||
const selection = whole && options.scope === 'picked' ? options.selection : null;
|
||||
const started = startScan(owner, { ...options, ...extra, selection });
|
||||
const targets = whole && options.scope === 'picked' ? targetsForItems(options.picked) : extra.targets;
|
||||
const started = startScan(owner, { ...options, ...extra, targets });
|
||||
renderScanPage(owner, panel); // buttons show "scanning"
|
||||
await started;
|
||||
if (whole) {
|
||||
@@ -301,7 +353,7 @@ function mainPage(owner, panel, { summary, config, last }, busy, progressHost) {
|
||||
// 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: pickedCount() })
|
||||
: 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));
|
||||
@@ -323,7 +375,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, Boolean(data.config?.has_api_key), progressHost)
|
||||
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)
|
||||
: mainPage(owner, panel, data, busy, progressHost);
|
||||
panel.replaceChildren(page);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* The scan settings' model picker (GET /anomalous/scan_pickable): every model a scan reads, as
|
||||
* a list or as covers, each with how the last scan left it (the scan page's words: not scanned
|
||||
* yet, not found, damaged, found, set to "don't scan"), its folder, base model and size. The
|
||||
* folders on the left, the state chips, the search and "picked only" narrow what is shown;
|
||||
* "pick these" picks all of it, a click one model, a shift-click the run since the last click.
|
||||
* OK hands the picks to the settings page as items { type, path_idx, rel, filename, name, size,
|
||||
* state }; Cancel, ✕, Esc and the backdrop leave them as they were. Long lists are drawn a
|
||||
* screenful at a time.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { createViewScope } from './ui_lifecycle.js';
|
||||
import { typeLabel } from './ui_model_types.js';
|
||||
import { formatSize } from './ui_model_download.js';
|
||||
|
||||
const STATES = ['new', 'unmatched', 'damaged', 'matched', 'skip'];
|
||||
const STATE_KEYS = {
|
||||
new: 'scanPageNew', unmatched: 'scanPageUnmatched', damaged: 'modelSourceDamagedShort',
|
||||
matched: 'scanPickFound', skip: 'modelSourceSkipped',
|
||||
};
|
||||
const STEP = 60; // rows drawn at a time
|
||||
const VIEW_KEY = 'anomalous_scan_picker_view';
|
||||
|
||||
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 stored() {
|
||||
try {
|
||||
return localStorage.getItem(VIEW_KEY) === 'covers' ? 'covers' : 'list';
|
||||
} catch {
|
||||
return 'list';
|
||||
}
|
||||
}
|
||||
|
||||
function store(view) {
|
||||
try {
|
||||
localStorage.setItem(VIEW_KEY, view);
|
||||
} catch { /* the view is only a convenience */ }
|
||||
}
|
||||
|
||||
/** How the last scan left a model, from the metadata its card badge reads too. */
|
||||
export function scanState(meta) {
|
||||
if (meta.scan_choice === 'skip') return 'skip';
|
||||
if (meta.info_source === 'civitai') return 'matched';
|
||||
if (meta.info_source === 'local') return meta.unmatched_reason === 'damaged' ? 'damaged' : 'unmatched';
|
||||
return 'new';
|
||||
}
|
||||
|
||||
export const pickKey = item => `${item.type}|${item.path_idx}|${item.rel}`;
|
||||
|
||||
/** The picks' count and size, as the settings page and the picker's footer say them. */
|
||||
export function picksLine(items) {
|
||||
if (!items.length) return t('scanPickNone');
|
||||
return t('scanPickSummary', { count: items.length, size: formatSize(items.reduce((sum, item) => sum + (item.size || 0), 0)) });
|
||||
}
|
||||
|
||||
/** The listed models as rows to show: their key, state, folder and what a search matches. */
|
||||
function readModels(data) {
|
||||
const roots = new Map((data.roots || []).map(root => [`${root.type}|${root.path_idx}`, root]));
|
||||
const perType = new Map();
|
||||
for (const root of roots.values()) perType.set(root.type, (perType.get(root.type) || 0) + 1);
|
||||
const rootLabel = (root) => {
|
||||
const label = typeLabel({ type: root.type });
|
||||
return perType.get(root.type) > 1 ? `${label} · ${root.parent}/${root.name}` : label;
|
||||
};
|
||||
const models = (data.models || []).map((model) => {
|
||||
const meta = model.metadata || {};
|
||||
const cut = model.rel.lastIndexOf('/');
|
||||
const root = `${model.type}|${model.path_idx}`;
|
||||
const name = meta.custom_name || model.filename.replace(/\.safetensors$/i, '');
|
||||
return {
|
||||
...model, meta, name, root, dir: cut < 0 ? '' : model.rel.slice(0, cut),
|
||||
key: pickKey(model), state: scanState(meta),
|
||||
base: meta.baseModel || (meta.base_guess ? `≈ ${meta.base_guess}` : ''),
|
||||
text: `${name}\n${model.rel}`.toLowerCase(),
|
||||
};
|
||||
});
|
||||
return { models, roots, rootLabel };
|
||||
}
|
||||
|
||||
/** The item kept for a picked model (what the settings page lists and the scan needs). */
|
||||
const asItem = model => ({
|
||||
type: model.type, path_idx: model.path_idx, rel: model.rel, filename: model.filename,
|
||||
name: model.name, size: model.size, state: model.state,
|
||||
});
|
||||
|
||||
/** Opens the picker over the page with `picked` (items) picked; `onConfirm(items)` on OK. */
|
||||
export function openScanPicker(picked, onConfirm) {
|
||||
const scope = createViewScope();
|
||||
const picks = new Map(picked.map(item => [pickKey(item), item]));
|
||||
const filter = { folder: '', state: '', query: '', onlyPicked: false };
|
||||
let view = stored();
|
||||
let all = [];
|
||||
let roots = new Map();
|
||||
let rootLabel = () => '';
|
||||
let shown = [];
|
||||
let drawn = 0;
|
||||
let lastClicked = -1;
|
||||
|
||||
const overlay = el('div', 'anomalous-pick-overlay');
|
||||
const dialog = el('div', 'anomalous-pick-dialog');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
const close = () => scope.dispose();
|
||||
|
||||
const head = el('div', 'anomalous-pick-head');
|
||||
const search = el('input', 'anomalous-pick-search');
|
||||
search.type = 'search';
|
||||
search.placeholder = t('scanPickSearch');
|
||||
const views = el('div', 'anomalous-pick-views');
|
||||
head.append(el('h2', 'anomalous-pick-title', t('scanPickTitle')), search, views, button('anomalous-pick-close', '✕', close));
|
||||
|
||||
const folders = el('nav', 'anomalous-pick-folders');
|
||||
const chips = el('div', 'anomalous-pick-chips');
|
||||
const bar = el('div', 'anomalous-pick-bar');
|
||||
const list = el('div', 'anomalous-pick-list');
|
||||
const main = el('div', 'anomalous-pick-main');
|
||||
main.append(chips, bar, list);
|
||||
const body = el('div', 'anomalous-pick-body');
|
||||
body.append(folders, main);
|
||||
|
||||
const summary = el('span', 'anomalous-pick-summary');
|
||||
const foot = el('div', 'anomalous-pick-foot');
|
||||
foot.append(summary, el('span', 'anomalous-pick-hint', t('scanPickRangeHint')),
|
||||
button('anomalous-scan-secondary anomalous-scan-small-btn', t('dialogCancel'), close),
|
||||
button('anomalous-scan-primary anomalous-scan-small-btn', t('dialogOk'), () => {
|
||||
onConfirm([...picks.values()]);
|
||||
close();
|
||||
}));
|
||||
dialog.append(head, body, foot);
|
||||
overlay.append(dialog);
|
||||
document.body.append(overlay);
|
||||
scope.onDispose(() => overlay.remove());
|
||||
scope.listen(overlay, 'mousedown', (event) => { if (event.target === overlay) close(); });
|
||||
scope.listen(window, 'keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.stopPropagation(); // the scan page behind stays open
|
||||
close();
|
||||
}, true);
|
||||
|
||||
// More rows when the end of the list comes near.
|
||||
const more = el('div', 'anomalous-pick-more');
|
||||
const watcher = new IntersectionObserver((entries) => {
|
||||
if (entries.some(entry => entry.isIntersecting)) drawMore();
|
||||
}, { root: list, rootMargin: '300px' });
|
||||
scope.onDispose(() => watcher.disconnect());
|
||||
|
||||
// A folder: '' all, "type|path_idx" a models folder, "type|path_idx|dir" a folder in it (subfolders too).
|
||||
const inFolder = (model, folder = filter.folder) => !folder || folder === model.root
|
||||
|| (folder.startsWith(`${model.root}|`) && `${model.dir}/`.startsWith(`${folder.slice(model.root.length + 1)}/`));
|
||||
const matches = model => (!filter.query || model.text.includes(filter.query)) && (!filter.onlyPicked || picks.has(model.key));
|
||||
|
||||
function paintSummary() {
|
||||
summary.textContent = picksLine([...picks.values()]);
|
||||
}
|
||||
|
||||
function paintViews() {
|
||||
views.replaceChildren(...[['list', 'scanPickViewList'], ['covers', 'scanPickViewCovers']].map(([value, key]) => {
|
||||
const node = button(`anomalous-pick-view${view === value ? ' is-on' : ''}`, t(key), () => {
|
||||
view = value;
|
||||
store(view);
|
||||
paintViews();
|
||||
paintList();
|
||||
});
|
||||
node.setAttribute('aria-pressed', String(view === value));
|
||||
return node;
|
||||
}));
|
||||
}
|
||||
|
||||
function paintFolders() {
|
||||
const rows = [['', t('scanPickAllFolders'), 0, all]];
|
||||
for (const [root, info] of roots) {
|
||||
const inRoot = all.filter(model => model.root === root);
|
||||
if (!inRoot.length) continue;
|
||||
rows.push([root, rootLabel(info), 0, inRoot]);
|
||||
const dirs = [...new Set(inRoot.flatMap(model => model.dir ? model.dir.split('/').map((_, i, parts) => parts.slice(0, i + 1).join('/')) : []))].sort();
|
||||
for (const dir of dirs) {
|
||||
const key = `${root}|${dir}`;
|
||||
rows.push([key, dir.split('/').pop(), dir.split('/').length, inRoot.filter(model => inFolder(model, key))]);
|
||||
}
|
||||
}
|
||||
folders.replaceChildren(...rows.map(([key, label, depth, models]) => {
|
||||
const row = button(`anomalous-pick-folder${key === filter.folder ? ' is-on' : ''}${depth ? '' : ' is-root'}`, undefined, () => {
|
||||
filter.folder = key;
|
||||
lastClicked = -1;
|
||||
paintFolders();
|
||||
paintChips();
|
||||
paintList();
|
||||
});
|
||||
row.style.paddingLeft = `${10 + depth * 14}px`;
|
||||
const count = models.filter(model => picks.has(model.key)).length;
|
||||
row.append(el('span', 'anomalous-pick-folder-name', label), el('span', 'anomalous-pick-folder-count', String(models.length)));
|
||||
if (count) row.append(el('span', 'anomalous-pick-folder-picked', String(count)));
|
||||
row.title = label;
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function paintChips() {
|
||||
const here = all.filter(model => inFolder(model) && matches(model));
|
||||
const counts = Object.fromEntries(STATES.map(state => [state, here.filter(model => model.state === state).length]));
|
||||
const chip = (state, label, count) => {
|
||||
const node = button(`anomalous-pick-chip${filter.state === state ? ' is-on' : ''}${state ? ` is-${state}` : ''}`, undefined, () => {
|
||||
filter.state = state;
|
||||
lastClicked = -1;
|
||||
paintChips();
|
||||
paintList();
|
||||
});
|
||||
node.append(el('span', '', label), el('strong', '', String(count)));
|
||||
node.setAttribute('aria-pressed', String(filter.state === state));
|
||||
return node;
|
||||
};
|
||||
chips.replaceChildren(chip('', t('scanPickStateAll'), here.length),
|
||||
...STATES.filter(state => counts[state] || filter.state === state).map(state => chip(state, t(STATE_KEYS[state]), counts[state])));
|
||||
}
|
||||
|
||||
function paintBar() {
|
||||
const fresh = shown.filter(model => !picks.has(model.key)).length;
|
||||
const pick = button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPickShown', { count: shown.length }), () => {
|
||||
for (const model of shown) picks.set(model.key, asItem(model));
|
||||
refresh();
|
||||
});
|
||||
pick.disabled = !fresh;
|
||||
const unpick = button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPickUnpickShown'), () => {
|
||||
for (const model of shown) picks.delete(model.key);
|
||||
refresh();
|
||||
});
|
||||
unpick.disabled = fresh === shown.length;
|
||||
const only = el('label', 'anomalous-pick-only');
|
||||
const box = el('input');
|
||||
box.type = 'checkbox';
|
||||
box.checked = filter.onlyPicked;
|
||||
box.onchange = () => {
|
||||
filter.onlyPicked = box.checked;
|
||||
refresh();
|
||||
};
|
||||
only.append(box, el('span', '', t('scanPickOnlyPicked')));
|
||||
const clear = button('anomalous-pick-link', t('scanPickClear'), () => {
|
||||
picks.clear();
|
||||
refresh();
|
||||
});
|
||||
clear.disabled = !picks.size;
|
||||
bar.replaceChildren(pick, unpick, only, clear);
|
||||
}
|
||||
|
||||
function paintList() {
|
||||
shown = all.filter(model => inFolder(model) && matches(model) && (!filter.state || model.state === filter.state));
|
||||
drawn = 0;
|
||||
list.className = `anomalous-pick-list is-${view}`;
|
||||
list.replaceChildren();
|
||||
if (!shown.length) list.append(el('p', 'anomalous-pick-empty', t('scanPickEmpty')));
|
||||
drawMore();
|
||||
paintBar();
|
||||
}
|
||||
|
||||
function drawMore() {
|
||||
if (drawn >= shown.length) return;
|
||||
more.remove();
|
||||
const end = Math.min(shown.length, drawn + STEP);
|
||||
for (let index = drawn; index < end; index += 1) list.append(row(shown[index], index));
|
||||
drawn = end;
|
||||
if (drawn < shown.length) {
|
||||
list.append(more);
|
||||
watcher.observe(more);
|
||||
}
|
||||
}
|
||||
|
||||
/** One model: a click picks or unpicks it, a shift-click the models since the last click as well. */
|
||||
function row(model, index) {
|
||||
const node = button(`anomalous-pick-row${picks.has(model.key) ? ' is-picked' : ''}`, undefined, (event) => {
|
||||
const pick = !picks.has(model.key);
|
||||
const from = event.shiftKey && lastClicked >= 0 ? Math.min(lastClicked, index) : index;
|
||||
const to = event.shiftKey && lastClicked >= 0 ? Math.max(lastClicked, index) : index;
|
||||
for (const each of shown.slice(from, to + 1)) {
|
||||
if (pick) picks.set(each.key, asItem(each));
|
||||
else picks.delete(each.key);
|
||||
}
|
||||
lastClicked = index;
|
||||
if (filter.onlyPicked && !pick) refresh();
|
||||
else {
|
||||
for (const each of list.querySelectorAll('.anomalous-pick-row')) each.classList.toggle('is-picked', picks.has(each.dataset.key));
|
||||
paintFolders();
|
||||
paintBar();
|
||||
paintSummary();
|
||||
}
|
||||
});
|
||||
node.dataset.key = model.key;
|
||||
node.setAttribute('role', 'checkbox');
|
||||
node.setAttribute('aria-checked', String(picks.has(model.key)));
|
||||
const cover = el('span', 'anomalous-pick-cover');
|
||||
if (model.preview_url) {
|
||||
const img = el('img');
|
||||
img.loading = 'lazy';
|
||||
img.alt = '';
|
||||
img.src = `${model.preview_url}&variant=${/\.(mp4|webm|mov)(?:&|$)/i.test(model.preview_url) ? 'poster' : 'card'}`;
|
||||
img.onerror = () => img.remove();
|
||||
cover.append(img);
|
||||
}
|
||||
const names = el('span', 'anomalous-pick-names');
|
||||
names.append(el('strong', 'anomalous-pick-name', model.name), el('small', 'anomalous-pick-path', `${rootLabel(roots.get(model.root) || model)} / ${model.rel}`));
|
||||
const facts = el('span', 'anomalous-pick-facts');
|
||||
facts.append(el('span', `anomalous-pick-state is-${model.state}`, t(STATE_KEYS[model.state])));
|
||||
if (model.base) facts.append(el('span', 'anomalous-pick-base', model.base));
|
||||
facts.append(el('span', 'anomalous-pick-size', formatSize(model.size)));
|
||||
node.append(el('span', 'anomalous-pick-check', '✓'), cover, names, facts);
|
||||
node.title = model.state === 'skip' ? `${model.rel}\n${t('modelSourceSkippedHint')}` : model.rel;
|
||||
return node;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
paintFolders();
|
||||
paintChips();
|
||||
paintList();
|
||||
paintSummary();
|
||||
}
|
||||
|
||||
scope.listen(search, 'input', (event) => {
|
||||
if (event.isComposing) return;
|
||||
filter.query = search.value.trim().toLowerCase();
|
||||
lastClicked = -1;
|
||||
refresh();
|
||||
});
|
||||
scope.listen(search, 'compositionend', () => search.dispatchEvent(new Event('input')));
|
||||
|
||||
paintViews();
|
||||
paintSummary();
|
||||
list.append(el('p', 'anomalous-pick-empty', t('scanPickLoading')));
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/anomalous/scan_pickable', { signal: scope.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (scope.signal.aborted) return;
|
||||
({ models: all, roots, rootLabel } = readModels(data));
|
||||
if (data.total > all.length) main.insertBefore(el('p', 'anomalous-pick-note', t('scanPickTruncated', { shown: all.length, total: data.total })), list);
|
||||
refresh();
|
||||
search.focus();
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
list.replaceChildren(el('p', 'anomalous-pick-empty', t('scanPickFailed', { error: String(error.message || error) })));
|
||||
}
|
||||
})();
|
||||
}
|
||||
+2
-1
@@ -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=20261007-theme-2");
|
||||
@import url("./styles/18-scan-page.css?v=20261011-pick");
|
||||
@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");
|
||||
@@ -28,3 +28,4 @@
|
||||
@import url("./styles/27-param-compare.css?v=20261010-compare-8");
|
||||
@import url("./styles/28-workflows.css?v=20261010-wf-2");
|
||||
@import url("./styles/29-workflow-detail.css?v=20261011-wfd-3");
|
||||
@import url("./styles/30-scan-picker.css?v=20261011-pick");
|
||||
|
||||
@@ -304,16 +304,91 @@ button.anomalous-scan-stat:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-scan-group > .anomalous-scan-setting-help {
|
||||
margin-top: 8px;
|
||||
/* Under "which models": what a scan of all of them does, or the picks. */
|
||||
.anomalous-scan-scope-facts,
|
||||
.anomalous-scan-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick {
|
||||
.anomalous-scan-scope-total {
|
||||
color: var(--amb-text-main);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.anomalous-scan-scope-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-count {
|
||||
color: var(--amb-text-main);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-names {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 260px;
|
||||
padding: 3px 4px 3px 10px;
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.04);
|
||||
color: var(--amb-text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-name > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-remove {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-remove:hover {
|
||||
background: rgba(var(--amb-ink-rgb), 0.1);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-more {
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* A setting: what it does on the left, its switch or button on the right. */
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/* The scan settings' model picker (ui_scan_picker.js): folders on the left, then the state
|
||||
chips, what to do with the shown models, and the models as a list or as covers. */
|
||||
|
||||
.anomalous-pick-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--amb-lt-scrim, rgba(0, 0, 0, 0.6));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-pick-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1180px, 100%);
|
||||
height: min(880px, 100%);
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--amb-bg-panel, var(--amb-bg-card));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
color: var(--amb-text-main);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-pick-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--amb-border);
|
||||
}
|
||||
|
||||
.anomalous-pick-title {
|
||||
margin: 0 8px 0 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-pick-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 380px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: 9px;
|
||||
background: var(--amb-bg-input);
|
||||
color: var(--amb-text-main);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.anomalous-pick-search:focus {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 55%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-pick-views {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 9px;
|
||||
background: var(--amb-bg-sunken);
|
||||
}
|
||||
|
||||
.anomalous-pick-view {
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-pick-view.is-on {
|
||||
background: color-mix(in srgb, var(--amb-link) 18%, transparent);
|
||||
color: var(--amb-text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-pick-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-pick-close:hover {
|
||||
background: rgba(var(--amb-ink-rgb), 0.08);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-pick-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Folders: each with its model count, and how many are picked in it. */
|
||||
.anomalous-pick-folders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex: 0 0 230px;
|
||||
padding: 10px 8px;
|
||||
border-right: 1px solid var(--amb-border);
|
||||
background: var(--amb-bg-sunken);
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-pick-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-pick-folder.is-root {
|
||||
margin-top: 8px;
|
||||
color: var(--amb-text-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-pick-folder:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.anomalous-pick-folder:hover {
|
||||
background: rgba(var(--amb-ink-rgb), 0.05);
|
||||
}
|
||||
|
||||
.anomalous-pick-folder.is-on {
|
||||
background: color-mix(in srgb, var(--amb-link) 16%, transparent);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-pick-folder-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-pick-folder-count {
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.anomalous-pick-folder-picked {
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--amb-btn-primary-bg);
|
||||
color: var(--amb-btn-primary-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-pick-main {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-pick-note {
|
||||
margin: 10px 16px 0;
|
||||
color: var(--amb-warn-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* The states, each with how many of the shown models have it. */
|
||||
.anomalous-pick-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.anomalous-pick-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-pick-chip strong {
|
||||
color: var(--amb-text-main);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.anomalous-pick-chip:hover {
|
||||
background: rgba(var(--amb-ink-rgb), 0.05);
|
||||
}
|
||||
|
||||
.anomalous-pick-chip.is-on {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-link) 16%, transparent);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-pick-chip.is-new strong { color: var(--amb-link); }
|
||||
.anomalous-pick-chip.is-damaged strong { color: var(--amb-danger-text); }
|
||||
.anomalous-pick-chip.is-matched strong { color: var(--amb-ok-text); }
|
||||
|
||||
.anomalous-pick-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--amb-border);
|
||||
}
|
||||
|
||||
.anomalous-pick-only {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 6px;
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-pick-link {
|
||||
margin-left: auto;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--amb-link);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-scan-pick-head .anomalous-pick-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.anomalous-pick-link:disabled {
|
||||
color: var(--amb-text-dim);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.anomalous-pick-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 8px 12px 16px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-pick-list.is-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.anomalous-pick-list.is-covers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.anomalous-pick-empty {
|
||||
grid-column: 1 / -1;
|
||||
margin: 40px 0;
|
||||
color: var(--amb-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.anomalous-pick-more {
|
||||
grid-column: 1 / -1;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* One model: its check, cover, name and where it is, then its state, base model and size. */
|
||||
.anomalous-pick-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 40px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.anomalous-pick-row:hover {
|
||||
background: rgba(var(--amb-ink-rgb), 0.05);
|
||||
}
|
||||
|
||||
.anomalous-pick-row.is-picked {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-link) 10%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-pick-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--amb-border-strong);
|
||||
border-radius: 50%;
|
||||
color: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-pick-row.is-picked .anomalous-pick-check {
|
||||
border-color: var(--amb-link);
|
||||
background: var(--amb-link);
|
||||
color: var(--amb-bg-panel, #000);
|
||||
}
|
||||
|
||||
.anomalous-pick-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 7px;
|
||||
background: var(--amb-bg-sunken);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anomalous-pick-cover img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.anomalous-pick-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-pick-name,
|
||||
.anomalous-pick-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-pick-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-pick-path {
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.anomalous-pick-facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.anomalous-pick-state {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.06);
|
||||
color: var(--amb-text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-pick-state.is-new {
|
||||
background: color-mix(in srgb, var(--amb-link) 16%, transparent);
|
||||
color: var(--amb-link);
|
||||
}
|
||||
|
||||
.anomalous-pick-state.is-damaged {
|
||||
background: color-mix(in srgb, var(--amb-danger-text) 16%, transparent);
|
||||
color: var(--amb-danger-text);
|
||||
}
|
||||
|
||||
.anomalous-pick-state.is-matched {
|
||||
background: color-mix(in srgb, var(--amb-ok-text) 14%, transparent);
|
||||
color: var(--amb-ok-text);
|
||||
}
|
||||
|
||||
.anomalous-pick-base {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
color: var(--amb-text-soft);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-pick-size {
|
||||
min-width: 64px;
|
||||
color: var(--amb-text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* As covers: the cover on top, the check in its corner, the state and size under the name. */
|
||||
.is-covers .anomalous-pick-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-color: var(--amb-border);
|
||||
}
|
||||
|
||||
.is-covers .anomalous-pick-check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.is-covers .anomalous-pick-cover {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.is-covers .anomalous-pick-path,
|
||||
.is-covers .anomalous-pick-base {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-covers .anomalous-pick-facts {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.is-covers .anomalous-pick-size {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-pick-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--amb-border);
|
||||
}
|
||||
|
||||
.anomalous-pick-summary {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-pick-hint {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.anomalous-pick-overlay { padding: 8px; }
|
||||
.anomalous-pick-head { flex-wrap: wrap; }
|
||||
.anomalous-pick-title { flex: 1 1 100%; }
|
||||
.anomalous-pick-folders { display: none; }
|
||||
.anomalous-pick-base, .anomalous-pick-hint { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user