Commit Graph
3283 Commits
Author SHA1 Message Date
Will Miao e95ff360cf fix(download): decode percent-encoded stored filenames before saving
CivitAI stores non-ASCII file names percent-encoded; the mini endpoint's
raw fileName (preferred since #1100) returned that encoded string, so it
landed on disk literally as "%E7%8A%AC..." and the path-budget
truncation could cut mid-escape. Decode to valid non-ASCII UTF-8 names
before the on-disk name is fixed; literal % (stored as %25) and invalid
escapes pass through unchanged.
2026-10-19 08:15:03 +08:00
Will Miao b5d313113c fix(recipes): strip legacy trailing ellipsis from prompts on modal open
Old companion-extension builds scraped Civitai's collapsed "...Show more"
toggle as part of the prompt text, so recipes imported through them can end
with a stray "..." on prompt and/or negative_prompt.

Repair it where the recipe modal already loads its detail payload: GET
/api/lm/recipe/{id} now strips trailing "..." runs from both fields, persists
the cleaned gen_params through recipe_scanner.update_recipe_metadata (recipe
JSON, memory and persistent caches, FTS index, preview EXIF) and returns the
cleaned values. The repair is unconditional — no source or API check — since
the existing library damage all came from that scraper. Values without a
trailing "..." are returned untouched, and a failed write logs and falls back
to the stored value so the detail response never breaks. Once persisted the
value no longer matches, so later opens are no-ops.
2026-10-18 21:59:47 +08:00
Will Miao f0615f769d i18n: translate the count tooltip and folder-removal strings
Fill in the two keys added by 965bfd33 (the always-on hover text of the counted
filter chips, All Types options and Refresh scope rows) and sweep the four
leftover sidebar.deleteFolder* placeholders from the folder-removal flow
(missingMessage, removeFromList, forgotten, exists), so no [TODO: Translate]
remains in any locale.

"models" reuses each locale's loras.controls.refresh.rootModels noun and
"recipes" the R4 noun in its plain plural (fr keeps "Recipes"); the separator
follows the sidebar.folderRoot.*Status precedent (CJK ——, all others —), and
ko puts the count after the noun as in rootModels. The folder strings mirror
each locale's existing deleteFolderResult.success / .missing renderings.

Terminology recorded in docs/i18n-translation-guidelines.md §2.
2026-10-18 18:35:01 +08:00
Will Miao 965bfd33cd feat(controls): keep model counts readable in large libraries
Counts in the filter panel chips (base models, tags, model types), the All Types
dropdown and the Refresh scope rows were rendered as raw digits, so a library with
10k+ models turned every chip into a five digit pill that dominated the row it sat
in — and the Refresh menu grouped its numbers while the filter panel did not.

One formatter now renders all of them (`formatCount`): exact and locale-grouped
below 10,000, abbreviated to three significant digits above it, and abbreviated
only where the locale's Intl compact form is a real unit and narrower than the
plain number — German keeps 18.475 instead of the rounded 18.500, Russian keeps
18 475 instead of the wider 18,5 тыс.

Their hover text is always present and always exact ({name} — {count} models, and
... recipes on the Recipes page): an affordance that only exists above a threshold
is undiscoverable, and the tooltip is also what reveals a chip label that CSS has
ellipsized.

Chips gain a two-slot layout (the name ellipsizes, the count keeps a tabular
right-aligned slot) so a long tag name can no longer push the count out of the
chip; Refresh rows move from float:right to a flex row, which keeps the counts in
a right-hand column and lets a long root label ellipsize. The All Types group
tooltip moves onto its trigger so it stops leaking onto the menu options.

The two new strings ship as [TODO: Translate] placeholders in the nine locales;
the translation pass is the next commit.
2026-10-18 18:34:16 +08:00
Will Miao 915cec9b67 i18n(controls): translate the model type dropdown strings
Fill in the nine locales for the three keys added by 3d65e044 (model
sub-type quick-switch dropdown): the trigger tooltip, the "All Types"
option and the "{count} types" multi-selection label.

Reuses each locale's existing `header.filter.modelTypes` rendering of
"model type"; the count fragment follows the locale's usual count form
(zh 种/種, ja タイプ, ko 유형 {count}개, fr/de/es plain plural, ru the
count-agnostic тип(ов), he סוגים).
2026-10-18 16:54:51 +08:00
Will Miao 6b3566a894 i18n(workflow): translate the send-to-workflow exclusion strings
Fill in the nine locales for the keys added by f8ecc00f (send-to-workflow
target filtering): the settings section header, the two exclusion-list
labels/placeholders/help texts, the quick-exclude toast and the
node-selector tooltip.

New terminology: "node type" (zh-CN 节点类型 / zh-TW 節點類型, ja
ノードタイプ, ko 노드 유형, fr type de nœud, de Knotentyp, es tipo de nodo,
ru тип узла, he סוג צומת) and "send targets" (zh-CN 发送目标 / zh-TW
傳送目標, ja 送信ターゲット, ko 전송 대상, fr cibles d'envoi, de Sendeziele,
es destinos de envío, ru цели отправки, he יעדי שליחה), both reusing each
locale's existing node/target/workflow renderings.

The literal ComfyUI menu label "Mark as" (hardcoded English in
web/comfyui/node_marker.js) stays untranslated in every locale, per
docs/i18n-translation-guidelines.md §6.
2026-10-18 16:53:31 +08:00
Will Miao 6835880e0b style(controls): drop decorative icons from toolbar action buttons
Width pressure on the controls bar is real (see the max-width: 1500px
tightening in layout.css), and the icons on text-labeled action buttons
are redundant encoding. Remove them from Refresh / Fetch / Download /
Bulk / Duplicates / Doctor and the recipes-page Import / Batch Import.

Kept: state-carrying icons (favorites star, updates exclamation,
exit-duplicates fa-times, badges) and structural icons (carets, layout
toggle, dropdown menu items). ModelDuplicatesManager rebuilds the
duplicates button on mode exit, so its restore string is updated too.
2026-10-18 16:46:44 +08:00
Will Miao 3d65e044f8 feat(controls): add model sub-type quick-switch dropdown to the controls bar
Single-select Model Type dropdown next to the sort control on the loras,
checkpoints and other pages (hidden when only one sub-type exists).
Selection maps to filters.modelTypes, persists via the existing filter
storage, and stays in sync with the filter panel's multi-select chips.
2026-10-18 16:16:59 +08:00
Will Miao 6ab4b041c0 style(card): unify quant/hl badge height with version-name chip
Match font-size, line-height, and vertical padding of .quant-badge and
.hl-badge to .version-name across base/medium/compact densities so all
chips in .badge-version-unit share the same box height. Color remains
the sole semantic differentiator.
2026-10-18 11:58:41 +08:00
Will Miao f8ecc00f6b feat(workflow): user-controlled send-to-workflow target filtering
- Global exclusion lists (node classes + title patterns) in settings,
  applied at the shared filterRegistryNodes choke point; settings UI
  section is hidden in standalone mode
- Per-node "Exclude from Send Targets" context-menu toggle persisted
  in the workflow JSON (lm_exclude_send)
- Quick-exclude button on node selector items writes the comfy class
  to the exclusion list
- Precedence: per-node exclusion > explicit marker > global lists
- interceptModeChange now supports multiple listeners; registry hooks
  mode changes so mute/bypass refreshes candidates immediately
2026-10-18 09:42:44 +08:00
Will Miao 43b26f7cc9 fix(paths): cap filename components to the filesystem NAME_MAX byte limit 2026-10-18 07:11:44 +08:00
Will Miao 72705c570e fix(move): stop drag-and-drop from nesting the target under the source dir (#1148)
The sidebar drag-and-drop derived the library root by stripping the card's
cached folder off its file path. Any disagreement between the two -- a stale
or wrong-cased folder recorded by an older version -- made the strip fail and
silently returned the file's own directory as the root, so the move created
<current dir>/<target path> on disk and physically nested the library.

Two defenses:

* SidebarManager now resolves the move root from the authoritative library
  roots (longest prefix match) and only falls back to the card heuristic,
  which compares case-insensitively and aborts the move on a real mismatch
  instead of fabricating a root.
* Reconcile repairs existing entries whose folder no longer matches where
  their file_path sits. Refresh never rewrote existing rows, so folders
  recorded wrong by 1.2.2-1.2.5 kept poisoning moves forever; now a plain
  Refresh heals them (no manual Rebuild Cache needed).
2026-10-17 19:39:37 +08:00
Will Miao f940daae29 feat(sidebar): compact drag image, hover auto-expand, edge auto-scroll for card drags
Replace the browser's default full-card drag snapshot with a small chip
(thumbnail + name + count badge) anchored below-right of the cursor, so
the folder tree stays visible while aiming. Collapsed folders now
auto-expand after a short hover during a drag, and the tree auto-scrolls
when the pointer nears its top/bottom edge.
2026-10-17 18:20:42 +08:00
Will Miao 1276673e0e fix(loaders): migrate backslash values in subgraphs and clear the error flag (#1149)
The follow-up report found three gaps in the value migration:

* applyToAllNodes only walked the root graph, so loader nodes inside
  subgraphs kept their backslash values. It now recurses through
  node.subgraph (Subgraph extends LGraph, so .nodes works at every level).
  Promoted combo widgets share the interior widget's store-backed value,
  so migrating the interior widget fixes the subgraph boundary too.
* Pasted nodes never hit the graph-level configure hook. A per-node
  onConfigure wrapper now runs the same sanitize + migrate pass after
  widget values restore.
* The corrected value kept its stale "value not in list" flag. The
  migration now fires the node's onWidgetChanged hook, which the
  frontend's error-clearing chain uses to drop value_not_in_list errors.
2026-10-17 17:12:41 +08:00
Will Miao be0133a699 fix(loaders): accept backslash-spelled model names from old workflows (#1149)
The forward-slash paths fix changed the checkpoint/unet combo lists on
Windows from "dir\model.safetensors" to "dir/model.safetensors", so
workflows saved before it loaded with a value no option matches: the
front-end extension reset the widget to the first model, or ComfyUI
flagged it "value not in list" when the widget was wired.

Two layers:

* random_loader_control.js migrates the widget value to its
  separator-normalized form when that form is a known model, before the
  reset-to-first fallback and before the wired early return.
* The loader nodes declare VALIDATE_INPUTS for the model name input,
  lifting ComfyUI's strict list check for it; the shared helper resolves
  separator-normalized names against the cache, so API-submitted or
  programmatic values validate too. get_checkpoint_info_absolute now
  normalizes backslashes explicitly instead of via os.sep, which was a
  no-op on Linux.
2026-10-17 10:19:53 +08:00
Will Miao 27275049b2 style(modals): redesign re-link and link-source dialogs
Compact single-field dialog layout: narrower content (640px), header with
accent icon and in-flow close button whose hover outline no longer crosses
the header divider, warning/info banner with theme-token side accents,
monospace URL example chips, and error text that only takes space when
present. Buttons switch to the standard secondary/primary pair.
2026-10-16 16:43:19 +08:00
Will Miao 034168d04e fix(cache): re-freeze GC after cache rebuilds (#1147)
A full rebuild or library switch replaces the frozen cache with freshly
allocated, GC-tracked objects, so ComfyUI's post-prompt gc.collect() would
slow down again until the next restart. Schedule a debounced re-freeze
(10 s settle delay) after ModelScanner full rebuilds and after model/recipe
library-change re-initialization, so repeated rebuilds and multi-scanner
library switches freeze once, after follow-up work settles.
2026-10-16 08:19:33 +08:00
Will Miao 6ef20bd33e fix(hash): run calculate_sha256 in a thread executor
The async calculate_sha256 performed its full-file read+hash loop
synchronously, freezing the single aiohttp event loop for the whole
duration of a multi-GB checkpoint hash — every HTTP endpoint, websocket,
and the extension's progress polling hung with no error surfaced.

Move the blocking loop into run_in_executor so hashing occupies a worker
thread instead of the event loop. Fixes all call sites at once (download
finalize, checkpoint lazy hash, scanners, metadata manager).
2026-10-15 23:29:40 +08:00
Will Miao 85d27dfa36 feat(metadata): link foreign-ecosystem models to their real source via CivArchive hints
When CivArchive's hash index only knows a file as a foreign-ecosystem
entry (e.g. a civision mirror of a ModelScope upload), record the
entry's source URL during the lookup. On a not-found fetch the sync
service now resolves that URL through the model-source registry, proves
via a strict sha256 gate (ModelScopeSource.fetch_card_context_by_hash)
that the repository really publishes this exact file, then persists the
source_url/source_platform link and runs the download-time hydration
path, yielding the correct name, base model, tags, trigger words and
example images from the actual source site.

Models already linked to an external source are never re-linked, and
rate-limited runs skip the extra fetches entirely.
2026-10-15 23:12:52 +08:00
Will Miao 35bc21a5bb fix(civarchive): don't resolve foreign-ecosystem file IDs into CivitAI metadata
CivArchive's /sha256 endpoint also indexes files from other ecosystems
(e.g. civision/ModelScope) whose model_id/model_version_id belong to a
different ID space. _resolve_version_from_files fed those IDs into the
CivitAI-ID-based /models/{id} endpoint, which resolves modelVersionId
globally, so a colliding ID silently linked the local file to an
unrelated CivitAI model (hash f3a5370b... resolved to CivitAI model
119656 via version id 130022).

Two guards: skip file entries whose source marks a foreign ecosystem,
and require the resolved version's files to contain the queried SHA256
before accepting it.
2026-10-15 22:49:06 +08:00
Will Miao 740b42db3f test(rate-limit): epsilon the cooldown upper bounds against float noise
Arming computes now + backoff and remaining_seconds subtracts a later
monotonic reading, so the result can round an epsilon above the armed
window (Windows CI reported 60.00000000000003 for a 60s bound). Same
fix pattern as 946809f4.
2026-10-15 22:11:44 +08:00
Will Miao 7deb0c9285 fix(config): skip already-live roots when admitting configured roots
admit_configured_roots() re-probed every configured root on every scan
(os.path.exists + realpath per root), so even a single-root refresh
statted every configured drive first — enough to spin up sleeping disks
and stall the scan the user actually asked for.

Admission only ever appends, so roots already in the live lists can
never gain entries from re-preparation. Probe only the configured roots
that are not live yet; the plug-in-a-drive-later behavior is unchanged
(a missing root is re-probed on each call until admitted).
2026-10-15 21:16:14 +08:00
Will Miao f4d9a6ccd6 fix(download): don't let a CivArchive cooldown block CivitAI-served downloads
A CivArchive API 429 armed a civarchive.com cooldown that the pre-flight
gate then applied to every download tagged source=civarchive — even when
the file bytes would have come from CivitAI (the common case of clicking
download on a CivArchive page for a model that still lives on CivitAI).

- Narrow the pre-flight gate to the CivitAI hosts; the CivArchive legs
  keep their own protection (metadata fetch via wait_for_slot, mirrors
  via the new deprioritization).
- Fall back to the default (CivitAI) metadata provider when the
  CivArchive fetch is rate-limited; re-raise the structured 429 only
  when CivitAI has nothing either (archive-only model), so the queue
  re-queues instead of failing.
- Deprioritize mirrors whose host is in cooldown once the concrete
  download URLs are known (also on the aria2 resume path), so a cooled
  mirror never shadows a ready one.
2026-10-15 21:16:03 +08:00
Will Miao cf3877bbc9 fix(scanner): keep folder casing when slicing the relative path (#1148)
85f79283 made _calculate_folder compare root identities, but sliced the
relative folder out of the identity itself -- and normalize_root_identity
folds case on Windows (or any drive-letter path), so every move recorded
the folder in lowercase. The sidebar then grew a lowercase twin branch
("Krea 2" vs "krea 2") on each move until the next disk walk healed it.

Slice from normalize_root_spelling instead, a case-preserving counterpart
that folds only separators and redundancies, with a length guard for the
rare Unicode case-fold that changes the string length.
2026-10-15 21:10:41 +08:00
Will Miao a1fc5eb825 i18n: translate showQuantizationOnCard keys into 9 locales 2026-10-15 11:21:17 +08:00
Will Miao 19abf7ed03 feat(ui): show quantization badge on model cards
Distinguish same-version quantized models (e.g. GGUF Q4_K_M vs Q8_0) on
cards, which previously showed only the identical CivitAI version name.

Detection is content-based and stored as a three-state sidecar field
(absent = unchecked, "" = checked-unavailable, value = canonical name),
mirroring the autov3 pipeline:

- GGUF: minimal pure-struct header parser reads general.file_type
- safetensors: dominant tensor dtype F8_* -> FP8
- CivitAI files[].metadata.fp == "fp8" fills in as a fallback only
- A one-time backfill service covers existing libraries; each file
  costs only a few-hundred-byte header read

The footer renders the badge after the version name (suppressed in
grouped mode), gated by a new show_quantization_on_card setting that
follows the show_version_on_card toggle pattern.
2026-10-15 11:18:06 +08:00
Will Miao d1586c4c61 test(paths): make the path-budget tests portable to Windows
Two platform assumptions broke the Windows informational job: the
alongside-sidecar fixture went through abspath, which prepends the drive
letter on Windows and skewed the asserted lengths by two; and the
download-rename trim test patched a fixed path limit that left no room
for a viable stem under the runner's long temp dir. The fixture now uses
plain dirname, and the limit is patched relative to the actual tmp_path.
2026-10-15 08:54:43 +08:00
Will Miao 260b21766b fix(startup): freeze hydrated caches out of GC tracking (#1147)
ComfyUI runs a full gc.collect() after every prompt, and its cost scales
with the number of GC-tracked objects. With a large library the hydrated
model/recipe caches account for ~1M long-lived objects, so each post-prompt
collection held the GIL for seconds and froze the whole server.

After all scanner init tasks finish, run gc.collect() + gc.freeze() once to
move the long-lived objects into the permanent generation, which the
collector no longer scans. Matches the workaround the reporter verified
manually.
2026-10-15 08:43:29 +08:00
Will Miao 7a5c1da9b5 fix(paths): trim model filenames to the real full-path budget (#1142)
The per-component caps (150/100/50) do not add up to a guarantee: a deep
model root plus template folders plus a max-length stem still overflows
the Windows MAX_PATH limit, and raw download filenames carried no cap at
all. fit_stem_to_path_budget() now measures the actual destination —
the model path and the sidecar location resolved through sidecar_paths
(the centralized mirror tree is typically deeper) — and trims the stem
to fit, reserving room for companion suffixes (.metadata.json.bak,
previews, .aria2).

Applied at all four name/path decision points: bulk filename-template
rename, post-download template rename, the initial download save path
(file_info rewritten to the on-disk name, matching the #1100 raw-name
precedent), and an auto-organize pre-check that skips with an
actionable message instead of failing opaquely on Windows.
2026-10-15 08:36:49 +08:00
Will Miao dabaf32858 docs(update): mark the threshold material in §10-§11 as historical
The status header still advertised "P5 (alerts panel) planned" while §11 opened
with "P5a implemented", so a user reading the plan to learn the UI could not
tell that the panel, the numeric threshold, the alerts query, the two alert
endpoints and getPriceAlerts were built on a branch and removed in e42d649d,
before v1.2.5 shipped. §10 is stale in the same way: it lists the alerts query,
price_alert_* and getPriceAlerts among what shipped.

The header now states the released behaviour (no independent surface, no
threshold, no price-drop alert), §10 and §11 carry a superseded note pointing at
§12.4/§12.7, and the §11 "Owner decision" paragraph is marked reversed by
§12.3 D1. Documentation only; no code or behaviour changes.
2026-10-14 09:19:28 +08:00
Will Miao d4a01c8d6d test(download): compare rule-routed save dirs as paths, not strings
The feat/download-root-rules merge added two tests asserting the joined
save_dir against a str(Path) expectation. `_calculate_relative_path` returns a
forward-slashed relative path that `os.path.join` appends to a native-spelled
root, so on Windows the value is "...\unet-archive\Flux.1 D/fantasy" while the
expectation is "...\unet-archive\Flux.1 D\fantasy" -- purely a separator
difference. Only those two assertions failed (3844 passed), and the Windows job
was green at the merge's first parent (b197793b).

Capture save_dir as a Path, like every other fake in this file, and compare
Path objects so the assertions pin the routed root -- their actual subject --
instead of the host separator.
2026-10-13 11:35:39 +08:00
Will Miao e27500c80f Merge branch 'feat/download-root-rules'
Download Root Rules: route downloads to a non-default root by ordered
base-model/tag rules per model type (loras/checkpoints/unet/embeddings),
with a rules editor in Settings > Organization, a default-root cue, and
the matched rule shown in the download modal. Translated in all locales.

Conflict note: settings_manager.py adopted the shared
utils.root_paths.normalize_root_identity from main (issue #1146).
2026-10-13 09:23:35 +08:00
Will Miao b197793b10 fix(rate-limit): do not leave the gate on an early timer wake
`wait_for_slot` trusted a single `asyncio.sleep(delay)`. The event-loop timer can
fire before the delay elapsed -- Windows granularity is ~15ms, which the new
Windows job caught -- and the method then returned while the cooldown was still
armed, so the caller sent a request inside the window the gate exists to enforce.

It now re-checks the fixed `wake_at` and sleeps again, which also restores the
strict `elapsed >= 0.14` cooldown bounds the earlier relaxation had weakened (a
substring replace had turned two of them into `>= 0.09`), and a regression test
fails the gate if it ever returns on an early wake.
2026-10-12 11:59:10 +08:00
Will Miao 946809f47c test(rate-limit): tolerate the timer on Windows, and restore two bounds
The last Windows failure was float noise on an upper bound: a 0.15s cooldown
window reports 0.15000000000000568 remaining, so the assertion missed by 6e-15.
The upper bound now carries an epsilon.

The two `elapsed >= 0.14` cooldown assertions had been weakened to `>= 0.09` by
a substring replace while relaxing the pacing bound -- that would have let a
missing wait pass. They are back at 0.12 (0.15s minus Windows timer rounding),
which still fails if the cooldown is not awaited.
2026-10-12 11:51:48 +08:00
Will Miao d25f170ffe test: fold the last Windows path and label assumptions
Second pass over the informational Windows job (91 failures -> 11 -> 0):

* `_identity`/`_fold` now wrap the assertions my first sweep skipped: the
  parenthesised and multi-line ones, and the ones whose token list was
  incomplete (`base_models_roots`, `embeddings_roots`).
* The reconciler's `current_name` and `active_roots` carry the drive-prefixed
  label on Windows ("C: loras"), so the last direct comparison goes through
  `_root_label_names` too.
* `open_settings_location` reveals the settings *file* with
  `explorer /select,` on Windows instead of opening the containing folder, so
  that test asserts the platform's call instead of the POSIX one.
* Three more expectations are written in the business form the code returns
  (metadata payload, extracted download paths, hydrated preview URL).
2026-10-12 11:46:13 +08:00
Will Miao c16de68640 test: make the backend suite pass on Windows
The new informational Windows job surfaced 91 failures: four real bugs (fixed in
the previous two commits) and a long tail of POSIX assumptions in the tests
themselves. None of them assert behaviour that differs on Windows, so they are
fixed rather than skipped wherever the behaviour is the same:

* Path spelling: expectations built with `str(tmp_path)` are compared through an
  identity helper (`normalize_root_identity`) or the platform's separator, since
  the code hands out forward-slashed business paths while `str()` is native.
* Root labels: `_root_display_labels` prefixes the drive on Windows ("C: loras"),
  so label assertions compare the final path component or derive the expected
  label from the same helper.
* `~` expansion reads USERPROFILE (ntpath), not HOME; timer granularity rounds
  0.1s down; a case-only rename counts as "already named" via `os.path.normcase`.
* Genuinely POSIX-only strategies are skipped with a reason instead of failing:
  the headless clipboard fallback, dbus file-manager selection, xdg-open, and
  the case-fold index test that simulates a case-sensitive filesystem.
2026-10-12 11:35:33 +08:00
Will Miao 85f79283b6 fix(paths): keep business paths forward-slashed on Windows
Four places produced the host's separator (or a mixed path) where the app's
business form is forward-slashed, so on Windows the value could not be compared
with the one the rest of the app stores:

* `ModelScanner._calculate_folder` matched `file_path.startswith(root)` as raw
  strings, so every forward-slashed path resolved to the library root and a
  moved model was filed under the root instead of its folder. It now compares
  root identities and slices the relative part off.
* `normalize_relative_folder` ran `os.path.normpath`, and ntpath folds forward
  slashes back to backslashes: every folder parameter (scoped scan, create,
  rename, delete) came back host-spelled and mismatched the stored folders.
* `_format_model_name_for_comfyui` returned `os.path.relpath` unchanged, so the
  checkpoint/unet nodes and the metadata node emitted `unet\base.safetensors`
  on Windows -- a name no ComfyUI widget list matches.
* `resolve_centralized_dir_for_dir` passed a component that spans two
  directories ("<library>/<component>") whole to `os.path.join`, leaving a mixed
  path ("...\comfyui/loras-1a2b3c4d\sub") that no native path equals.

Also hands the rename re-key callback business paths, like every other absolute
path the move service returns.
2026-10-12 11:35:33 +08:00
Will Miao fd40cd952b fix(cache): close the connection the schema init opened
`with sqlite3.Connection` ends the transaction but keeps the file handle open,
so every cache class leaked one handle per instance until the garbage collector
happened to run. On Windows that makes the database file undeletable while the
process lives (WinError 32), which broke 30 cache tests in teardown and would
also block moving or clearing a cache at runtime.

All eight `with <connection>` sites now go through `closing_connection()`, and a
regression test asserts the schema init leaves no usable connection behind.
2026-10-12 11:35:14 +08:00
Will Miao 5fbac84910 test(i18n): fail when a locale file is re-serialised
The layout is load-bearing for review: re-dumping a locale with a different
`indent` rewrites every line, so the diff hides the actual change. Assert that each
file still matches the canonical serialisation it is maintained in (`en.json`
indent=2, the translated locales indent=4, `ensure_ascii=False`, one trailing
newline), so the mistake fails in CI instead of in review.
2026-10-12 10:55:49 +08:00
Will Miao a750ca88ac docs(agents): pin the locale file layout and the stale-translation rule
Two things agents kept getting wrong while editing UI copy, both learned the
expensive way:

* `locales/*.json` come in two layouts: `en.json` (the reference, hand-maintained)
  is 2-space indented, the nine translated locales are 4-space (what
  `sync_translation_keys.py` writes). Loading one with `json.load` and dumping it
  back with the other indent reformats the whole file: changing two strings in the
  translated locales produced a 26k-line diff that had to be reverted and redone in
  place. The section now names both layouts, says to edit values in place, and adds
  the cheap guard that catches the whole class of mistake: after writing to an
  existing structured file, `git diff --stat` must match the change you meant to
  make.
* Changing an existing English string makes every other locale's value stale.
  Those values have to go back to `[TODO: Translate] <new English>` rather than
  keep describing the old behaviour until someone notices.
2026-10-12 10:55:49 +08:00
Will Miao b68af5400e fix(sidebar): shorten the missing-folder dialog copy (#1145)
The dialog sits in the sidebar and now offers the removal itself, so telling the
user the entries "can still be removed from the sidebar" said the obvious twice
and read oddly next to a button that does exactly that. Say what is true and stop:
"This folder no longer exists on disk. You can remove it from the list."

The result toast follows the button's wording ("removed from the list") instead of
naming the sidebar again. Both strings changed in English, so the existing
translations of them are back in the queue with the new source text rather than
left saying "refresh the sidebar and try again".
2026-10-12 10:35:04 +08:00
Will Miao 5976224d75 fix(pending-delete): drop the staging container with its last batch (#1145)
Deleting a folder that holds models answered "A deletion is still pending inside
this folder. Wait for the undo window to expire." even though nothing was staged
and no window was running. `.lm-pending-delete` is only a container: the purge,
the undo and the staging rollback all removed the batch dir inside it and left the
container behind, and the folder manifest matched the directory *name* alone. Any
folder where a model had ever been deleted with the undo window was therefore
undeletable for good, and the same name match refused folder renames.

* `staging_dir_has_content()` is the shared answer to "is there still a batch
  here?": an empty container is a leftover, a quarantined batch (`*.orphaned`) is
  preserved data and still counts, and an unreadable directory counts too — never
  delete over something that could not be listed.
* The container is removed with its last batch (purge, undo, staging rollback,
  merged-loser cleanup). `rmdir` only ever succeeds on an empty directory, and the
  path is checked to really be a `.lm-pending-delete`, so a model folder is never
  at risk.
* A manifest-less batch dir with nothing in it is a crash leftover between
  `makedirs` and the manifest write: it is dropped instead of quarantined, which
  would have left an empty container that blocks deletion just the same.

Verified: purge, undo and the empty-manifest-less sweep all leave no container
behind, the container survives while another batch is staged, and an empty one no
longer blocks folder delete or rename while a real (or quarantined) batch still
does. 3818 backend tests pass.
2026-10-12 10:34:55 +08:00
Will Miao 3a718ce2c3 fix(sidebar): let a ghost folder be cleared from the UI (#1145)
Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.

* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
  plus the cached models under it) and refuses with `code: "exists"` while any root
  still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
  already gone. The dry run stays a pure read and keeps answering `missing`, which
  is what makes the dialog offer the removal. `POST`/`GET
  /api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
  directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
  the missing state, and reports the folder as removed from the sidebar rather than
  as a delete that never happened. An `exists` conflict says the directory is back
  and should be deleted instead.

Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
2026-10-12 09:31:29 +08:00
Will Miao 0508daa0fb fix(scanner): verify recorded folders per entry, not per scan (#1145)
`_reconcile_cache()` replaced the recorded folder list only when *nothing* was left
unverified: one switched-off drive, one directory `os.walk` could not enter, or one
symlink whose target was gone, and the whole list fell back to a union with what
the walk found. A folder moved with a file manager therefore stayed in the sidebar
for good — the walk kept adding the new location while the old entry was never
dropped, a restart re-hydrated the union from SQLite, and the ghost could not be
deleted in the UI either because its directory was gone.

* `_UnreachablePaths.covers_folder()` answers "could this specific entry have been
  read?" for a library-relative folder: it is kept only while a directory the walk
  failed to enter (or an offline symlink) may still hold it. A root that is
  unreadable *as a whole* deliberately does not count: library-relative names carry
  no root, so a missing drive would trivially cover every entry and no ghost could
  ever be cleared. Its folders still show while they hold cached models
  (`get_all_folders()` unions the model-derived folders) and a genuinely empty one
  comes back the next time that root is walked.
* The merge keeps today's union for a scoped scan, and for a root-scoped scan that
  does not cover every root: those cannot speak for the roots they were not asked
  about. Asking for every root is a full refresh and cleans again.

Verified: a folder moved externally is dropped while another root is offline, the
offline root keeps its own folders and cached models, and a scoped scan still never
drops a folder of a root it did not scan. The Windows-only half of this (a
backslash `os.sep` making the configured twin shadow the live root, so deleted
models were never pruned) is now reproduced in CI by patching `os.sep` instead of
needing a Windows runner.
2026-10-12 09:31:17 +08:00
Will Miao fadad1b507 ci(backend): run the test suite on Windows too (#1146)
The backend suite only ever ran on ubuntu-latest, which is why #1146 shipped:
every configured model root counted twice, the duplicate shadowed the live root
as unreachable, and models deleted from disk were never pruned again. That whole
class of bug depends on `os.sep`/`os.name`, so a Linux-only matrix cannot see it.

* New `pytest-windows` job on `windows-latest` running the same suite. No
  coverage upload: the Linux job keeps producing that artifact.
* Informational for now (`continue-on-error: true`) so pre-existing Windows
  failures surface in the Actions UI without blocking merges; the flag comes off
  once the suite is Windows-clean.
* The symlink probe reports instead of aborting: `os.symlink` needs Developer
  Mode or an elevated shell on Windows, and a runner without it should still show
  the real test results.
2026-10-12 08:43:06 +08:00
Will Miao 6d0d7cb15b fix(scanner): compare model roots by identity, not by spelling (#1146)
ComfyUI hands its model folders over in the host's own spelling --
`utils/extra_config.py` runs every configured path through `os.path.normpath()`,
which is all backslashes on Windows, and `main.py` registers `<output>/loras`
unconditionally for its save nodes. `Config` kept that spelling verbatim while
`_prepare_*_paths()` normalized the live root lists to forward slashes, and the
scanner compared the two as raw strings. On Windows every configured root
therefore looked like a root that was not in the live list: the Refresh menu
listed each directory twice (the twin showing "0 models"), the completion toast
claimed reachable roots were "currently inaccessible" and kept their whole cache
count as `kept_unreachable`, and the settings sync persisted host-registered
directories that were never created.

The duplication was not only cosmetic. `_UnreachablePaths.add()` normalizes the
prefix it is given, so the configured twin of a live root registered exactly the
live root's prefix as unreadable: in the prune phase every cached-but-missing
entry under it matched and was "kept", so models deleted from disk were never
removed from the cache again.

* `py/utils/root_paths.py` holds the single `normalize_root_identity()`
  (separator style, redundant/trailing separators, and case where the filesystem
  folds it: whole paths on Windows, drive letters everywhere). `config.py` and
  `settings_manager.py` each carried a private copy and `model_scanner.py`
  compared raw strings.
* `ModelScanner` compares root identities in `get_configured_model_roots()`,
  `describe_model_roots()` (dedupe + `available`), the reconcile's
  `configured_only` list and the `ReconcileScope.roots` membership test, so one
  directory is one root whatever the host called it. `_normalized_root_prefix()`
  is built on the same identity, and an empty prefix no longer matches every
  path.
* A configured root that never made it into the live list is reported only while
  it still holds cached entries -- in the menu and in `skipped_roots` /
  `unavailable_paths` alike. A directory that neither exists nor was ever scanned
  has nothing to keep, so `<output>/loras` stops being an unavailable root.
* The startup sync drops saved roots that neither exist nor hold cached entries
  (`PersistentModelCache.roots_with_cached_entries()`, one scan per model type,
  separator-agnostic and wildcard-escaped). A root named by the library's
  `default_*_root` is kept even when empty: dropping it would let the next boot
  "repair" the default onto another root. When the cache cannot be consulted,
  every root is kept.
* `GET /scan?roots=` validates requested roots by identity too, so the browser
  extension or a script passing the spelling settings.json holds selects the root
  instead of answering 400 "Unknown model root(s)".

Verified: 8 of the new tests fail against the pre-fix source (checked with the
fix stashed); 3798 passed, 7 skipped, 11 deselected. The probe costs 71ms for a
100k-model library with seven missing roots (357ms when probing root by root).
2026-10-12 08:43:06 +08:00
willmiao e056142669 docs: auto-update supporters list in README 2026-10-11 08:23:13 +00:00
Will Miao 9f71d524a4 chore(release): bump version to v1.2.5 v1.2.5 2026-10-11 16:22:45 +08:00
Will Miao d5139500f9 fix(settings): stop clipping help tooltips in the settings pane
Help tooltips were CSS pseudo-elements on the info icon, so they were
descendants of the scrollable settings pane (.settings-content uses
overflow-y: auto) and got clipped at its top edge. The Active Library
deprecation notice made this obvious: the text grew to ~10 lines while
only ~5 fit above the icon, so the tooltip started mid-sentence at
"page. Deprecated:".

Render the tooltip once into <body> with position: fixed instead, so no
overflow ancestor can clip it, and position it from JS: above the trigger
when it fits, below otherwise, clamped to the viewport. It follows the
trigger on scroll/resize and hides on mouseout, Escape or touch. The
delegated listeners also cover help icons rendered later by the settings
manager, and the box may now use 320px instead of 220px, which keeps long
help texts to fewer lines.
2026-10-11 11:34:52 +08:00
Will Miao 79db86666d Merge branch 'fix/example-images-failed-blacklist' 2026-10-11 11:11:09 +08:00