Commit Graph
3253 Commits
Author SHA1 Message Date
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
Will Miao cbbefdecf9 fix(theme): make checkboxes and loading spinner follow the theme accent
Native checkboxes in settings (managed types, base-model skip list) and the
folder-root delete modal had no color declaration, so checked state used the
browser default blue; the loading spinner used hardcoded #3498db/#f3f3f3.
Give the checkboxes accent-color: var(--lora-accent) and derive the spinner
track/arc from the accent token so all themes and presets stay consistent.
2026-10-10 20:51:19 +08:00
Will Miao c0165919b3 fix(theme): readable on-accent text color and unified accent aliases
Solid-accent buttons used the page text color (--lora-text) or white,
which falls below WCAG AA on the mid-bright accent (L 52-72%) in every
theme/preset — e.g. ~1.7:1 in dark+solarized, making primary buttons
hard to read. Add --color-accent-contrast (dark, low-chroma, same hue,
derived per preset) aliased as --text-on-accent, and switch ~90
accent-background selectors (primary/confirm buttons, nav items, chips,
badges, checkmarks) to it.

Also define the previously dangling --lora-accent-l/c/h, --lora-accent-hover
and --lora-accent-light aliases (fixing ~40 dead declarations), replace the
hardcoded rgba(--lora-accent-rgb, 79 70 229) fallbacks with oklch channel
syntax, and add the missing margin above .template-custom-input in
settings.
2026-10-10 20:41:10 +08:00
Will Miao 3d165f92e0 fix(theme): use opaque surface tokens for modals and sticky layers
The elevated surface tokens (--bg-elevated / --surface-elevated) are
translucent, so sticky modal footers and table headers double-composited
over the modal surface and showed a visible lighter seam in light themes.

Add derived opaque tokens (--bg-elevated-solid / --surface-elevated-solid,
aliased as --lora-surface-solid) and use them for .modal-content, the
download/import modal sticky action footers, and the refresh result
table's sticky header, so the modal surface and its sticky layers always
share one opaque color across every theme and preset.
2026-10-10 19:58:54 +08:00
Will Miao a8941f453f fix(sidecars): use button-triggered wording in the migration dialog
The "Migrate Sidecars Now" button reused the mode-change confirmation
copy, which told the user they could migrate later with the very button
they had just clicked. Add manual variants of the confirm messages
(confirmToCentralizedManual / confirmToAlongsideManual) that drop the
mode-change preamble and the circular backreference, and translate them
for all 9 locales.
2026-10-10 18:58:56 +08:00
Will Miao 5796a0e2e9 fix(sidecars): report over-long migration paths and fix root relocation (#1142)
- Surface Windows WinError 206 / ENAMETOOLONG during sidecar migration as
  an actionable per-model error instead of a bare OSError
- Offer root relocation based on the resolved sidecar root, so moving off
  (or back to) the default storage directory relocates existing sidecars
- Hint in the migration confirm dialog when the destination is the default
  location, so a custom folder can be set before migrating
2026-10-10 14:35:08 +08:00
Will Miao 6d45f9296b i18n: translate the recipe workflow badge tooltip 2026-10-10 09:06:56 +08:00
Will Miao 377a1337db feat(recipes): show a has-workflow badge on recipe cards 2026-10-10 09:02:48 +08:00
Will Miao e1b102fc64 i18n: translate the recipes 'Has Workflow' filter keys 2026-10-10 08:35:26 +08:00
Will Miao 587d952604 feat(recipes): add 'Has Workflow' filter to the recipes filter panel 2026-10-10 08:33:54 +08:00
Will Miao 2e5f1db6df i18n(download): translate download root rules strings in all locales
Fill the 26 [TODO: Translate] placeholders per locale (24
settings.downloadRootRules.* keys + modals.download.routedByRule.*)
across zh-CN, zh-TW, ja, ko, fr, de, es, ru, he, following the
i18n guidelines: routing/root/base-model/tag terminology per §2,
R3 Latin model types and example token lists kept verbatim, R6
register, per-locale UI-label quoting in routedByRule, and fragment
style for the summary/chip strings. The es tag chip uses "Tag" (the
full "etiqueta" overflows the badge; the tooltip keeps the full term).

Source fix per R9/R7: en.json summary.selectBaseModels/enterTags used
U+2026 — now ASCII "..." (JS fallbacks and vitest assertions moved
with it; truncateMiddlePath's … stays, it is a path-elision character,
not a locale string).

Guidelines doc: new §2 "Download root rules feature" terminology
subsection + 2026-10 status entry.
2026-10-10 08:15:35 +08:00
Will Miao b31e831c57 fix(example-images): stop permanently blacklisting models on transient failures
The example-images download loop marked a model as failed in
.download_progress.json whenever its metadata had no civitai images or
processing raised any exception. Since failed_models is a permanent skip
list for non-force runs (and excluded from the pending pre-check), models
whose metadata simply had not been fetched yet — or that hit a transient
error — were never retried. Users had to delete the progress file to
unstick them, and the blacklist re-grew afterwards.

- Distinguish 'metadata not fetched yet' (no civitai payload) from
  confirmed absences (civitai entry present but image-less, or the model
  is known to be gone from CivitAI via civitai_deleted/from_civitai).
  Only confirmed absences are marked failed; the rest stay pending.
  Retrying pending models only re-reads local metadata, so no extra
  provider API calls are introduced.
- Transient processing errors no longer add the model to failed_models.
- Add unblock_failed_example_image_models() to remove hashes from the
  persisted failed list (library-scoped and legacy locations) and from
  the live download manager's in-memory state.
- Hook it into MetadataSyncService.update_model_metadata so a successful
  metadata fetch/relink immediately re-enables example image downloads
  for that model — no TTL polling needed.
2026-10-10 08:09:08 +08:00
Will Miao 7a76d44a20 fix(settings): raise specificity of download root rule grid override
.mapping-controls is defined later in the file than the rule-row
styles, so the single-class .download-root-rule-controls selector lost
the cascade and the 1fr 1fr auto template still applied (wide chip
track, actions wrapping to an implicit row). Use the compound selector
.mapping-controls.download-root-rule-controls so the four-column
template wins regardless of source order.
2026-10-10 07:55:54 +08:00
Will Miao 18a296e054 fix(settings): keep match-type chip compact in download root rule rows
.mapping-controls is a 3-column grid (1fr 1fr auto) built for the path
mapping rows; rule rows have 4 children, so the chip stretched to fill
its 1fr track and the action buttons wrapped to an implicit second row.
Give .download-root-rule-controls an explicit 4-column template and pin
the chip to its natural width.
2026-10-10 07:53:31 +08:00
Will Miao dce7da3d58 feat(settings): declutter download root rule rows
- Move match-type selection out of rule rows: two side-by-side add
  buttons (+ Base model rule / + Tag rule) at the group bottom; rows
  show a static BM/Tag chip instead of a select. Changing a rule's
  type is now delete-and-recreate. Removed setDownloadRootRuleMatchType.
- Replace Edit/Collapse text buttons with a chevron-down icon that
  rotates when expanded (aria-expanded preserved, i18n tooltip kept).
- Truncate root dropdown option labels in the middle via
  truncateMiddlePath(path, 24, 16); option.value, option.title and
  select.title keep the full path, and (default) suffix is appended
  after truncation.
- Summary wording: empty drafts show "Select base models…" / "Enter
  tags…"; more than two items collapse to first two + "+N more"; tag
  summaries drop the "tags:" prefix since the chip already conveys it.
2026-10-10 07:47:24 +08:00
Will Miao af90fe873d feat(modal): open model metadata sidecar location from the model modal
Centralized sidecar storage moves .metadata.json into a mirror tree, so
the Location link in the model modal could no longer reveal a model's
sidecar. The Location row now shows an inline metadata icon button
(centralized mode only) that calls a new
POST /api/lm/models/open-sidecar-location endpoint: the backend resolves
the sidecar through py/utils/sidecar_paths, reveals the .metadata.json
selected in the file manager (falling back to the sidecar directory when
it has not been written yet), and returns the path for clipboard mode on
headless/Docker sessions. The platform reveal logic is factored into a
shared FileSystemHandler._reveal_file helper. All 9 locales translated.
2026-10-10 07:41:48 +08:00
Will Miao 7937aeaa04 fix(filesystem): select files via FileManager1 DBus on Linux
open-file-location now asks the freedesktop org.freedesktop.FileManager1
ShowItems interface to reveal the file selected (GNOME Files, Dolphin,
Nemo, Caja, recent Thunar), falling back to plain xdg-open when the
interface is unavailable. Also align the Linux branch with the other
location handlers: headless sessions return clipboard mode instead of
silently failing.
2026-10-10 07:15:21 +08:00
Will Miao c372a688de feat(download): support tag-based rules and show matched rule in download modal
Download root rules now accept match_type "tag": the rule hits when any
of its free-text tags equals one of the model's CivitAI tags (trimmed,
case-insensitive), so e.g. all "character" models can be routed to a NAS
root regardless of base model. Rules stay ordered — first match wins
across mixed base_model/tag rules.

- settings normalize keeps tag entries as free text (case-insensitive
  dedupe, no base-model whitelist); resolve_download_root gained a tags
  parameter and a match_download_root_rule helper
- the download flow passes the version's model tags into rule resolution
- the routing endpoint accepts tags and reports the matched rule; the
  versions endpoint ships model-level tags with each version so the
  location step can preview tag matches
- the settings rule editor gained a per-rule match-type dropdown; tag
  rules edit a comma-separated list (committed on blur/Enter) instead of
  the base model checkbox panel
- the download confirmation modal shows "Routed by rule: ..." next to
  the preselected root
2026-10-09 22:32:25 +08:00
Will Miao 198232f3a4 feat(settings): show default root cue in download root rules editor
Each rule group now shows which root is the type's default ('Default:
{root} — unmatched downloads go here'), and the default root's option in
the per-rule root dropdown carries a '(default)' suffix. Comparison is
deliberately loose (separators, trailing slashes, case) since this is
display-only. Changing a default root select in the Library section
re-renders the rules editor so the cue never goes stale.
2026-10-09 22:08:13 +08:00
Will Miao 406096c0e1 fix(metadata): record the actual checkpoint on Set/Get and stale-cache workflows
trace_model_path dead-ended on KJNodes GetNode virtual links and fell back
to the first entry in MODELS, which could be a stale checkpoint name
resurrected from the process-lifetime node_cache — images ended up with a
wrong or missing Hashes.model.

- SetNodeExtractor now records MODEL passthrough as a model_variable entry
  (variable name -> source node id), cached per node like other metadata
- trace_model_path resolves GetNode variable references through those
  entries instead of giving up (last SetNode wins, per KJNodes semantics)
- _fill_missing_metadata validates cached checkpoint names against the
  node's current widget values and rebuilds stale entries from the current
  inputs instead of resurrecting the previous model
2026-10-09 20:44:39 +08:00
Will Miao 5dc2109d7c feat(download): add download root rules routing downloads by base model
Add a download_root_rules setting: ordered per-type rules
({match_type: base_model, match: [...], root: path}) that replace the
default download root when 'use default paths' is on. The first matching
rule wins; unmatched downloads fall back to default_*_root. Rules are
consulted under the final download type, so a checkpoint routed to the
unet roots uses the unet rule set.

- settings_manager: normalize/get/set with root validation against the
  configured roots (folder_paths + extra_folder_paths); invalid rules
  are dropped with a warning
- download_manager: apply the resolved root in the use_default_paths
  branches for lora/checkpoint(unet)/embedding downloads
- download routing handler: expose resolved_root so the download
  confirmation modal preselects the rule-routed root
- settings UI: per-type rule editor in the Organization section with
  base-model multi-select panels, root dropdowns, reorder and remove
2026-10-09 11:50:26 +08:00
Will Miao 1a6b0f78a3 chore(libraries): deprecate multi-library support ahead of removal
Warn at startup (log + dismissible startup message) when settings.json
contains more than one library, marking the feature as deprecated while
keeping it fully functional. Removal is planned for one of the next
releases, at which point all libraries' folder paths will be merged into
a single library. Multiple folder roots within one library remain fully
supported and unaffected.

- SettingsManager: add _warn_if_multi_library() invoked from
  _migrate_to_library_registry
- Settings UI: label the Active Library selector as deprecated and note
  the removal plan in its help text (en.json)
- Docs: add deprecation banners to multi_library_design.md and
  library-switching.md
- Remove dead code: SettingsManager.update_active_library_paths had no
  call sites
2026-10-09 11:10:04 +08:00
Will Miao 18f3208468 fix(civitai): surface missing-model diagnostics and anchor image-page URL parsing
- Return the ResourceNotFoundError detail from get_civitai_versions and
  get_model_versions_status instead of a bare "Model not found", and let
  the download modal display the server-provided message
- Anchor extract_civitai_image_id / extractCivitaiImageId to
  ^/images/<id> so API paths and partial numeric IDs are rejected, same
  as the model-page parsing fix in #1140
2026-10-09 08:12:56 +08:00
Eric-Yeh 01ffda7303 fix(civitai): anchor model-page URL parsing and improve missing-model diagnostics (#1140) 2026-10-09 08:07:00 +08:00
Will Miao fad17decdf fix(download): scope the preflight cooldown gate to the download source
The pre-flight rate-limit gate checked civarchive.com for every download,
so a cooldown armed by bulk metadata fetches blocked plain CivitAI
downloads for the whole window. Only CivArchive-sourced downloads consult
civarchive.com now.
2026-10-08 21:51:43 +08:00
Will Miao acefe1a348 fix(metadata): fail loudly when the sidecar write cannot land
update_model_metadata ignored the save_metadata result, so a fetch on a
drive that went offline mid-run was counted as success: the cache got
the fresh CivitAI payload while the durable sidecar never landed, and
every later fetch skipped the model as already fetched.

The save result now raises MetadataPersistError. In the bulk fetch path
this fails the item (reported in the summary, cache untouched, model
stays eligible for the next run); relink surfaces an honest 500.

Also corrects the stale known-limitation note in the scoped-scan plan:
a startup-offline root has been listed and re-admitted since Wave 6.
2026-10-08 07:55:47 +08:00
Will Miao 95acd6308a i18n(metadata): translate the fetch summary 'Drive Offline' stat
Reuses each locale's established rootOffline rendering from the scoped
scan feature (§2 'Scoped scan and root availability').
2026-10-08 07:49:04 +08:00
Will Miao 3468686e5f feat(metadata): skip models on offline roots during bulk fetch (#1108)
Bulk metadata refresh picked candidates purely from cache state, so the
entries the scoped scan deliberately keeps for unreachable drives were
fetched anyway — burning CivitAI rate limit on sidecar/preview writes
that cannot land.

- Filter the fetch queue by root reachability (live check via
  describe_model_roots), covering both startup-offline and mid-session
  drive-off; probing failures degrade to no filtering
- Report offline_skipped / offline_roots in the WS terminal payloads,
  the HTTP result and the completion message
- Show a "Drive Offline" stat card in the fetch summary modal when
  any model was skipped this way
2026-10-08 07:47:54 +08:00
Will Miao 842ffb1df8 i18n(sidebar): translate the recipes-folder protection strings 2026-10-07 19:39:20 +08:00
Will Miao 1805519f4f fix(sidebar): keep the recipes storage dir out of folder management
The default recipes dir (<first lora root>/recipes) is auto-created by the
recipe scanner and recorded by the model-root walks as an empty folder, so
it showed up in the folder sidebar — and since recipe files are not model
weight files, the folder delete endpoint's model_count check let a
right-click delete wipe the whole recipe library via shutil.rmtree.

- scan walks (full scan, reconcile, all-folders backfill) no longer descend
  into the effective recipes dir, and get_all_folders() filters it out of
  snapshots persisted before the exclusion existed
- delete_folder/rename_folder refuse the recipes dir and any ancestor of
  it with code "protected"
- the delete modal renders a protected state (confirm hidden, per-row
  checkbox disabled) instead of a deletable 'no models' prediction
- RecipeScanner.recipes_dir now shares the side-effect-free resolver in
  py/utils/recipes_paths.py
2026-10-07 19:38:28 +08:00
Will Miao f4d0228d6b feat(scanner): report and re-admit model roots that are unavailable (#1108)
`Config._dedupe_existing_paths()` drops roots whose directory does not exist
when the root list is built, so a drive that is switched off while LM starts was
not "an offline root" — it was not a root at all. The Refresh ▾ menu had no row
for it in either state, a full refresh reported nothing (its cached entries
survived only because they fall outside every configured prefix), and plugging
the drive back in changed nothing until the next restart.

* `Config` now records the configured (existence-unfiltered) paths per model
  type — from ComfyUI's list in plugin mode, from the active library snapshot in
  standalone mode, where the host mock filters non-existent paths itself — and
  exposes them through `configured_roots_for()`.
* `ModelScanner.describe_model_roots()` appends configured-but-unavailable roots
  with a live `reachable`, an `available` flag and their cached entry count, so
  `/roots` (and therefore the menu) offers a row in both states: greyed while the
  directory is missing, normal and clickable once it is back.
* `_reconcile_cache()` reports them as `skipped_roots` / `unavailable_paths` with
  reason `root_unavailable` and counts their cached entries in `kept_unreachable`,
  which is what makes the "N models kept" toast appear in the startup-offline
  case. Report-only: they stay outside the scan scope, so nothing about pruning
  changes. A root-scoped scan stays quiet about roots it was not asked about, and
  a folder-scoped scan only mentions a missing root that holds cached entries of
  that folder.
* `Config.admit_configured_roots()` re-runs the per-type prepare helpers against
  the configured paths and APPENDS what is readable now (plus the
  checkpoint/unet/other side maps), then refreshes the preview allowlist, so a
  drive plugged in mid-session can be scanned without restarting. Append-only and
  order-preserving by design: removing a root mid-session would let a later
  settings save persist the loss, and re-sorting would move `*_roots[0]`, which
  derives the recipes directory and the usage-stats file location. `_roots[0]`
  never moves.
* `/roots` and `/scan` admit first, so `describe_model_roots()` stays a pure read
  and a caller that never opens the menu (the browser extension) still gets a
  drive that came back.

Verified on the sandbox with drive-Z switched off before startup: `/roots`
reports `drive-Z reachable=false available=false models=60`, a full refresh
returns `skipped_roots=[drive-Z root_unavailable]`,
`unavailable_paths=[{... kept: 60}]`, `kept_unreachable=60`, and all 432 models
stay cached. Renaming the drive back while the server runs admits it
(`available=true`) and `GET /scan?roots=<drive-Z>` walks it with no restart.
3714 passed, 7 skipped; frontend 1495 passed (149 files); vue widgets 96 passed.
2026-10-07 16:38:38 +08:00