Compare commits

...
227 Commits
Author SHA1 Message Date
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
Will Miao 08a8e54d87 fix(config): keep unavailable model roots in the saved library paths
Starting ComfyUI with an external drive switched off erased that drive's path
from `settings.json`. `Config._dedupe_existing_paths()` drops paths that do not
exist at the moment the root list is built, and `save_folder_paths_to_settings()`
(plugin mode, called from `Config.__init__`) persisted `list(self.loras_roots)`
through `upsert_library(folder_paths=...)`, which REPLACES the library's paths.
One boot with the drive off was therefore enough to lose the configuration, not
just to hide the root — the same applied to checkpoints/unet/embeddings and the
other-model keys.

* `Config` now records the *configured* (existence-unfiltered) primary paths per
  model type while building the live lists, and the startup sync persists those.
  A path the host (plugin mode) or the library (standalone mode) no longer
  configures is still dropped, so removing a path keeps working.
* In standalone mode the host mock (`MockFolderPaths.get_folder_paths`) already
  filters non-existent paths out of settings.json, so the active library snapshot
  is the only record of what the user configured; `_remember_library_configured_paths()`
  reads it there and leaves plugin mode to ComfyUI's own unfiltered list.
* `_resolve_valid_default_root()` receives the configured paths in
  `allowed_paths`, so a `default_*_root` on a switched-off drive is no longer
  "repaired" onto another root.
* `_append_new_paths()` is the shared append-only union (order preserved, case
  insensitive) used by the sync.

Verified: a new config test builds the plugin-mode sync with one switched-off
drive, one readable root and one path the host dropped — the switched-off path
and the default root that points at it survive, the dropped path does not, and
the live `loras_roots` still holds only the readable root. tests/config 64 passed.
2026-10-07 16:35:29 +08:00
Will Miao 0941f99226 i18n(sidebar): translate the folder-scan strings
The sidebar's "Scan this folder" entry and its vanished-folder warning were the
last 2 [TODO: Translate] keys, so grep -c "TODO: Translate" locales/*.json is 0
everywhere again.

  scanFolder              zh-CN 扫描此文件夹 · zh-TW 掃描此資料夾 · ja このフォルダをスキャン
                          ko 이 폴더 스캔 · fr Analyser ce dossier · de Diesen Ordner scannen
                          es Escanear esta carpeta · ru Сканировать эту папку · he סרוק תיקייה זו
  scanFolderResult.missing
                          reused verbatim from each locale's existing
                          renameFolderResult.missing / deleteFolderResult.missing — the situation
                          is identical ("the folder vanished before the action ran"), so a third
                          variant of the same sentence would only be a translation drift risk.

The label follows each locale's "Check for updates in this folder" phrasing
(检查此文件夹的更新 → 扫描此文件夹, Vérifier les mises à jour dans ce dossier → Analyser ce
dossier) so the two refresh entries in the same menu read as a pair.
docs/i18n-translation-guidelines.md gains the two rows in "Scoped scan and root
availability" plus the updated status note.

Verified: python scripts/sync_translation_keys.py --dry-run reports no pending
changes, pytest tests/i18n 20 passed, frontend 1495 passed.
2026-10-07 15:26:08 +08:00
Will Miao 12930ce78c feat(sidebar): scan a single folder from the folder context menu (#1108)
The Refresh menu can scope a scan to one model root, but the folder a user is
looking at lives in the sidebar's unified tree, which merges every root into one
relative-path namespace. "Scan this folder" therefore addresses the folder, not a
root: the backend walks that relative path under every root that holds it, which
is also what makes the action safe while another drive is switched off.

Backend:
* GET /scan accepts `folder=<rel>` (alone or with `roots=`), rejected together
  with full_rebuild=true like the roots parameter. Validation reuses
  normalize_relative_folder(), extracted to module scope from ModelMoveService so
  the folder operations and the scan endpoint reject the same input (absolute
  paths, drive letters, `..` climbing) instead of each carrying its own copy.
* The reconcile summary carries `scope_label` (the folder) for a folder scope, so
  the result toast names the folder the user clicked instead of the roots it
  happens to live under; the completed WS payload carries it too.
* `folder` is a scope prefix exactly like a root: only that subtree is re-read or
  pruned, and an unreachable root keeps the entries that fall inside it.

Frontend:
* The sidebar folder context menu gains "Scan this folder" above "Check for
  updates in this folder" (they share the refresh divider); the entry is gated by
  the same supportsFolderManagement flag as the other folder operations.
* SidebarManager.scanFolder() resolves the node through the existing
  _resolveFolderCandidates() before doing anything: a folder no root holds any
  more explains itself ("no longer exists on disk") instead of scanning nothing,
  and an unresolvable multi-root node is refused rather than guessed.
* PageControls.refreshModels() and BaseModelApi.refreshModels() forward the folder
  scope, and _showRefreshSummary() prefers scope_label over the walked roots.

Verified live on the three-root sandbox with one drive switched off:
GET /scan?folder=pack000 walks drive-G and drive-Y, reports scope_label=pack000,
keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all
420 models cached. 3704 passed, 7 skipped; frontend 1495 passed (150 files); vue
widgets 96 passed. The 2 new sidebar keys are [TODO: Translate] placeholders
pending the feature owner's go-ahead.
2026-10-07 15:15:35 +08:00
Will Miao bd184559bf i18n(scanner): translate the scoped-scan strings
The scoped-scan feature left exactly 6 [TODO: Translate] keys behind, so this
is the whole pending set for all 9 locales. Renderings reuse the established
nouns (folder: zh-CN 文件夹 / ja フォルダ / ru папка / he תיקייה; "model" counts
from sidebar.deleteFolderModal) and introduce one new term, "Offline" for a
configured root that cannot be read right now:

  scopeSection        zh-CN 只扫描一个文件夹 · ja フォルダを 1 つだけスキャン
                      fr Analyser un seul dossier · ru Сканировать только одну папку
  rootOffline         zh-CN 离线 · ja オフライン · fr Hors ligne · ru Недоступен
  rootModels          zh-CN {count} 个模型 · ko 모델 {count}개 · he {count} מודלים
  refreshCompleteScoped / refreshKeptUnreachable / scanRootUnreachable
                      (counts and {scope}/{paths} stay verbatim)

Every placeholder set matches en.json, fr keeps the typographic apostrophe, and
the three CJK locales keep full-width punctuation while ko uses ASCII like the
rest of that file. docs/i18n-translation-guidelines.md gains the
"Scoped scan and root availability" terminology table plus the updated status
note, so the "no remaining placeholders" claim holds again.

Verified: python scripts/sync_translation_keys.py --dry-run reports no pending
changes, pytest tests/i18n 20 passed, and grep -c "TODO: Translate"
locales/*.json is 0 everywhere.
2026-10-07 15:04:16 +08:00
Will Miao 470d85cca6 feat(scanner): scan a single root and keep unreachable entries (#1108)
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).

Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
  scope reconcile normally, everything outside is neither re-read nor removed.
  The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
  read are kept and reported instead of removed. Sources: a configured root that
  is not reachable (drive switched off while LM runs), a directory os.walk
  failed to enter (permissions / I/O error / Windows junction to an offline
  drive), and a known first-level symlink whose target is gone
  (Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
  not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
  scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
  exposed as ModelScanner.last_reconcile_summary, returned by
  BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
  leftwards with real parent segments until unique, shared by the walk-progress
  line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
  full_rebuild=true); GET /roots gains root_details (label / reachable / cached
  count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
  own directory is unreachable - browsing the grid with a drive off used to
  strip preview references from the persistent cache.

Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
  their cached counts; offline roots stay clickable and explain themselves; rows
  are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
  entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
  an argument-forwarding refreshModels) so a page facade cannot drop them: the
  first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
  to the info style instead of rendering an unstyled box.

Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
2026-10-07 15:01:26 +08:00
Will Miao 2dcaf6a30e feat(sidebar): manage folders that live under several model roots
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
2026-10-06 17:14:07 +08:00
Will Miao 5ebf5aeccc fix(scanner): keep folder records honest across the model roots
The recorded folder list is a union over the model roots keyed by relative
path, but remove_known_folder() dropped an entry unconditionally. Deleting
<rootA>/test in the sidebar therefore hid a "test" that <rootB> still held:
the node disappeared from the next tree load and came back after the next
scan, which reads as "the delete did not work". The same call purged cache
entries by relative folder, so removing an empty <rootA>/test2 also evicted
the model cards of <rootB>/test2 until the next scan, and
rename_known_folder() re-keyed both the recorded folder and the "folder"
field of models that never moved.

* _folders_present_on_disk() answers "which of these relative folders does
  some root still hold?" with stats off the event loop (model roots can live
  on slow network shares) and reports nothing for stand-in scanners without
  roots, which preserves their previous behaviour.
* remove_known_folder(folder, absolute_path=None) keeps the entries another
  root still owns and purges by the removed directory's absolute path when
  the caller knows it. Without a path the relative-folder filter stays as the
  documented fallback: it prunes the entry (disk-verified either way) but
  cannot tell same-named copies apart.
* rename_known_folder() re-adds the survivors of the old name and only
  touches cache entries whose file_path sits inside the renamed directory.

Tests: the two delete tests now remove the directory from disk first, which
is what the contract always assumed, plus three new scanner tests (a twin
keeps the entry, the entry goes once no root holds it, the twin's cards
survive the purge), one for the legacy fallback and one for a rename that
leaves a twin behind.
2026-10-06 17:13:57 +08:00
Will Miao a48855d576 i18n(scanner): translate the reconcile walk progress strings
The walk-progress feature left exactly one [TODO: Translate] key behind,
common.scanProgress.walkFiles ("{count} files"), so this is the whole
pending set for all 9 locales. Each rendering reuses the locale's
stages.count_models noun for "files":

  zh-CN {count} 个文件          zh-TW {count} 個檔案
  ja    {count} 件のファイル      ko    파일 {count}개
  fr    {count} fichiers        de    {count} Dateien
  es    {count} archivos        ru    {count} файл(ов)
  he    {count} קבצים

{count} arrives pre-formatted (toLocaleString), so no locale adds digit
grouping, and the string stays a bare fragment: the root labels, the
parentheses and the " | " before the ETA are composed by
static/js/api/baseModelApi.js. ru uses файл(ов) because the counter ticks
live (notEmptyMessageCount precedent).

Also document the new surface in docs/i18n-translation-guidelines.md:
a status note plus the "Scan progress (walk phase)" terminology section
recording the per-locale renderings and the fragment/composition rule.

Verified: python scripts/sync_translation_keys.py --dry-run reports no
pending changes, pytest tests/i18n 20 passed, and
grep -c "TODO: Translate" locales/*.json is 0 everywhere.
2026-10-06 12:42:08 +08:00
Will Miao 14e660e876 feat(scanner): report and parallelize the reconcile walk
A regular Refresh walked every configured root sequentially, so a full
25 TB drive delayed the roots behind it, and the dialog sat on "Checking
for changes..." at 0 % for the whole walk with no way to tell it was
working. On a cold external drive the walk itself dominates the cost, so
the fix is to overlap the drives and to show what the walk is doing.

Backend (py/services/model_scanner.py):
* Extract the per-root walk into the synchronous _walk_root_for_reconcile()
  worker and merge its results on the event loop afterwards, in configured
  root order: which business path wins a file reachable through several
  roots must not depend on the order the workers happened to finish in.
* Group roots by device (_root_device_key: drive letter on Windows, st_dev
  on POSIX) and run one worker per device. Roots sharing a device stay
  sequential, so directory claims and the overlap dedup (#871, #1041) keep
  their configured-order semantics; different devices run in parallel.
* Track walk progress per root (_ReconcileWalkTracker), weighted by each
  root's cached entry count because the real file count is only known once
  the walk ends. The bar splits into walk (0-50 %) and new-file (50-99 %)
  phases so it never jumps backwards, and the walk broadcasts files seen,
  active roots and the ETA counters.
* Replace the Windows case-insensitive fallback -- a scan of every cached
  path per miss, i.e. O(files x cached) -- with a lazily built lower-cased
  index (_CachedPathLookups, also now guarding the realpath alias index for
  worker threads), and lift the os.name == "nt" gate into the module-level
  _CASE_INSENSITIVE_PATHS so the branch is testable off Windows.
* Excluded-model membership is a set lookup instead of a list scan.

Frontend:
* render the walk phase as "Checking for changes... <roots> (N files)" with
  the ETA, and reset the ETA tracker when the stage changes: the per-file
  rate of counting files says nothing about processing them.
* add common.scanProgress.walkFiles; the other locales keep the sanctioned
  [TODO: Translate] placeholder until the translation pass.

Verified: no-change reconcile over 10k files/500 dirs 101 ms and 10k/5000
dirs 230 ms (was 93/229 ms, within noise); a two-device sandbox walk runs
both roots concurrently, names them in the progress messages and finishes
with added=15, removed=0; 3665 passed, 7 skipped; frontend 1456 passed,
vue widgets 96 passed.
2026-10-06 12:40:57 +08:00
Will Miao 8cd53c20f8 refactor(nodes): resolve dynamic inputs without inspect.stack()
The Prompt and Lora Stack Combiner nodes expose unbounded dynamic input
slots (trigger_wordsN / lora_stackN). They resolved them by having
INPUT_TYPES() return a custom lookup object, but only when the caller was
ComfyUI's get_input_info() -- detected with inspect.stack(). That frame
inspection is what the registry security scan reports as
python_anti_debugging under the obfuscated-code admin tag.

Make the lookup a dict subclass instead, so INPUT_TYPES() can always
return it:

  * /object_info (server.py) json.dumps INPUT_TYPES() directly, and a
    dict subclass serializes its stored entries -- byte-identical to the
    plain dict that was returned before.
  * input_order (list(value.keys())), validate_inputs'
    set(class_inputs["optional"]) and every other iteration still see only
    the static slots.
  * get_input_info() (graph.py) keeps resolving dynamic names through the
    overridden __contains__/__getitem__, which no longer depends on who
    the caller is.

The one behaviour change is in execution.py:get_input_data -- a dynamic
input passed as a constant rather than a link now reaches the node
instead of being silently dropped. These inputs are declared forceInput,
so the frontend only offers links; where it can happen the new behaviour
is the intended one.

Verified against ComfyUI's own consumer code: json.dumps output, keys(),
set(optional) and get_input_info() lookups all match the old behaviour,
and the two nodes no longer cross-resolve each other's slots.
3657 passed, 7 skipped.
2026-10-05 15:23:16 +08:00
Will Miao 7e57f12796 refactor(nodes): drop importlib from the pytest import fallback
The `except ImportError` branch in __init__.py exists because pytest's
Package collector walks up from tests/ while __init__.py exists, which
makes the repo root a package node and imports this file as a top-level
module (__package__ == ""). Relative imports cannot resolve there.
ComfyUI itself always loads the directory as a package, so the branch is
test-only -- verified by replaying nodes.py:load_custom_node().

`importlib.import_module("py.nodes.prompt").PromptLM` is equivalent to
`from py.nodes.prompt import PromptLM`: nothing here is lazy and nothing
avoids a cycle, so the indirection bought nothing. Removing it also drops
the python_bytecode_manipulation finding (any-code-execute +
obfuscated-code) the registry security scan reports against __init__.py.

Verified: probe during the suite shows the fallback still runs with
__package__ == "" and builds all 21 NODE_CLASS_MAPPINGS entries;
3653 passed, 7 skipped.
2026-10-05 15:18:19 +08:00
Will Miao 0e3897f09b chore(registry): trim the published archive to runtime files
The registry security scan flags a node version on ANY finding, even
severity "info", so every file in node.zip is scan surface. Dev-only
trees (tests/, docs/, scripts/, .agents/, Vue widget sources) accounted
for ~44 of the 115 findings that flagged 1.2.2-1.2.4.

comfy-cli builds the archive as `git ls-files` minus `.comfyignore`
matches, so this drops 501 of 1033 tracked files while keeping every
runtime path: py/, web/, static/, templates/, locales/,
example_workflows/, data/supporters.json, refs/, standalone.py.

Dropping vue-widgets/ is deliberate: the prebuilt bundle ships in
web/comfyui/vue-widgets/, and py/vue_widget_builder.py skips its mtime
check when src/ is absent, so end-user installs no longer risk an
npm install at startup.
2026-10-05 14:58:13 +08:00
Will Miao db50632e83 docs(update): link the filed upstream request (civitai/civitai#5384)
Filed as willmiao with the 138-word text from §13.1; the internal notes in §13.2
(write/read asymmetry, evidence table) stayed out of the issue.
2026-10-05 14:52:13 +08:00
Will Miao d141c29ced docs(update): cut the upstream request down to the ask
The first draft read as a design document for the CivitAI team: it explained how
to change toPublicPaidAccessDto, cited discountedTerms, and described how we read
prices from model pages today. Two of those do not belong in an issue.

- no implementation guidance: they know their service, and withholding prices may
  be a deliberate product decision (the code comment says pricing belongs to the
  purchase flow), so the request has to argue the need rather than the diff
- no description of our current page reading: it is our approach, it shifts the
  thread from the feature to our behaviour, and it invites an objection that has
  nothing to do with the ask

The filed text is now 138 words: ask, why (the API gives the gate and the early
access end date but not the price, which is the other half of "wait or pay now"),
one credibility clause naming the integration, and one scope concession (public or
authenticated-only). The evidence table and the write/read asymmetry stay in the
plan as internal notes, explicitly marked as not for the issue.
2026-10-05 14:42:37 +08:00
Will Miao dcc0add6b0 docs(update): draft the upstream API request for public Buzz prices
No existing civitai/civitai issue asks for this (searched paidAccess, "download
price", "buzz price API" and every open [API Feature Request]), so the draft is a
new issue using that repo's title convention.

The argument leans on how small the change is there: toPublicPaidAccessDto
already receives a PaidAccessRow carrying `terms` and `sales` and returns only
{permanent, endsAt}; discountedTerms already resolves sale prices; and the write
path already accepts the same terms via updateModelVersionPaidAccessSchema. The
reads withhold exactly what the writes accept.

It also documents why the page is not a workaround: civitai.red challenges
non-browser clients (403 for any User-Agent), while civitai.com and civitai.green
404 mature models to anonymous visitors, so mature models have no readable price
source at all.

Also marks the P4 upstream task as drafted.
2026-10-05 14:37:18 +08:00
pixelpaws 0c5bf329e9 Merge pull request #1137 from willmiao/feat/paid-model-price-tracking
feat(updates): show Buzz download prices and surface obtainability of available versions
2026-10-05 12:32:42 +08:00
Will Miao fbd2e6a2eb Merge remote-tracking branch 'origin/main' into feat/paid-model-price-tracking
# Conflicts:
#	docs/i18n-translation-guidelines.md
2026-10-05 12:28:35 +08:00
pixelpaws 15dda1f168 Merge pull request #1138 from willmiao/feat/showcase-vertical-layout
feat(ui): add optional vertical list layout for model modal showcase
2026-10-05 09:39:19 +08:00
Will Miao 9dacaf06f7 feat(ui): add optional vertical list layout for model modal showcase
Bring back the pre-v1.2.2 classic vertical list for example images as a
persisted showcase_layout setting (gallery stays the default), switchable
from both a Settings select and an in-modal segmented toggle.

- Revive the vertical list renderer (adapted from 4a6042d0) as
  VerticalListView.js, reusing the shared MediaUtils/MetadataPanel
  infrastructure; legacy CSS scoped under .showcase-vertical
- Dispatch showcase rendering on state.settings.showcase_layout;
  collapsed bar, empty/filtered states and import flow stay shared
- Keep the back-to-top button available in vertical mode (the
  showcase-expanded class that hides it now only applies to the gallery,
  whose thumbnail strip occupies that corner)
- Persist the setting via DEFAULT_SETTINGS + DEFAULT_SETTINGS_BASE and a
  new select under Settings > Layout Settings
- i18n: 6 new keys translated in all 9 locales; terminology recorded in
  docs/i18n-translation-guidelines.md

Closes #1136
2026-10-05 09:38:48 +08:00
Will Miao 977bbffa67 i18n(ja): shorten the "Free Now" badge to 無料化
無料になりました (8 characters) is a sentence, not a badge label: it sits next
to 有料 (2) and 早期アクセス (6) in the version row. 無料化 is three characters
and names the state transition this badge actually marks - it only appears when
a version's gate lapsed, never for a version that was free all along. The
tooltip keeps the full sentence.
2026-10-05 09:17:23 +08:00
Will Miao 98ba319735 i18n(update): translate the Buzz download price strings
Completes the 13 keys the obtainability feature left as [TODO: Translate], in all
9 locales: the two gate-event counts, the sale / Blue Buzz / "Free Now" / early
access end-date badge strings, and the six settings.priceTracking strings plus the
section header.

- Buzz and Blue Buzz stay as-is everywhere: they are CivitAI currency names, not
  translatable words (R3)
- register follows each file's existing norm (你 zh-CN, 您 zh-TW, Sie de, tú es,
  вы ru); fr keeps the file's ASCII apostrophe style; no full-width punctuation
  leaked into the Latin/Cyrillic/Hebrew locales
- two source fixes came with the pass: the unused settings.priceTracking.label key
  is removed (no template renders it - the toggle uses enabled/enabledHelp), and
  settings.sections.priceTracking now reads "Buzz Download Prices" so the header
  matches what the feature does: prices are displayed, nothing is tracked for
  alerts
- the locale files were edited by exact-value replacement rather than re-serialised,
  so each diff is 13 lines and the per-file indentation is untouched (R1)
2026-10-05 09:13:58 +08:00
Will Miao cbe9d58522 docs(update): the model card cannot carry a single price
Live model 958009 has 37 gated versions across 6 price points, and the owner's
own library has a model whose two early access versions end on different dates,
so an 'Update - 500 Buzz' badge would be fabricated rather than summarised. The
uniformity that made it look plausible is an artefact of floor pricing. Prices
stay in the version list; only a deadline is well defined at model level.
2026-10-05 09:05:58 +08:00
Will Miao e42d649df0 refactor(update)!: make obtainability a property of updates, not a surface
The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:

- the alert population included versions the user already owns (neither the event
  generator nor the query filtered on is_in_library; in the owner's library 28 of
  52 gated versions were already downloaded, so most "alerts" were about files
  already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
  panel had three indistinguishable causes and read as a broken feature.

The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.

- both alert-state columns are removed from the schema rather than left dead; a
  database created by an unreleased build has them dropped on open (native
  ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
  price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
  it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
  locales are removed; the setting keeps only the enable flag and the refresh
  interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
  API, only the number is best-effort, and that is our plumbing, not the user's
  problem

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
2026-10-05 08:54:08 +08:00
Will Miao 156d2e5eb9 fix(ui): explain a 0 Buzz threshold in the price alerts panel
The default threshold is 0 ("only tell me when a version becomes free") and the
settings copy says so, but the panel did not: a real instance with 52 priced paid
versions and an untouched threshold showed "Nothing is under your price threshold
right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads
as a broken feature.

- the payload now carries pricedCount, so the empty state can say how many paid
  versions already have a known price
- when the threshold is 0 and prices are known, the empty state says so and
  points at Settings - Library instead of implying there is nothing to show
- the read-time threshold comparison means setting one takes effect immediately;
  measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44,
  500 -> 48, 5000 -> 51
2026-10-04 20:28:57 +08:00
Will Miao 425d90a912 fix(update): decouple price capture from the metadata TTL
Found in a real instance: after enabling price tracking, a normal "Check updates"
captured exactly one price out of 718 models, so the alerts panel looked broken
while the log said the refresh completed.

Price capture only ran when the version list was re-fetched, so it inherited the
metadata TTL: with 24 h metadata and 24 h price TTLs, only the handful of models
whose metadata happened to be stale that round were ever priced.

- the cached record already carries the gate, so the price pass now runs off
  whichever version list is available (freshly fetched or stored) and applies the
  result without touching last_checked_at, so a price-only pass cannot silently
  extend the metadata TTL
- a failed attempt now satisfies the price TTL, so a mature model whose page no
  host will serve is not retried on every single update check
- an explicitly forced check re-prices within the TTL

Verified by copying a real instance's update DB into a sandbox and running a
non-forced check: bulk metadata fetches 0 (version lists entirely from cache)
while priced versions went 1 -> 20 and the panel listed 19 alerts.
2026-10-04 20:20:50 +08:00
Will Miao ec5fef512b fix(update): read model-page prices from a host that answers
End-to-end verification against the live site found the price capture broken for
a whole class of users: the civitai page hosts are not interchangeable, and the
user's civitai_host preference was silently fatal. With civitai_host=civitai.red
the update DB held zero prices even with tracking enabled.

- civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge,
  403 for any User-Agent, aiohttp and httpx alike), while civitai.com and
  civitai.green answer normally for anonymously visible models and 404 for
  mature ones. An earlier manual check with curl passed on TLS fingerprint luck,
  which is why this was missed.
- get_model_prices now tries the configured host first, then the others, and
  takes the first parseable payload. The host that worked is remembered, and a
  host that refuses outright is parked for 15 minutes so a library full of
  mature models does not pay three requests each; a 404 is model-specific and
  does not park the host. Links keep using the configured host, which is where
  the user's own browser has clearance.
- Mature models still have no price source anywhere, so that is now stated
  instead of silent: price_check_attempted_at separates "tried and unreadable"
  from "never looked", gated versions show a muted "Price unavailable" badge,
  and the alerts panel reports unavailableCount.
- Failures are logged at warning level, once per host per TTL, with the
  per-host reason, instead of only at debug level.
- The recorded alternatives (internal tRPC with the user's API key, or an
  extension-assisted fetch from the user's browser) and the strengthened
  upstream ask for a public price field are documented in the plan.
2026-10-04 20:00:29 +08:00
Will Miao 7ed19c185c feat(update): add a Buzz price alerts panel to the notification bell
P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
2026-10-04 15:08:48 +08:00
Will Miao ad2402724b feat(update): track buzz prices and alert below a threshold
CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
2026-10-04 08:53:24 +08:00
Will Miao 5f4054265d fix(update): treat a timed paidAccess gate with no recorded end as active
CivitAI only returns a non-null paidAccess for an *active* gate: a lapsed gate
stays in the database as a tombstone and is filtered out server-side, so
{"permanent": false, "endsAt": null} — a timed gate whose window end has not
been recorded yet — is still enforced. Verified live: on model 1802980 that
version reports canDownload: false while its lapsed siblings report true.

Both the update service and the download gate dropped that shape, so such
versions read as free and "Hide Early Access Updates" missed them — the class
of bug reported in #1060.

The interpretation now lives in py/utils/paid_access.py and is shared, so the
badge, the update filter and the download warning cannot disagree.
2026-10-04 08:53:12 +08:00
pixelpaws f94c6b7497 Merge pull request #1135 from willmiao/feat/openmodeldb-support
feat(metadata): add OpenModelDB support for upscalers
2026-10-03 21:50:17 +08:00
Will Miao cf3bff9922 Merge remote-tracking branch 'origin/main' into feat/openmodeldb-support
# Conflicts:
#	docs/i18n-translation-guidelines.md
2026-10-03 21:48:37 +08:00
Will Miao 43ef1e86f6 feat(i18n): translate OpenModelDB settings keys in all 9 locales 2026-10-03 21:19:24 +08:00
Will Miao 35f1ced41a feat(metadata): add OpenModelDB metadata provider and model source for upscalers
Add OpenModelDB (openmodeldb.info) as a metadata and download source for
the existing upscaler model type.

Metadata:
- New OpenModelDBClient: fetches the site's bulk JSON dumps, caches them
  on disk (24h TTL + ETag revalidation), and builds a local sha256 index
- New OpenModelDBModelMetadataProvider adapts catalogue entries to the
  CivitAI-shaped version dict contract; registered in the fallback chain
  behind the enable_openmodeldb_api setting (default on), gated to the
  upscaler sub-type so other model types never trigger the dump download
- Persisted provenance uses metadata_source "openmodeldb" plus a nested
  openmodeldb block (page URL, architecture, scale, license)

Images: paired-image LR/SR URLs are ephemeral imgdiff.net sessions, so
displayable images come from the site-hosted auto-generated thumbnails
(model-level cover leads images[], per-image thumbs for the rest); the
original comparison URL is kept in meta.comparisonUrl.

Downloads:
- New OpenModelDBSource (flat model ids, omdb: group prefix) with
  resource filename derivation that recovers names hidden mid-path
  (mediafire) or synthesizes {id}.{type} for folder links
- HTML-gateway mirrors (mediafire/mega/drive) are rejected with a clear
  manual-download hint instead of silently saving an HTML page as .pth
- ModelSource base gains is_valid_source_id / default_subdir_parts /
  resolve_download_url hooks so flat-id sources need no platform branches

UI: "View on OpenModelDB" link in the model modal (downloaded and
hash-enriched models), settings toggle next to the CivArchive one.
2026-10-03 21:13:07 +08:00
pixelpaws b36f2099ee Merge pull request #1134 from willmiao/perf/bulk-rename-apply
perf(rename): make bulk filename-template apply O(n) instead of O(n²)
2026-10-03 15:39:58 +08:00
Will Miao 896ce5eddb perf(rename): make bulk filename-template apply O(n) instead of O(n^2)
Applying a filename template to a large library re-did O(library) work for
every renamed file: a full natsort resort plus whole-table SQLite rewrite and
download-history resync after each rename, and a full scan plus resort of the
entire recipe collection per renamed LoRA. On a 20k-model library with 300k
recipes on a HDD this pushed "Apply to Library" into multi-day runs.

- ModelScanner.defer_cache_persist(): bulk loops update the in-memory entry
  and indexes only; resort + persist + download-history sync run once at
  context exit, forced even on cancellation/error since files are already
  renamed on disk. Single-rename callers keep immediate per-call behavior.
- RecipeScanner.build_lora_hash_index(): one-shot hash -> recipes index so
  per-file lookups are O(1); update_lora_filename_by_hash gains hash_index /
  defer_maintenance params, with a single finalize_bulk_filename_updates()
  resort at the end of a bulk session.
- ModelLifecycleService.bulk_rename_session() / BulkRenameContext wire the
  deferred path through rename_model (hash index built lazily on first
  recipe-touching rename).
- Blocking os.rename sequence offloaded via asyncio.to_thread so one file's
  HDD I/O no longer stalls the event loop (no cross-file parallelism).
- Skip logic, per-batch WebSocket progress, cancellation, and result
  counters unchanged.
2026-10-03 14:31:10 +08:00
Will Miao f8aba393fe feat(models): show Civitai model/version ids in model modal, always-on hash/id search
- Model modal hash footnote now shows Civitai model id and version id
  (right-aligned, quick-copy buttons); hidden for non-Civitai models
- Hash/id exact search (sha256/autov2/autov3/civitai ids) is now always
  on: the search-options "hash" toggle is removed and the search_hash
  query param is silently ignored for API compatibility
- Footnote render condition relaxed so autov3-only and id-only models
  still show the line
- i18n: 4 new keys translated in all 9 locales; filters.hash key removed
2026-10-03 09:46:46 +08:00
Will Miao 515469054c feat(i18n): translate routing-override keys in all 9 locales 2026-10-02 21:50:19 +08:00
Will Miao a5bd3845da feat(downloads): allow manual checkpoint/diffusion root override in download modal 2026-10-02 21:40:06 +08:00
Will Miao 034660d8c4 feat(downloads): return structured 429 rate-limit responses with retry_after
On a CivitAI/CivArchive 429, download-model and download-model-get now
return HTTP 429 with {"reason": "rate_limited", "retry_after": N}
instead of a generic 500 string, and the queue row goes back to
"queued" rather than history as failed — so queue drivers can
auto-pause and retry later instead of burning through the queue.

- new DownloadRateLimitError carrying retry_after/host (opt-in via
  raise_on_rate_limit on Downloader; other call sites keep the legacy
  string behavior)
- fail-fast pre-flight gate in DownloadManager consults
  RateLimitCoordinator before acquiring the semaphore slot: hosts in
  cooldown get an immediate structured 429, no HTTP request attempted
- best-effort 429 detection for the aria2 backend
2026-10-02 21:16:06 +08:00
pixelpaws 2a667df98c Merge pull request #1132 from willmiao/fix/diffusion-routing-default
fix(downloads): route unknown checkpoint baseModels to diffusion models by default
2026-10-02 17:09:56 +08:00
Will Miao 693e7e8dca feat(i18n): translate unknown-base-model routing keys in all 9 locales
Fills the 4 settings.unknownBaseModelRouting.* placeholders plus the
leftover doctor.issues.sidecar_mirror_orphans.title placeholder per the
feature owner's request, following docs/i18n-translation-guidelines.md:
option labels reuse each locale's checkpoints.modelTypes renderings,
base model follows the §5 matrix, and punctuation/register match each
file's conventions. Adds the feature's Status note and a §2 term-map
subsection for 'routing'.
2026-10-02 10:58:54 +08:00
Will Miao 4ece4b42ff refactor(settings): move unknown-base-model routing to Library > Folder Settings
The setting decides which library an unknown-model download lands in, so
it belongs next to the default-root selects rather than General >
Downloads. Element id, settings key and i18n keys are unchanged, and
loadSettingsToUI() populates it by getElementById on every modal open,
so no JS changes are needed.

Also drop "(recommended)" from the diffusion-models option label; the
default is already conveyed by pre-selection.
2026-10-02 10:47:17 +08:00
Will Miao 7b2a108596 fix(downloads): close routing gaps and map CivitAI ModelType.UNet to the checkpoint branch
Cross-checked both baseModel lists against CivitAI's official
baseModelRecords (packages/civitai-shared src/basemodel.constants.ts):

- CHECKPOINT_BASE_MODELS gains SD 2.0/2.1 768, SD 2.1 Unclip, SDXL 0.9 /
  1.0 LCM / Turbo / Distilled, Playground v2 and Stable Cascade
  (unCLIP-style but CheckpointLoader-loaded).
- DIFFUSION_MODEL_BASE_MODELS gains SVD XT, LTXV 2.5, Flux 3 Video,
  Wan Image 2.7, Wan Video 2.7 / 3.0, HiDream-O1, Boogu and the Ming
  Image Design families. API-only (Kling/Sora/Veo/Imagen...), 3D and
  audio baseModels are intentionally skipped.
- Pony V7 exclusion now backed by live-API evidence (model 1901521 is
  AuraFlow-architecture shipping .gguf variants).

CivitAI has no model-level diffusion ModelType: DiT models are uploaded
as "Checkpoint" or "UNet", with only uploader-chosen file types to tell
them apart. model.type "unet" previously fell through type derivation
and failed with 'not supported for download'; it now goes through the
checkpoint branch in both the download manager and the download routing
endpoint, so the standard chain (file type -> baseModel lists -> unknown
default) applies.
2026-10-02 10:34:59 +08:00
Will Miao 3aa32120df fix(downloads): route unknown checkpoint baseModels to diffusion models by default
CivitAI labels new DiT architectures (MiniMax H3, future Flux/Wan/Qwen
variants) as model.type "Checkpoint" with plain "Model" file entries,
so the DIFFUSION_MODEL_BASE_MODELS allowlist could never keep up and
such downloads were mis-routed to the checkpoint roots (e.g. model
2877206 / version 3374439). The set of true full-checkpoint families is
closed, so the baseModel fallback is inverted:

1. file type UNet/Diffusion Model -> unet (unchanged)
2. baseModel in DIFFUSION_MODEL_BASE_MODELS (now incl. MiniMax H3) -> unet
3. baseModel in new CHECKPOINT_BASE_MODELS (SD 1.x/2.x/3.x, SDXL, Pony,
   Illustrious, NoobAI) -> checkpoint
4. unknown/empty baseModel -> new unknown_base_model_routing setting,
   defaulting to diffusion models

The setting is exposed under Settings > Downloads, validated in
SettingsManager, and threaded into both the download manager and the
download routing endpoint so they keep agreeing.
2026-10-02 09:58:01 +08:00
Will Miao 2193ec8f38 fix(example-images): support importing for Other models and pending hashes
Two gaps kept 'import example images' from working on the Other page:

- import_images/delete_custom_image/set_example_image_nsfw_level only
  searched the lora/checkpoint/embedding scanners, so Other-category
  models were never found ('Model with hash ... not found in cache').
  All three now go through a shared scanner list that includes the
  Other scanner.
- Other models (and fresh checkpoints) carry hash_status=pending with
  an empty sha256, so the frontend sent an empty model_hash and the
  import was rejected with 'Missing model_hash parameter'. The modal
  now also sends the model's file path, and the import use case
  resolves the hash on demand via the scanner's lazy-hash calculation.
  The resolved hash is returned to the UI and persisted on the
  showcase element so follow-up operations target the same
  hash-keyed folder.
2026-10-01 10:16:34 +08:00
Will Miao eeb9270827 fix(scanner): make move_model resilient to missing source files (#1126)
Concurrent or repeated move requests for the same model raced each other:
the first move succeeded, the rest failed with FileNotFoundError, leaving
the model card pointing at stale/empty paths.

- Serialize moves per source file with an asyncio.Lock keyed on the
  normalized source path
- When the source file is already gone, reconcile instead of failing:
  locate the model via the hash index or the expected target paths, repair
  the metadata sidecar and cache entry, and reuse the stale cache entry
  when no sidecar exists at the new location
- Avoid duplicate cache entries when the cache already tracks the moved
  file; only drop the stale source entry
- Move via business paths (abspath) instead of realpath, matching every
  other file mutation and the containment check; realpath stays reserved
  for scanner dedup per project convention
2026-10-01 08:55:47 +08:00
Will Miao 42fa8294df fix(widgets): hide DOM widgets from Properties Panel to avoid WidgetLegacy width pollution
The frontend's right-side Properties Panel falls back to WidgetLegacy for
unregistered widget types, and WidgetLegacy.draw() writes widget.width
(≈panel width) onto the real widget object. The canvas DOM overlay honors
widget.width ?? node.width, so clicking a node with the panel open squashes
the widget content to the left until undo/recreate (ComfyUI_frontend #11574).

Setting hideInPanel: true on all addDOMWidget options keeps LM widgets out
of the panel entirely. Older frontends without the option ignore it safely.

Refs #979
2026-09-30 10:52:15 +08:00
Will Miao 18504c712c fix(nodes): let Load Image Metadata value outputs drive dropdown widgets
model_name, sampler_name and scheduler were declared as COMBO outputs, so the
documented wiring failed at queue time with "Return type mismatch between linked
nodes". ComfyUI only accepts a COMBO output into a node that declares its
dropdown as COMBO/IO.Combo, while Load Checkpoint, KSampler and the LoRA Manager
loaders expose their options as a plain list; comfy_execution.validation rejects
any non-string input type there, and a STRING output is rejected the same way.

Declare the three sockets untyped ("*"), the type ComfyUI's own Primitive node
uses to feed widget inputs. Verified with execution.validate_inputs that they now
link into both classic list dropdowns and IO.Combo inputs.

Also corrects the wiring guide, which claimed COMBO was the supported type.
2026-09-29 10:20:05 +08:00
pixelpaws 013c378a7a Merge pull request #1131 from willmiao/fix/sidecar-root-identity
fix(sidecars): keep mirrored sidecars when a model root moves
2026-09-29 09:45:41 +08:00
Will Miao faeb66a23d feat(recipes): opt-in workflow embedding for widget recipe saves
Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.

- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
  UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
  long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
  the existing optimize_image workflow path, derives has_workflow by detection,
  and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
2026-09-29 09:03:26 +08:00
Will Miao 69691b17a1 feat(recipes): preserve embedded ComfyUI workflow on remote imports
CivitAI serves a re-encoded, metadata-free optimized rendition as the recipe
preview, so the ComfyUI workflow embedded in the original image was dropped:
imported recipes reported has_workflow=false and never offered "Send Workflow
to ComfyUI" even when the source image carried one.

Recover the workflow from the original rendition and carry it to the save step
as data, so the stored preview stays the small optimized image:

- ExifUtils: embed a caller-supplied workflow during optimize_image's single
  encode pass, and add embed_workflow() to patch WebP EXIF in place (used by
  the verbatim skip_optimize branch and as a safety net).
- RecipePersistenceService.save_recipe: embed metadata["workflow"] before
  detecting has_workflow.
- analyze_remote_image: return the workflow recovered from the original
  rendition it already downloads for EXIF parsing.
- RecipeManagementHandler: add _fetch_original_media() and workflow helpers;
  _do_import_from_url reuses them, and _do_import_remote_recipe fetches the
  original only when CivitAI reports a ComfyUI payload (meta.comfy) so
  workflow-less images pay no extra bandwidth.
- Batch URL imports and the import modal forward the recovered workflow.

Verified against the reported image: has_workflow flips from false to true and
the recovered workflow matches the original (25 nodes, same graph id).
2026-09-29 07:20:12 +08:00
Will Miao b90d60f043 fix(sidecars): carry the identity map through a sidecar root move
A model root that moved earlier is mirrored under a pinned name derived
from its old path. If anything resolved against the new sidecar path before
the relocation ran, the destination got a map naming the mirror after the
*current* path. migrate_root's keep-newer transfer then dropped the source
map, so the moved metadata stayed orphaned under the pinned component while
reads followed the new name and rebuilt defaults — losing favorites, notes
and tags a second time.

The identity map is now relocated by relocate_root_map() instead of the
generic transfer: entries recorded under the old sidecar root win for the
roots they describe, destination-only entries are preserved, and the cache
is dropped so the next resolution reloads the merged map. A merge that
cannot be written is reported as a migration error rather than silently
stranding the moved metadata.

Reported by the Codex review on #1131.
2026-09-28 21:25:48 +08:00
Will Miao c3a9350155 fix(sidecars): keep mirrored sidecars when a model root moves
Centralized sidecars were addressed by a hash of the model root's absolute
path, so moving or renaming a root produced a new mirror directory. The
scanner then found no sidecar there, rebuilt default metadata, and silently
lost favorites, notes, tags and usage tips for every model under that root,
leaving the old metadata orphaned on disk.

Mirrors are now addressed by a root identity pinned in
<sidecar_root>/.lm-sidecar-roots.json. The identity starts as the existing
deterministic <basename>-<path digest> -- so pre-existing mirrors keep
resolving even if the map is lost, and a relocated sidecar root keeps its
names -- and is re-anchored to the root's new path when it moves, matched by
basename and recorded sample directories. Ambiguous matches are never
guessed: the mirror is left untouched and reported.

Also:
- drop the <library> path segment (single-library direction); a legacy
  library prefix is only recognised while adopting an existing mirror, which
  also keeps the unreleased centralized layout usable
- surface stranded mirrors in Doctor and in the log instead of silently
  rebuilding sidecars
- keep the default alongside mode untouched: the identity map is loaded and
  reconciled lazily, only while centralized storage is in use
2026-09-28 21:11:38 +08:00
Will Miao 0dd8d74032 fix(ui): stop body data-theme from shadowing theme preset tokens
applyTheme() mirrors the active mode onto <body> as data-theme="dark", but
the theme preset is only ever written to <html>. The palette token blocks in
tokens/colors.css and base.css used the bare attribute selector
[data-theme="dark"], so <body> matched them on its own and re-declared the
default dark palette (#1a1a1a / #2d2d2d / ...) directly on the body, where it
shadowed the preset values inherited from <html>. Every non-default preset
therefore painted the selected accent over the default preset's background,
surface, text and border tokens, and flipped into that state ~200ms after
load, when initTheme() first touched <body> — the accent-tinted background
flash seen on reload and nav-tab switches. Reached only in dark mode, since
light mode has no [data-theme="light"] token block.

Scope the palette token blocks to :root so a data-theme attribute on any
descendant (only <body> has one) can no longer re-declare them; descendant
rules such as [data-theme="dark"] .foo still match through <html>. Add a
regression guard that fails on bare attribute token blocks.
2026-09-27 21:48:58 +08:00
Will Miao 0a262cbe0c fix(linking): support external-source linking for Other-model roots
set_hf_url rejected files under config.other_roots (VAEs, text encoders,
upscalers, ...) with 'File is not within any configured model directory'
because neither _find_matching_root nor _infer_model_type knew about the
Other category. Include other_roots in both, so linking routes the cache
update to the Other scanner instead of falling back to the LoRA one, and
downloads into Other roots keep the lazy-hash metadata path.

Also make the root prefix match boundary-aware so /models/vae no longer
swallows a sibling like /models/vae-old.
2026-09-27 11:02:11 +08:00
pixelpaws ebd2b5b3e0 Merge pull request #1125 from willmiao/feature/sidecar-storage-ux
feat(sidecars): surface storage location, cover excluded models in migration
2026-09-27 10:12:53 +08:00
Will Miao 0f7aa85b0b i18n(sidecars): translate the sidecar storage UX strings
Fill the [TODO: Translate] placeholders for the 7 settings open*/path
keys, the confirm-dialog destination line and the 13-key migration
summary block in all 9 locales, per the sidecar storage terminology in
docs/i18n-translation-guidelines.md (new term rows added there for
storage location / Open Folder / installation folder).
2026-09-27 10:05:30 +08:00
Will Miao e7c1c07db0 feat(sidecars): restyle migration result as a summary modal
Follow the app's existing operation-summary convention (Metadata Fetch
Summary / Batch Download Summary): a self-managed modal appended to
document.body with a 3-state summary header, stat cards (moved /
models / skipped / conflicts / errors), and a failure table listing
per-model errors that were previously swallowed into a single count.

The storage location line and Open Folder action move into the modal
actions; the page reload still happens only when the modal is
dismissed. ESC is captured so it never reaches the settings modal
underneath. The obsolete migrateSuccess toast key is dropped — the
modal is the success feedback now.
2026-09-27 10:05:18 +08:00
Will Miao 485679223b fix: return clipboard mode from _open_path on headless Linux
Addresses PR review: on a native Linux/SSH session with neither DISPLAY
nor WAYLAND_DISPLAY (and not Docker/WSL), _open_path unconditionally
launched xdg-open and reported success even though no file manager can
open. Mirror open_settings_location: hand the path to the browser for
copying instead. Fixes open_backup_location, open_wildcards_location
and open_sidecar_location together.
2026-09-27 10:05:05 +08:00
Will Miao 7aee964448 feat(sidecars): surface storage location and cover excluded models in migration
After migrating to centralized sidecar storage users had no indication
where their files went, and portable-mode installs silently placed the
sidecar root inside the plugin folder where a reinstall or git clean
would delete it.

- Migration now also covers models excluded from the library view and
  returns the resolved sidecar root in its result payload
- get_settings exposes the resolved sidecar root, whether it is the
  default, and whether it lives inside the installation folder
- New POST /api/lm/sidecars/open-location endpoint opens (or copies)
  the sidecar storage folder
- Settings UI always shows the effective storage path with an
  open-folder button, and warns when the root is inside the
  installation folder (portable-mode hazard)
- Migration confirmation shows the destination; on completion a result
  dialog summarizes moved/skipped/conflict counts with the storage
  location and an open-folder action
- Ignore /sidecars/ at the repository root so portable-mode sidecars
  are never committed

Refs #1045
2026-09-26 23:08:29 +08:00
Will Miao 62c144d80a i18n(settings): translate the sidecar storage strings
Fills in the 23 keys that came with optional centralized sidecar storage
(settings.sections.sidecarStorage, the 18 settings.sidecarStorage.*
labels/help/status/confirm strings and the 4
modals.sidecarMigrationConfirm.* titles/button), which the feature PR merged
as [TODO: Translate] copies. No placeholder remains in any locale.

Each locale reuses its own storage-relocation verb rather than a
transliteration of "migration" (ja 移動, ko 이동, ru перенос, matching
settings.folderSettings.recipesPathMigrating), its existing "preview images"
and nav-path renderings, and quotes the migrate button label the way its
other UI-label references do. `.metadata.json`, `.civitai.info` and the
`(<settings dir>/sidecars)` literal stay verbatim.

docs/i18n-translation-guidelines.md gains the "Sidecar storage feature" term
table and pass note, and refreshes the leaf-key count to 2128.
2026-09-26 19:37:32 +08:00
pixelpaws d21f209bad Merge pull request #1124 from willmiao/feature/centralized-sidecar-storage
feat: optional centralized storage for sidecar metadata and previews (#1045)
2026-09-26 19:15:07 +08:00
Will Miao a6fca8612f fix: address review — injective mirror roots, root relocation, full preview coverage, EXDEV-safe rollback
Codex review on #1124:

- P1: mirror layout root component is now <basename>-<roothash>
  (sha256 of the normalized root path), so two roots sharing a basename
  no longer map to the same mirror directory and overwrite each other's
  sidecars
- P1: changing sidecar_storage_path while centralized no longer strands
  assets in the old root — new relocate_root migration direction moves
  the whole mirror tree, rewrites preview_url prefixes inside sidecars,
  reconciles scanner caches, and prunes the emptied old tree; the
  settings UI detects the path change and offers the relocation
- P2: migration enumerates the same preview candidates as
  find_preview_file — case-insensitive variants (model.WEBP) and the
  legacy .example.0.jpeg suffix — instead of exact lowercase
  PREVIEW_EXTENSIONS only
- P2: _rollback_model_staging restores staged files with the
  EXDEV-tolerant mover, so a failed undoable-delete staging no longer
  strands a cross-filesystem centralized sidecar copy

Tests: same-basename root injectivity, mixed-case/example preview
migration, relocate_root happy path + guards + route 400, frontend
relocation prompt flow. Verified end-to-end in a sandboxed standalone
server: uppercase/legacy previews migrate, root relocation moves the
tree and the list API serves the new locations immediately without a
rescan.
2026-09-26 12:24:17 +08:00
Will Miao 5e4462822d i18n(sidebar): translate the folder delete verification strings
Fills in the 9 locale renderings for the 5 keys added with the folder delete
verification (`sidebar.deleteFolderModal.notEmptyMessageCount`,
`.notEmptyMessageExcluded`, `.busyTitle`, `.checking` and
`sidebar.deleteFolderResult.notEmptyWithCount`), so no `[TODO: Translate]`
placeholder remains anywhere.

Each locale reuses its existing `notEmptyMessage` cascade clause verbatim
(punctuation included) and its help-text style for quoting the "Manage
Excluded Models" label; `{count}` / `{excluded}` match en.json. The Hebrew
model-file noun stays `קובצי מודלים` across all four keys.
docs/i18n-translation-guidelines.md records the new copy in §2 and refreshes
its stale `{count}` and leaf-key counts.
2026-09-26 12:07:21 +08:00
Will Miao 6e45ef566c fix(sidebar): verify folder deletion against the backend
The sidebar derives "empty folder" from the models-only list, which omits
models flagged `exclude: true`, while the delete guard walks the folder on
disk and refuses on any weight file. A folder whose models are all excluded
therefore looked empty, offered the confirmation, and then failed with
"still contains models".

The delete modal still opens on that prediction, but is now corrected by a
dry run of the very delete the user is about to confirm, so the button state
cannot contradict the backend. The confirm button stays disabled while the
check runs, and a late answer is discarded once the modal is dismissed or
retargeted. The dry run also covers weight files no scanner indexes (a lora
folder holding only a `.gguf`, say) and files that appeared after the last
scan.

`_collect_folder_manifest()` now reports `excluded_model_count`, and the
refusal names the excluded models, so the message explains the mismatch
instead of reading like a bug. Locale files carry the sync placeholders in
this commit; the translations follow.
2026-09-26 12:07:03 +08:00
Will Miao 16430aef21 fix: reconcile scanner caches after sidecar migration
Sandbox E2E showed that after a migration the list API kept serving
pre-migration preview_url values; the first request to a stale URL made
the preview route's stale-URL cleanup wipe the reference from the cache
entirely, recoverable only by a full rebuild rescan.

The use case now records each migrated model's final preview location
(from the destination directory, covering conflict-keep cases), updates
the owning scanner's cache entries via ModelCache.update_preview_url,
and persists the cache. Per-scanner reconcile failures are logged and
skipped; per-model migration errors no longer prevent reconciliation of
the healthy models.

Verified end-to-end in a sandboxed standalone server: after
to_centralized and to_alongside migrations the list endpoint immediately
returns the correct preview URLs with no rescan, previews serve with
HTTP 200 in both layouts, and the mirror tree is empty after migrating
back.
2026-09-26 11:32:18 +08:00
Will Miao f5e983eaaa feat: optional centralized storage for sidecar metadata and previews (#1045)
Add an opt-in 'centralized' sidecar storage mode alongside the default
'alongside' layout. In centralized mode, .metadata.json sidecars and
preview assets live under a configurable root (sidecar_storage_path,
default <settings_dir>/sidecars), mirroring the library-relative
directory structure: <root>/<library>/<root_basename>/<rel_dir>/.

Backend:
- settings: sidecar_storage_mode / sidecar_storage_path with validation;
  changing either refreshes the preview allowlist
- config: centralized root added to preview-serving allowlist
- lifecycle: delete / move / rename / folder-rename / folder-delete and
  undoable-delete staging all operate on the mirror tree in centralized
  mode (model files themselves never move); EXDEV-tolerant cross-
  filesystem moves
- scanners: pending-hash filesystem scan walks the mirror tree in
  centralized mode; preview discovery reads from the sidecar dir;
  .civitai.info stays co-located in both modes
- migration: SidecarMigrationUseCase moves sidecars+previews between
  layouts both directions (keep-newer conflict resolution, preview_url
  rewriting, WebSocket progress), exposed as POST+GET
  /api/lm/sidecars/migrate with a mode guard (force=true for the
  settings-first flow)

Frontend:
- settings modal: sidecar storage section (mode select + path input with
  browse/validation), mode-change confirmation offering immediate
  migration (force=true), and a 'Migrate Sidecars Now' action
- i18n keys synced to all locales ([TODO: Translate] placeholders)

Docs: metadata-json-schema.md gains a storage-location section;
AGENTS.md records the sidecar_paths helper convention.
2026-09-26 10:00:58 +08:00
Will Miao 297d8787bd refactor: route sidecar/preview path derivation through sidecar_paths helpers
Phase 1 of #1045 (optional centralized sidecar storage): introduce
py/utils/sidecar_paths.py as the single place that resolves .metadata.json
and preview locations, and replace all inline splitext-based derivations
across scanners, services, download manager, and route handlers.

No behavior change: the default 'alongside' storage mode resolves every
path exactly as before. .civitai.info (third-party sidecar) derivation is
intentionally left co-located.
2026-09-26 09:02:37 +08:00
willmiao 20d8c22390 docs: auto-update supporters list in README 2026-09-26 00:53:58 +00:00
Will Miao 3555ddb588 chore(release): bump version to v1.2.4 2026-09-26 08:53:42 +08:00
Will Miao ede15032ce fix: expose source_model_id/source_version_id in model list payloads
format_response whitelists fields explicitly, so the new ModelScope
identity fields never reached the frontend: group badges rendered, but
card.dataset.modelId stayed empty and clicking 'N versions' silently
no-oped (handleViewLocalVersionsFromCard early-returns without it).
Found via browser E2E against a sandboxed standalone server.
2026-09-26 07:35:36 +08:00
Will Miao c48feeddb6 fix: stop grouping HF/ModelScope models by repository
A repository is not a model identity: collection repos on Hugging Face
and ModelScope host many unrelated models, which were wrongly shown as
versions of each other.

- Hugging Face models no longer auto-group (the Hub exposes no
  site-native model id)
- ModelScope models group by the site's native published-model id
  (MuseInfo modelVersion.modelId), extracted during enrichment and
  persisted on the sidecar as source_model_id/source_version_id;
  unenriched models stay standalone instead of collapsing a whole repo
  into one group
- TensorArt grouping unchanged (its URL id is already model-level)
- Frontend group-key derivation mirrors the new backend semantics
2026-09-25 23:29:30 +08:00
Will Miao 8a80f82d93 docs: clarify .civitai.info is a read-only third-party file, not an LM sidecar 2026-09-25 22:40:32 +08:00
Will Miao c4676183b5 i18n: translate huggingfaceApiKey settings strings in all locales
Fill the six settings.huggingfaceApiKey* placeholders left by the HF
access-token feature in all 9 locales, reusing each locale's existing
civitaiApiKey* status renderings; document the new terminology
(access token, gated repository) in the translation guidelines.
2026-09-25 18:46:31 +08:00
Will Miao 8b7ba59263 feat: support gated/private Hugging Face repos via access token
Add a huggingface_api_key setting (Settings UI, HF_TOKEN /
HUGGING_FACE_HUB_TOKEN env override) and attach it as a Bearer token
to Hugging Face file listing, model card fetching and downloads, so
gated and private repositories can be downloaded once the user has
accepted the repo terms.

- fetch_json/fetch_text accept custom headers; ModelSource gains an
  auth_headers() hook so handlers stay platform-agnostic
- 401/403 from the tree API now explain how to fix (configure token /
  accept gated terms)
- aria2 pre-resolves huggingface.co redirects and strips credentials
  before handing the signed CDN URL to aria2, mirroring the CivitAI
  handling so the token never leaks to the CDN
- settings API exposes huggingface_api_key_set only; the raw key joins
  _NO_SYNC_KEYS
2026-09-25 18:44:06 +08:00
Will Miao 067e605e75 fix: keep bypass/mute state on frontend 1.53+ node shell state (#1123)
ComfyUI frontend 1.53 turned LGraphNode.mode into a prototype accessor
backed by node._state, and serialize() now reads that state directly.
Redefining mode on the instance shadowed the setter, so bypass/mute
never reached the serialized workflow and silently reverted to Always
on save/reload or workflow tab switch.

Add interceptModeChange() in web/comfyui/utils.js: it delegates to the
prototype accessor when one exists (observing changes only), and falls
back to the legacy closure accessor on older frontends. Use it in
lora_loader.js and in the Vue widgets' setupModeChangeHandler, which
covers the LoRA provider/aggregator nodes with the same latent bug.
2026-09-25 18:12:01 +08:00
Will Miao dae18b3d1d fix: re-run dynamic prompts fed through linked text inputs
IS_CHANGED only receives constant inputs, so a linked text always
arrived as None and the node kept serving its cached first expansion.
Declare hidden PROMPT/UNIQUE_ID inputs and walk the prompt graph to
the upstream node: rerun only when its constants contain dynamic
syntax or cannot be statically resolved, keep caching for static
linked text.

Fixes #1120
2026-09-24 09:33:27 +08:00
Will Miao 2f9bd3ee7d feat: support reverse-proxy URL subpaths (llama-swap, SwarmUI) (#1122) 2026-09-24 08:04:00 +08:00
Will Miao 755e1a5bca fix: fall back to source image dimensions for missing width/height
When metadata extraction succeeds but no recognized latent source
provides dimensions (e.g. img2img via VAEEncode), width/height now fall
back to the source image size from the loaded pixels instead of the
synthetic 1024x1024 starter preset. The starter preset for metadata-free
images keeps its fixed size, and explicit overrides still win.
2026-09-23 20:31:59 +08:00
pixelpaws c202654d49 Merge pull request #1121 from mmartial/loader
Add Load Image Metadata node for reusing generation settings
2026-09-23 20:31:44 +08:00
Will Miao 74736f7560 fix(organize): exclude Civitai meta tags from folder names (#1119)
Follow-up to the keyword-dump guard. The reported model's tag list is
["lora, character, ... face", "base model"], so skipping the dump left the
"base model" label to be picked as the folder name. That label describes
Civitai's listing rather than the model's content, which makes it as
meaningless as a folder as the blob was.

Add CIVITAI_META_TAGS and is_civitai_meta_tag(), and skip those labels in
the automatic fallback. An explicit priority entry still matches them, so a
user who does want a "base model" folder can configure one.

The reported model now resolves to "Krea 2/no tags" instead of
"Krea 2/base model".

Also correct a comment that listed ".civitai.info" among the files sitting
next to a model. LoRA Manager only reads that sidecar -- other tools write
it -- and writes ".metadata.json" itself.
2026-09-23 13:33:59 +08:00
Will Miao 0ada32d0c7 fix(organize): stop keyword-dump tags from becoming folder names (#1119)
CivitAI tags are normally short single-concept labels, but some uploaders
pack their entire keyword list into one tag. The model in #1119 carries
"lora, character, rosie, irish, ... face" as a single 181-character tag.
Priority resolution matches aliases by exact equality, so that tag matched
nothing and resolve_priority_tag_for_model fell back to tags[0] -- the blob.
With the default "{base_model}/{first_tag}" template the model was filed
under "Krea 2/<181-character blob>/", and the full path plus the
".civitai.info" sidecar and the preview images next to it ran into the
Windows MAX_PATH limit.

Tags also bypassed sanitization on the way into a path: both
calculate_relative_path_for_model and DownloadManager._calculate_relative_path
sanitized model_name and version_name but interpolated {first_tag} verbatim,
so a tag containing "/" or ":" silently produced nested or illegal folders.

Two changes:

- The fallback skips tags that cannot serve as a folder name.
  is_usable_path_tag rejects comma-separated keyword dumps and tags longer
  than MAX_PATH_TAG_LENGTH; the resolver returns "" when nothing usable is
  left, which callers already render as "no tags". Whole-tag priority
  matching is untouched, so existing priority configurations behave the
  same.
- sanitize_folder_name gains an optional max_length, and every tag-derived
  segment now goes through it. Tags are capped at MAX_PATH_TAG_LENGTH, model
  and version names at MAX_FOLDER_NAME_LENGTH, and rendered filename stems at
  MAX_FILENAME_STEM_LENGTH.

For the reported model the folder becomes "Krea 2/base model" instead of the
blob, and the full path drops from 235 to 64 characters.

Existing libraries are not migrated up front: a path is only recomputed on
download, on an auto-organize run or when a filename template is applied, and
values already inside the caps are left byte-identical. Models previously
filed under a keyword-dump folder move on the next auto-organize run.
2026-09-23 13:16:26 +08:00
Martial Michel e9aff35957 feat: add image metadata loader with native LoRA Manager integration
Add Load Image Metadata (LoraManager) to extract reusable prompts,
model references, LoRA stacks, and sampling settings from images.

Prefer saved A1111-style parameters by default, with optional workflow
and subgraph sampler selection. Resolve local model and LoRA names,
report missing resources, and recover extraction failures with explicit
defaults and readable diagnostics.

Include parser, resource-resolution, and node regression tests, plus
usage documentation.
2026-09-22 22:18:03 -04:00
Will Miao 521531111a i18n: translate the Filename Templates feature into all locales
26 keys (settings.filenameTemplates.*, filenameTemplateProgress,
modals.filenameTemplateConfirm, related toasts) translated into the 9
non-English locales, reusing each locale's autoOrganizeProgress /
downloadPathTemplates renderings. Terminology recorded in
docs/i18n-translation-guidelines.md.
2026-09-19 11:00:39 +08:00
Will Miao 474da1b264 feat(settings): empty filename template reverts to recorded original filename (#1071)
Redefine the empty download filename template from a no-op to a bulk
revert: FilenameTemplateUseCase resolves the target from each model's
recorded original_file_name sidecar entry (skipping models without one),
which resolves follow-ups 1 and 2 with a single coherent semantic shared
by the download and bulk-apply paths.

Also replace the browser-native confirm() with a self-managed
confirmation modal (filenameTemplateConfirmModal) that stacks above the
settings modal, since ModalManager would close the settings modal when
opening a registered one.
2026-09-19 10:44:43 +08:00
Will Miao 78d38b449e docs: record filename template follow-ups for #1071 2026-09-19 09:05:39 +08:00
Will Miao 2bc9860b24 feat(settings): filename templates for download and bulk rename (#1071)
Add per-model-type filename templates ({model_name}, {version_name},
{base_model}, {author}, {first_tag}, {hash_short}, {original_name}) so
downloaded files get informative names instead of e.g. V1.safetensors.
Empty template keeps the current filename (opt-in, off by default).

- apply template automatically after downloads; rename conflicts keep
  the original name and never fail the download
- record original_file_name in metadata on rename for traceability
- bulk apply via GET|POST /api/lm/{prefix}/apply-filename-template with
  WebSocket progress, sharing the auto-organize lock
- settings UI lives in the new Organization tab with validation, live
  preview, and per-type 'apply to library' actions
2026-09-19 09:04:24 +08:00
Will Miao 327da0465b feat(settings): split overloaded Library tab into a new Organization tab
Move download path templates, priority tags, and auto-organize
exclusions out of the Library settings section into a dedicated
Organization section, so Library keeps location-focused settings
(roots, extra paths, example images, metadata) and Organization holds
file-arrangement rules. Translated settings.nav.organization for all
locales.
2026-09-19 07:38:07 +08:00
Will Miao c8c84bfc54 feat(loras): warn when widget strength leaves the usage-tips range
The cycler-list payload now carries usage_tips, and the LORAS widget
parses strength_min/strength_max/strength_range into a cached lookup.
Strength inputs (model and clip) turn amber with an explanatory tooltip
when dragged, typed, or stepped outside the recommended range.

Related: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1090
2026-09-19 05:49:01 +08:00
Will Miao 3b9e8efb3d feat(banners): rotate active banners one at a time with a pager
Stacking every active banner vertically ate header height when several
were active at once. Only the highest-priority banner renders now; a
‹ 1/N › pager cycles through the rest, and all active banners are still
recorded in the notification-center history so cycled-away ones stay
reachable. Newly registered banners preempt the displayed one only when
they outrank it.

Also fix the startup flow: the restart-required banner (now priority 80)
outranks the model-folders setup warning (60), and the setup banner is
retired once a non-empty folder path is saved.

New banners.pager.* keys translated in all 9 locales.
2026-09-18 21:40:33 +08:00
Will Miao d45a523fb5 feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic
POST /api/lm/browse-directory endpoint (browse logic extracted from the
recipe batch-import handler into py/utils/directory_browser.py) and wire
a browse button plus advisory validate-path feedback (POST
/api/lm/validate-path) into the settings path inputs: recipes path,
example images path/local root, and the extra-folder/model-path rows.

The browse button insets into the right edge of static inputs so narrow
settings rows keep their single-control layout.

Translations for the new settings.directoryPicker and
settings.pathValidation keys are filled in for all 9 locales.
2026-09-18 21:05:32 +08:00
Will Miao 6dc9f34f7d i18n: translate the Model Paths settings section into all locales 2026-09-18 19:43:46 +08:00
Will Miao 5adfa3be36 feat(settings): editable model library paths for standalone mode
Standalone users previously had to hand-edit settings.json to configure
primary folder_paths. Add a standalone-only Model Paths section to the
settings modal:

- Backend exposes standalone_mode, folder_paths (with template placeholder
  values filtered out) and a data-driven folder_path_schema derived from
  OTHER_MODEL_FOLDER_SUBTYPES via GET /api/lm/settings
- The new section renders multi-path editors per model type from the
  schema, with inline enable_other_models / sub-type controls so other
  model types are configured without leaving the tab
- Persistent restart-required cues after a save: nav dot, inline notice
  and a global banner (unique id per change so dismissals don't mute
  future reminders)
- The missing-model-paths startup banner and the Other Models no-paths
  empty state now deep-link into the new section instead of pointing at
  settings.json
2026-09-18 19:36:19 +08:00
Will Miao d4b82d98b2 test(recipes): pin the manual rebuild as the escape hatch from a skipped prune
The prune guard intentionally leaves the in-memory view empty while the
stored cache keeps the user's recipes, so there has to be a documented
way to accept the on-disk truth. That route is an explicit rebuild, which
clears the stored cache before a full directory scan. Cover it so the
FAQ recovery steps stay true.
2026-09-18 00:09:34 +08:00
Will Miao 8c1c1691e3 feat(settings): add an explicit opt-out from persisted portable mode
Setting LORA_MANAGER_PORTABLE=1 once wrote use_portable_settings: true
into the plugin's own settings.json, and every later run of every
instance sharing that plugin folder then read and wrote the portable
settings directory. There was no way back except editing the file by
hand, which is exactly the trap a user hit while following the FAQ's
instructions for isolating a second instance (#1114).

LORA_MANAGER_PORTABLE=0 is now the explicit exit:

- _should_use_portable_settings honours "0" as a forced off, so the
  resolved settings directory no longer depends on the persisted flag.
- SettingsManager clears the persisted flag in that case, so later runs
  without the variable stay on the shared settings directory.

Unset or unrecognised values keep the previous behaviour: the persisted
flag decides, so existing portable installs are unaffected.
LORA_MANAGER_SETTINGS_DIR still takes precedence over both.
2026-09-18 00:05:47 +08:00
Will Miao e14a084f0d fix(cache): make shared cache state survive a second instance
Installing a second LoRA Manager instance (standalone or a second
ComfyUI install) that shares the settings directory puts two processes
on the same cache databases. Three things made that unsafe.

- The updater preserved cache/ and model_cache/ but not a legacy
  recipe_cache/ directory, so a portable install predating the cache/
  move lost its recipe database on a git-based update. Add it to
  _PRESERVE_DIRS and to .gitignore.
- Cache connections used the sqlite3 default 5s timeout, which a
  scanning instance can exceed, turning a concurrent write into
  "database is locked". Route every shared cache connection through
  connect_cache_db(), which raises the timeout to 30s and sets
  busy_timeout + synchronous=NORMAL to match the existing WAL mode.
  App-private databases (download queue, update history) are unchanged.
- A full-table cache replace is a read-modify-write that SQLite cannot
  make atomic across processes, so two instances could interleave and
  one snapshot could overwrite the other. Guard the recipe and model
  save_cache paths with a cross-process advisory lock (flock on POSIX,
  msvcrt on Windows). Locking is best-effort: if it is unavailable the
  call proceeds and the SQLite busy timeout is the fallback.

The lock file is a hidden sibling of the database and is deliberately
never unlinked, so a second process cannot lock a fresh inode.
2026-09-17 23:59:22 +08:00
Will Miao c55c6f0a41 fix(recipes): stop an all-missing scan from wiping the recipe cache
A scan that finds no recipe files at all is not a reliable deletion
signal: an unmounted drive, a recipes_path that silently falls back to
another LoRA root, or a cache shared with a second instance all look
exactly like a real wipe. The reconcile step treated them all as
deletions and overwrote the persistent cache with an empty one, so
DELETE FROM recipes destroyed the user's only record of their recipes
and the FTS index was rebuilt from the empty view (#1116).

Guard the prune:
- _reconcile_recipe_cache reports an all-missing result when every
  persisted recipe file is gone AND the stored rows match the recorded
  file stats. An internally inconsistent cache (leftover orphans) is
  stale, not evidence of a fresh disappearance, and still prunes.
- The caller keeps the stored cache and logs a warning naming the
  directory it scanned and the number of recipes it preserved, instead
  of writing the empty result. It also skips the FTS rebuild so the
  index stays aligned with the stored rows.
- save_cache gains skip_if_empty as a storage-level backstop: refuse to
  empty a populated cache. Intentional clears (manual rebuild) keep the
  default behaviour.
- Log the resolved scan directory per run so a support reader can tell a
  real wipe apart from a scan that looked elsewhere.

Partial orphans (ordinary manual deletions) keep pruning as before.
2026-09-17 23:54:40 +08:00
Will Miao 7d963b27b5 fix(example-images): read real dimensions for imported videos, fixes #1115
Example videos added through the "Add examples" flow were stored with a
hardcoded 720x1280 entry. The dimension probe next to it only ran for
images (PIL cannot open .mp4/.webm files), so every video entry stayed
portrait regardless of the source. The showcase viewer then sizes its
container straight from that value (--media-aspect in showcase.css), so
landscape clips were letterboxed inside a 9:16 box. CivitAI-sourced
examples were unaffected because their dimensions come from the API.

PIL cannot read video containers, so add a dependency-free reader that
parses the container headers instead: moov/trak/tkhd for ISO base media
(with the sample description as a fallback), Segment/Tracks/Pixel* for
WebM/Matroska, and RIFF/WebP for animated examples saved with a video
extension. The sniffed signature decides which reader runs, so a .mp4
that is really WebM still reports the right size; the extension is only
a fallback. Both readers seek past mdat rather than reading it, so a
large file costs the same as a small one.

Imported entries now record the file's real size and keep the previous
placeholder only when the file cannot be parsed.

Existing libraries keep their wrong entries, so backfill them once via
the existing naming migration: bump CURRENT_NAMING_VERSION to 3 and
repair each model's empty-url entries from the files on disk, then sync
the scanner cache. Only entries with no remote url are touched -- those
have no other source, which makes the rewrite lossless -- and entries
already carrying the right size are left byte-identical, so the pass is
idempotent and a no-op for libraries that never imported a video.
2026-09-17 21:42:32 +08:00
Will Miao eba03800b9 feat(other): answer model-versions-status read-only for unsupported types
Civitai types with no scanner at all (Wildcards, Workflows, Hypernetwork,
Poses, AestheticGradient) used to get a 400 'Model type "x" is not
supported', which hid the Civitai version list from clients.

The handler now answers 200 with supported:false, a machine-readable
reason (model_type_unsupported, or other_models_disabled when the opt-in
master switch is off) and the versions marked read-only. The interactive
payload gains an explicit supported:true. Legacy clients only read
success/versions, so they are unaffected.
2026-09-17 20:46:42 +08:00
Will Miao bf497d5144 i18n: translate the standalone no-paths guidance into all locales 2026-09-17 10:39:45 +08:00
Will Miao 369613f811 feat(other): guide standalone users to settings.json from the no-paths empty state
The standalone empty state showed the folder_paths keys but not where to
put them, and its Open Settings button led to a modal that cannot edit
primary folder paths. Now the page shows the real settings.json path and
an Open Settings Folder button backed by the existing open-location API.

Also stop open_settings_location from claiming success on headless Linux
sessions: with no DISPLAY/WAYLAND_DISPLAY, xdg-open cannot work, so the
handler now returns clipboard mode and the browser copies/shows the path
instead.
2026-09-17 10:34:42 +08:00
Will Miao 9eeebac40b fix(e2e): resolve project root from the script's actual location
start_server.py computed the project root three levels up from scripts/,
assuming it lived under .agents/skills/<skill>/scripts/. After moving to
scripts/e2e/ that resolved to the ComfyUI root, so the launcher failed
with "can't open file 'standalone.py'".
2026-09-17 10:34:42 +08:00
Will Miao b9a516c9f8 fix(settings): restore the Other Models master toggle state on load
updateOtherModelsControls() synced the sub-type checkboxes and default-root
selects but never set the master toggle's checked state, and the
setting_toggle macro renders no checked attribute, so after a page refresh
the toggle always appeared off regardless of the saved setting.
2026-09-17 10:34:42 +08:00
Will Miao ef7fa7d3dd docs(readme): document other-model folder paths for standalone mode 2026-09-17 10:34:42 +08:00
willmiao 9c67dbbf15 docs: auto-update supporters list in README 2026-09-17 01:12:54 +00:00
Will Miao 16b0bdf70a chore(release): bump version to v1.2.3 2026-09-17 09:12:34 +08:00
Will Miao e09fe5888b refactor(reorder): drop the Alt + Arrow shortcut, keep drag only
The reorder shortcut cannot be made reliable in this UI. `Alt + Arrow` is
the browser's tab-history / back-forward gesture on several platforms,
and the modal already binds bare `ArrowLeft`/`ArrowRight` to model
navigation, so the binding either did nothing — a keypress with nothing
focused never reaches a listener on the tag list — or fought the browser.
An affordance that occasionally navigates the page away is worse than
having no keyboard path at all, so drop it.

Reordering is pointer-only again: drag the chip (tags) or its `⠿` grip
(trigger words, whose chip body is click-to-edit). Everything that existed
only to serve the shortcut goes with it — the keydown listener, the hover
tracking used to resolve the target chip, the aria-live announcements, the
per-grip position labels and `moveItemWithinContainer`. The grip becomes a
decorative, non-focusable `<span>` (`aria-hidden`, behind a 5px drag
threshold) instead of a `<button>`, so it no longer promises a keyboard
action it cannot perform.

The tooltip and hint drop the shortcut mention in all 10 locales
(`common.reorder.dragHandle` = "Drag to reorder" and the localised
equivalents); `common.reorder.ariaLabel` and `common.reorder.announcement`
are pruned from every locale by the sync script. The i18n guidelines
record the decision so no shortcut is re-added without re-adding the keys.
2026-09-17 09:07:24 +08:00
Will Miao f67689b0f9 fix(css): keep full-width modal fields inside their clipped container
Two stacked defects cut the side edges off the URL textareas in the
download and batch-import modals.

`#modelUrl` and `#batchUrlInput` are `width: 100%` with padding and a
border but no `box-sizing: border-box`, so the border box was wider than
the containing block and its right edge landed in the region the modal
clips: the right border column is missing in both screenshots while the
corner pixels of the top/bottom borders are drawn, and the batch
textarea's resize handle sits a padding-width to the right of the mode
toggle above it.

The download modal's `#downloadModal .download-step` additionally
scrolls with `overflow-x: hidden` and has no horizontal padding, so the
global `:focus-visible { outline-offset: 2px }` lost both vertical edges
there and only the top and bottom lines survived. Draw that ring inset
inside `#downloadModal`, mirroring the existing `#importModal` fix in
import-modal.css.

`.input-group input, .input-group select` gets the same border-box
treatment, which also repairs the standing clipped right border on the
other full-width fields the shared rule styles (the import modal's URL,
recipe-name and tag inputs, the batch directory and tags inputs, the
model root select and the target folder path).

Verified: `npx vitest run` 130 files / 1259 tests passed.
2026-09-17 07:54:57 +08:00
Will Miao 1d6da1787a i18n: translate the download progress stage strings
Fill in the 4 `modals.download.progress.*` keys added by the previous
commit across all 9 locales, so no `[TODO: Translate]` placeholder remains
and the "no remaining placeholders" claim in the guidelines holds again.

No new terminology: `metadata` reuses the §5 row (fr métadonnées, de
Metadaten, es metadatos, ru метаданные, he מטא-נתונים, ja メタデータ,
ko 메타데이터, zh-CN 元数据, zh-TW 中繼資料) and the fetching phrasing
mirrors each locale's existing `download.fetchingRepoFiles` /
`fetchingVersions` (de passive "werden abgerufen", es "Obteniendo", fr
"Récupération des", ru "Получение", he "מביא", ja "取得中", ko "가져오는
중"). "model file" follows `errors.noModelFiles` in each file.

`{name}` and `{source}` are verbatim §1-R2 placeholders. `{source}` is
replaced at runtime with the *untranslated* platform name, so its
surrounding spacing follows each locale's `modelCard.actions.viewOnSource`
precedent — ja `{source} から`, ko `{source}에서`, zh `从 {source}` /
`從 {source}`, he `מ-{source}` (as in the existing `מ-CivitAI`), ru
`из {source}` (as in `из Workflow`) — and no brand ever appears inside the
translated text.

Punctuation: ASCII `:` for the Latin / Cyrillic / Hebrew locales and for
ja / ko, whose four sibling keys in the same `progress` block already use
ASCII; French keeps this file's ` : `; zh-CN / zh-TW use full-width `:`
like their siblings.

The guidelines gain a status block recording the pass and those spacing
precedents, so a future source added to the same slot does not have to
re-derive them.

Verified: `pytest tests/i18n/test_i18n.py` 20 passed,
`sync_translation_keys.py --dry-run` reports no drift, `npm test` exits 0
(1259 JS + 91 Vue). Each locale file gains exactly 4 lines — the values
were substituted as literals rather than re-serialising the JSON, so no
formatting churn.
2026-09-17 07:47:17 +08:00
Will Miao d572292142 feat(download): fill model metadata from the source API on download
A ModelScope or Hugging Face download landed as a bare filename, hash and
source link; the model card stayed empty until the user ran "Enrich
Metadata with AI" by hand. But everything that makes a CivitAI download
useful — the display name, the description, the tags, the trigger words,
the example images, the preview — is already published by those sites'
public APIs, so asking for it at download time is deterministic work, not
model work.

Add `py/services/model_sources/hydration.py`, called by
`_save_source_metadata()` once the sidecar exists and the file is in the
scanner cache. It fetches the model card plus the site's card extras and
hands them to the same `PostProcessor` the AI skill uses, with an empty
`llm_output`, so the two paths cannot drift apart. What lands:

* `model_name` from the site's own display name (ModelScope's `Name`), so
  the card stops showing the local filename — written only while the value
  still equals the file stem, since once a user renames a model that
  choice is theirs to keep
* `civitai.name` from the matched version's label (`showName`), which the
  card renders as the version chip
* `civitai.description` / `modelDescription` from the author summary plus
  the README as HTML
* `civitai.images` / `preview_url` from the per-file example images
* `civitai.trainedWords` from the per-file trigger words
* `base_model`, `tags` and `usage_tips` as before

Provenance stays honest: the pass records
`metadata_source = "source:<platform>"` rather than the skill's
`agent:enrich_hf_metadata`, and — because no provider ran — it no longer
stamps `llm_enriched_at`; that stamp is now conditional on the LLM
actually answering, which is what the field means. The five hand-rolled
`civitai` dict merges in the post-processor collapse into one
`_merge_civitai()` helper.

Two guards keep it safe. Only a model whose stored
`source_platform`/`source_url` match the repository being downloaded is
updated, so a local file that merely shares a name never receives another
model's card; and a file already on disk is topped up too, which
back-fills models downloaded before this existed. READMEs and detail
payloads describe the repository rather than the file, so a short-lived
process-wide `ModelSourceCache` (300 s, 32 entries) keeps a batch over one
repository to two HTTP requests. Every failure is logged and swallowed:
hydration can never fail a download.

Fix the hash policy while here. `_save_source_metadata()` went straight to
`MetadataManager.create_default_metadata()`, bypassing the per-type
factory on the owning scanner, so a checkpoint paid a full SHA256 inside
the download request — `CheckpointScanner`/`OtherScanner` deliberately
record `hash_status="pending"` with an empty `sha256` for their multi-GB
files. Metadata is now created through `scanner._create_default_metadata()`.
Hydration copes with the empty hash: `_matching_versions()` falls back to
the repository basename, which is exactly what the download just wrote.

Report both post-transfer stages, which advance no byte counter and so
read as a stall: the bar sat at 100% showing `0 B/s` for the seconds spent
hashing and fetching. `_report_phase()` broadcasts
`{"status": "metadata", "stage": "indexing" | "source", "platform": ...}`,
and `LoadingManager` names the stage in the status line (keeping the batch
position), retitles the item line, replaces the dead speed figure and runs
a sheen over the bar. `stage`/`platform` are machine-readable; the wording
is localised in the frontend.

Finally, `modelscope.ai` is its own catalogue rather than an alias of
`modelscope.cn` — `referall13/EM1` exists only on `.ai` and
`jj3550945163/Krea-2-LORA` only on `.cn` — so its URLs were rejected with
"Invalid model URL format". Register it as `ModelScopeIntlSource`
(`platform="modelscope-ai"`, `msai:` group prefix, its own default
download directory) and derive every URL either deployment builds from a
per-class `base_url`. `modelscope.com` stays an alias of `.cn`, which is
what it redirects to. The frontend source table, the link dialog hints and
the docs mirror the split.

Verified against the live APIs: both reported `.ai` repositories list
their files, read their READMEs and yield name / version / base model /
trigger words / example images. Backend 3092 passed; frontend 1259 JS +
91 Vue passed. The nine locales carry the new progress copy in the next
commit.
2026-09-17 07:47:07 +08:00
Will Miao 1b1a8d63db feat(recipes): show the recipe base model in the modal header
Adds a base model pill at the front of the recipe modal's tags row,
showing the full base model name (cards keep the abbreviation since
their overlay width is constrained). Falls back to a dimmed Unknown so
the header layout does not shift when hydration fills the value in.
Hydration now also merges base_model. Translated in all 9 locales.
2026-09-16 19:58:44 +08:00
Will Miao a0a5b13ab0 fix(metadata): keep the saved trigger-word order on refresh
`civitai.trainedWords` is an ordered array, and the order is what gets
pasted into a prompt: "Copy Trigger Words" and the insert-into-node
action join it as-is. The refresh merge unioned the stored words with the
freshly fetched ones via `list(set(...))`, so any metadata refresh
silently shuffled a user's ordering into an arbitrary one. Now that the
UI exposes reordering, that would look like the feature losing the change
at random.

Merge in order instead: stored words first (in their saved order), then
newly discovered ones, duplicates dropped. `_merge_ordered_unique` keeps
the behaviour easy to assert, and the existing merge test keeps passing
because it compares the result as a set.
2026-09-16 08:23:13 +08:00
Will Miao 779bd18e75 i18n: translate the chip reordering strings
The three `common.reorder.*` keys (grip tooltip and hint, the per-grip
aria label, and the aria-live announcement) are rendered in all 9
locales. They sit under `common` rather than in a feature namespace
because both the tag editor and the trigger-word editor render them, and
only `dragHandle` is visible copy — the other two are screen-reader text.

`Alt` and the `↑/↓` glyphs stay verbatim everywhere, the same precedent
as `Shift+Enter` in `modals.model.metadata.notesHint`, because they name
the keys rather than an action. "position X of Y" reuses each locale's
existing counting phrasing (ja `{total} 件中 … 番目`, ko
`총 {total}개 중 …번째`, fr `sur {total}`, ru `из {total}`), and
parentheses follow each file's own convention: full-width in zh-CN /
zh-TW / ja, ASCII in ko and the Latin/Cyrillic locales. No
`[TODO: Translate]` placeholder is left in any locale.

docs/i18n-translation-guidelines.md gains the matching §2 subsection and
status note so a later terminology sweep preserves these renderings; the
leaf-key count in its header is corrected to 2025 at the same time.
2026-09-16 08:23:06 +08:00
Will Miao 01137eed88 feat(frontend): one grip reorder affordance for tags and trigger words
Model tags could already be reordered by dragging a chip, but the only
hint was a `cursor: grab` on `.metadata-item` — a hover-only, mouse-only
signal that also leaked into the bulk add-tags modal, where the chips are
not sortable at all. Trigger words could not be reordered, and their
order matters: "Copy Trigger Words" and the insert-into-node action join
the array as-is to build a prompt.

Both editors now share one vocabulary: a `⠿` grip that appears whenever
the list has something to order, plus `Alt + arrow` keyboard moves with
an aria-live announcement. Whether the chip body is draggable is a
property of the item rather than of the feature:

- tags have no click action of their own, so the whole chip stays
  draggable (`handleSelector: null`), with a 5px threshold so a click on
  the grip only focuses it
- trigger words keep click-to-edit on the body, so a drag starts from the
  grip only
- the grip is the element that opts out of touch scrolling
  (`touch-action: none`), so touch users drag by the grip in both editors
- reordering is offered only while editing: trigger words reveal the grip
  from `.edit-mode`, tags from the edit container, which stays hidden
  outside edit mode

The drag engine moves out of ModelTags.js into shared/pointerSort.js,
which now marks sortable containers with `pointer-sort-enabled` so only
lists that really sort show the grab cursor. Labels, the sortable flag,
the keyboard handler and the live region live in
shared/reorderSupport.js, and both editors render the same three
`common.reorder.*` keys (translations follow in the next commit).

Two inherited engine bugs are fixed on the way: the drop position was
only settled when an animation frame was still pending, so a fast drag
(or one that started by crossing the threshold) fell back into its
original slot; and the guard that stops a drop from triggering the chip's
own click handler was removed on a timer, swallowing unrelated clicks
until the next task.
2026-09-16 08:22:46 +08:00
Will Miao c6c44b741a feat(sidebar): show empty folders by default (#999)
The sidebar used the models-only folder list while the download and move
destination pickers list every directory, so a model downloaded into an
empty category folder did not appear in the sidebar at all. Empty folders
are also deliberate organization on disk, and a file-manager-shaped tree
that hides them is surprising. Default the preference to on.

Because the preference no longer gates fetching, both folder lists are
always loaded: the full list is the tree's single source of truth and the
empty-folder count, the models-only list is what "empty" is measured
against. That makes the view-options toggle a pure re-render, and lets the
new count decorate the "..." menu so the preference's effect is visible
without scanning the tree:

- empty-folder count shown next to the menu label, cleared when unknown
- the toggle is hidden entirely when there are no empty folders
- list view filters empty entries itself (the tree gets that for free
  from the backend, the flat list is now always loaded in full)
- creating a folder still re-enables the preference, since the folder the
  user just asked for would otherwise be invisible

Dim styling is decided by _isRenderedEmptyFolder() at render time; the
models-only set keeps its ancestor-expanded semantics for the delete
guard.
2026-09-15 20:44:19 +08:00
Will Miao 5095b23eb2 fix(css): restore the red on destructive context-menu entries
`Delete folder` and `Delete Model` rendered as plain menu text: the rule
was `color: var(--danger-color)`, and that token is defined nowhere in
the stylesheet tree. A var() reference to an undefined custom property is
invalid at computed-value time, so the declaration does not fall back to
a default — `color` inherits, and it silently matched the surrounding
menu text.

Points both the label and its icon (which inherits the colour) at
`--lora-error`, the themed error token the delete buttons already use.
The shared hover paints the accent background, which a red label does not
read against, so destructive entries also get their own `--lora-error-bg`
wash.

The same dead token was masked by a hardcoded fallback in the settings
priority-tags validation state; those now use the themed token too.

Fixes all seven destructive entries at once — the four model-card menus,
the exclude/duplicates `delete-all` entry and the folder sidebar menu.

Guard: tests/frontend/regression/contextMenuTokens.test.js fails if any
custom property used by menu.css stops resolving (verified by reverting
the token), and if var(--danger-color) ever comes back.
2026-09-15 20:28:05 +08:00
Will Miao cc25bb3dc2 refactor(sidebar): put the update check first, group the folder entries (#999)
The folder context menu now reads: the content action (check for updates)
on top, then the folder operations as one group (new subfolder, rename),
then the destructive entry behind its own divider.

Gating the three folder entries per page could leave the menu with
dangling separators — the recipes sidebar hides all of them and keeps
only the update check, which already rendered one stray divider before
this change and would have rendered two after it. Add
_updateContextMenuSeparators: a divider survives only when a visible
entry sits on both sides, and a run of consecutive ones collapses to a
single line. The three per-item display toggles fold into one loop.

The template order is guarded by a regression test that parses
templates/components/context_menu.html, plus behaviour tests for the
divider collapsing.
2026-09-15 20:25:08 +08:00
Will Miao 3b54a13cae i18n(sidebar): translate the folder-management strings (#999)
Fills in the 35 `sidebar.*` placeholders the folder-sidebar feature series
left behind — view options (tree/list, empty folders), folder creation,
the delete confirmation modal and its result toasts, and folder
renaming, including the undo copy and the "deleting a folder never
cascades over model files" rule the backend enforces.

All nine locales are translated, so no `[TODO: Translate]` placeholder
remains anywhere: the state §7 of the translation guidelines describes
holds again. Terminology reuses the existing §2 maps (folder, model
root, sidebar) with tree/list view added, recorded in a new §2
subsection; the stale leaf-count in the header is refreshed too.
2026-09-15 20:18:06 +08:00
Will Miao 9bbe57ee85 feat(sidebar): rename folders from the sidebar (#999)
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.

Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.

Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.

The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
2026-09-15 20:10:36 +08:00
Will Miao 4938faa049 feat(sidebar): delete folders from the sidebar (#999)
Folders created from the sidebar had no in-app way back out: the only
removal path was to leave ComfyUI, delete the directory by hand and
rescan. A typo'd folder also polluted the move/download destination
picker permanently, since it reads the same all_folders source.

Adds POST /api/lm/{prefix}/delete-folder, restricted to directories
whose subtree holds no model weight files — a folder-level cascade would
bypass the per-model lifecycle bookkeeping (metadata sidecars, previews,
cache entries, pending-delete staging, recipe references). The service
walks the directory itself instead of trusting the possibly stale cache,
reports what it would remove (models / files / subfolders / symlinks),
and refuses library roots, top-level symlinks (shutil.rmtree rejects
those) and folders holding a staged delete, whose manifest would be
invalidated by the move. Symbolic links inside the subtree are counted
but never followed.

ModelScanner.remove_known_folder mirrors add_known_folder: the removed
subtree leaves all_folders while ancestors are kept (every recorded
ancestor exists on disk in its own right), stale cache entries under the
prefix are purged and the folder list recomputed. The handler broadcasts
models_changed so destination pickers drop the folder too.

The sidebar entry is a destructive context-menu item. The modal opens in
a confirm state for model-free folders and an explanatory one when the
subtree still holds models, decided from the models-only set that already
dims empty nodes; a stale tree is caught by the 409 not_empty/busy
conflict. Truly empty folders get the existing 20s undo affordance,
implemented by re-creating the directory.
2026-09-15 20:05:10 +08:00
Will Miao cc8eedcff7 refactor(sidebar): inline new-folder row, drop drag-to-blank creation (#999)
- Render the new-folder input as a temporary tree row at the creation
  location (file-explorer style): full-width input confirmed with Enter
  and canceled with Escape/blur; the parent folder auto-expands, and in
  list mode the row is inserted after the parent item
- Remove the drag-to-blank-area folder creation (drop-zone strip,
  sidebar-level drag handlers, performDragMoveWithState); dropping models
  onto folder nodes still moves them
- Update empty-state hints and locale keys accordingly
2026-09-15 19:47:39 +08:00
Will Miao 9734df15b4 feat(sidebar): show empty folders and create folders from the sidebar (#999)
Empty folders (tracked in the scan-recorded all_folders list, same source
the move/download destination picker uses) can now be surfaced in the
folder sidebar via a view-options toggle, dimmed when their subtree holds
no models. Folders can be created directly from the sidebar through a new
POST /api/lm/{prefix}/create-folder endpoint with library-root
containment checks; the scanner records the new directory incrementally
so the tree reflects it without a rescan.

The sidebar header moves its view toggles (tree/list, recursive, empty
folders) into a "..." menu to fit the new create-folder button.
2026-09-15 15:14:56 +08:00
Will Miao 2ceb1e2850 fix(scanner): stop truncating dotted model file names (#1112)
A LoRA named `lora-sd1.5-backlight_slider_v10.safetensors` showed up in the
manager as `lora-sd1`, hid itself from searches for the rest of its name, and
collapsed into the same lora syntax tag as every sibling sharing the prefix.

The name was cut twice.  `_process_model_file()` imports a third-party
`.civitai.info` sidecar by handing `from_civitai_info()` the local stem with
the extension already stripped, and the builder then stripped a second
"extension" from it -- `os.path.splitext` reads everything after the last dot
as one, so the version dot in `1.5` ended the name.  The download path never
hit this because API filenames keep their extension and only need one strip.

Pass the real basename from the migration site, and make the builder strip
only a recognized model extension (`strip_model_extension`), so both input
shapes resolve to the same stem.  The `model_name` fallback that reused the
same expression is fixed with it: on a sidecar without `model.name` the
display name was truncated too.

Libraries already corrupted do not heal on their own: the incremental Refresh
skips paths already in the cache (only a full rebuild reloads metadata) and
startup hydrates rows from SQLite as-is, so the wrong name survives restarts.
Reconcile now compares each cached row against the stem of its file path --
one string compare per file and no extra syscall, so a clean library pays
nothing -- and repairs mismatching rows through `load_metadata()` (which
normalizes the sidecar) and the existing in-place `_sync_cache_from_metadata_impl()`
path, which writes a targeted single-row SQL delta instead of a full save.
Repairs are one-shot, and a missing or corrupt sidecar keeps its row so a full
rebuild can recreate it without losing tags or civitai data.

Tests: the builder keeps dotted stems for all four model classes and still
strips real extensions; the migration writes the full local name to the
sidecar; and reconcile repairs memory, sidecar and SQLite row, runs exactly
once, and never reads metadata on a clean library.
2026-09-15 09:07:56 +08:00
Will Miao 942717f0b6 fix(agent): drop site-generated placeholder model cards
A repository whose uploader wrote no README still gets a card.  ModelScope
answers with a placeholder notice ("the contributor provided no further
description"), a block of SDK/git download instructions, and a closing
invitation to complete the card.  None of it describes the model, yet it was
being sent to the LLM and, worse, stored as `modelDescription` — so a Krea 2
LoRA whose only real text was the author's summary showed 841 characters of
`pip install modelscope` scaffolding on its description tab.

Add `_strip_generated_card_boilerplate()` and run it on both paths:
`clean_readme_for_llm()` (the prompt) and `convert_readme_to_html()` (the
stored description).  Markers are matched as substrings because the notices
are prose and because non-Latin scripts are not space-delimited — the notice
continues with a full-width period, so the `title == keyword` matching used
for the English boilerplate headings never fired.

A marker heading takes its whole section with it, which is what removes the
download block hanging off the notice; a stand-alone notice line is dropped
alone.  Content the author added later, under a heading of equal or higher
level, is kept, so a card that was improved after the placeholder is not
thrown away.

Verified on the live repositories: the placeholder card's description went
from 841 characters to the 86-character author summary, while the repo with
a genuinely author-written card is byte-for-byte unchanged.
2026-09-14 21:35:35 +08:00
Will Miao 0f160e157f fix(modelscope): identify a model file by hash before filename
A file was matched to its published version by comparing basenames against
each version's `stats.fileList`.  Renaming the weights — routine once a
model is filed away, and the reason the scanner records a sha256 at all —
made the match fail silently, so the file lost its example images and its
preview with no indication why.

The detail payload's `ModelInfos.safetensor.files[]` carries a real sha256
per published file, and the local hash is already on disk, so match on that
first: it is the one identifier a rename cannot invalidate.  Exact basename
and `showName` matching remain as fallbacks, and an unknown hash falls
through to them rather than giving up, so a re-encoded file still resolves.

Verified against the live repository: a renamed `c1-st1000` file with its
hash yields the c1-st1000 image, the same rename without a hash yields
nothing, and supplying c1-st2000's hash resolves to the c1-st2000 image even
when the filename claims otherwise.
2026-09-14 21:27:09 +08:00
Will Miao e9e9ee20c6 perf(modelscope): fetch a repository's model card once per run
A collection repository publishes many model files under a single source id,
but enrichment re-read the README and the model-detail payload for every one
of them: eight checkpoints meant sixteen HTTP requests, each detail payload
being 10-22 KB of JSON.

Add `ModelSourceCache`, created by `execute_skill()` for the duration of a
run and passed to the provider through a new optional `cache` argument on
`fetch_model_card_context()`. The agent caches the README (repository-wide
and provider-agnostic), and ModelScope caches its detail payload under a
provider-namespaced key.

Only successful reads are memoised, so a transient failure is still retried
for the next file, and the per-file selection is redone from the cached
payload so a checkpoint never inherits a sibling's example images. Nothing
is retained across runs — a model card can change at any time — and download
URLs are not routed through the cache.

Measured over the eight checkpoints of one ModelScope repository: 16
requests before, 2 after.

To keep the two concerns separable, `_build_card_context()` now turns a
detail payload into a `ModelCardContext` as a pure function.
2026-09-14 21:24:57 +08:00
Will Miao f0ee30fc68 fix(agent): keep each tag's own wording instead of forcing single words
The tags instruction demanded "all lowercase, no spaces, no hyphens" with
single-word examples.  That clause arrived in the same commit that added
the priority_tags cross-reference, so it reads as a crude way of pushing
the model towards that (entirely single-word) vocabulary rather than as a
requirement in its own right — and nothing in the codebase depends on it:

* `_merge_tags` only lowercases and de-duplicates;
* `resolve_priority_tag` matches aliases exactly, and the priority config
  syntax already supports multi-word entries and aliases;
* the tag FTS index tokenises on non-alphanumerics, so a hyphenated tag is
  indexed as two tokens and stays searchable;
* tags never reach a ComfyUI prompt — that is `trainedWords`.

It also fought the priority_tags rule it was meant to support.  Handed the
site-curated `character-enhancement`, satisfying both rules produced
`character` as well; the run added generic priority-list tags and dropped
the site's own wording.  The spelling used by the site, the frontmatter or
the author is now kept verbatim — hyphenated, multi-word or non-Latin —
and no separator-free synonym is invented for a tag already included.

Measured on a Krea 2 portrait LoRA, the proposal went from nine tags
(four of them generic priority-list words) to six grounded ones.
2026-09-14 21:22:31 +08:00
Will Miao 51de85a6ca fix(agent): persist the LLM confidence through metadata writes
The post-processor stored the LLM's confidence as `_llm_confidence`, but
that value could never be read back: `BaseModelMetadata.from_dict()`
deliberately excludes underscore-prefixed keys from `_unknown_fields` and
`to_dict()` strips private fields, so it was erased by the next metadata
write and was invisible to `read_metadata()`.  The enrichment evaluation
harness reads this field to score runs, so confidence was always scored
as blank.

Store it as `llm_confidence`, which round-trips as an ordinary unknown
field — the same mechanism `llm_enriched_at` already relies on.  Nothing
else consumed the old name, and the harness still accepts it so sidecars
written by earlier versions keep evaluating.

Covered by a metadata load/save round-trip regression test plus
assertions that the post-processor writes the persisted key and no longer
writes the private one.
2026-09-14 20:42:14 +08:00
Will Miao 4064ea7d3a refactor(agent): apply model-source data without an LLM
`_build_prompt_context()` was only reached when the LLM was configured,
so a user with no provider got nothing at all from a linked model source
— no preview, no example images, no author summary, no tags — even
though all of that is deterministic data from a public API.

Split the model-card fetch into `_load_source_card()`, which runs for
every source-backed enrichment, and have the post-processor apply its
result whether or not the LLM runs. The prompt is then built from the
already-fetched card rather than re-fetching it.

Invoking "Enrich Metadata with AI" still always calls the provider; a
model source supplying a description, images and tags is not treated as
a reason to skip it, since the LLM's summary and notes are richer and an
action that silently does not call out to the provider would be
unpredictable. The site data acts as a fallback for the gaps the LLM
leaves.

Add `base_model_resolver.resolve_base_model()` to map the site's own
names (`krea/Krea-2-Turbo`, `KREA_2_TURBO`) onto the canonical
vocabulary, used only when the LLM returns no base model. It is strictly
conservative — exact normalised matching plus a bounded set of variant
suffixes, and it only ever returns a name that is already in the
vocabulary — so an uncertain hint defers to the LLM instead of writing a
plausible-looking wrong value.
2026-09-14 20:39:10 +08:00
Will Miao 35b291ab19 feat(modelscope): read the model-detail API for card extras
ModelScope's model card is not just README.md: the author's summary
(Description), the site-curated tags (OfficialTags), the internal
architecture enums (VisionFoundation/SubVisionFoundation) and — per
published version — the model filenames with that file's example images
(coverImages) and trigger words all live in the model-detail API.
AIGC repositories there frequently ship an auto-generated boilerplate
README and put the only useful text in Description, so reading just the
README yielded almost nothing.

Add `ModelSource.fetch_model_card_context()` returning a new
`ModelCardContext`, implemented by ModelScopeSource against the public
(no API key) detail endpoint. Example images are matched to the model's
basename through each version's `stats.fileList`, so every checkpoint in
a collection repository gets its own images rather than a sibling's.

Consume the context in the post-processor:

* example images seed `civitai.images` and, being per-file, take priority
  in the preview fallback chain
* the author summary becomes a paragraph in `modelDescription` and fills
  `civitai.description` when the LLM returns no short description
* site-curated tags are always merged in, which also fixes the official
  `character-enhancement` being dropped by the prompt's no-hyphen rule
* per-file trigger words are used before the repo-wide YAML
  `instance_prompt`
* an explicitly stated strength range is recovered by regex so
  `usage_tips` is populated even without an LLM

The prompt gains a Site-Provided Metadata section so the LLM can prefer
the site's first-hand data over its own guesses.
2026-09-14 20:38:56 +08:00
Will Miao e711e643f1 fix(ui): pin download modal action buttons with sticky footer
Mirror the import modal fix (c5088772): make the download modal a flex
column with a scrollable step area so the Back/Download buttons stay
visible on short viewports (e.g. 1080p) instead of requiring a scroll
to the bottom of the location step.
2026-09-14 19:29:00 +08:00
Will Miao db38ad80e6 fix(nodes): snapshot scanner cache before iterating on executor thread
Node code reads cache.raw_data while MetadataSyncService may mutate it
from a background thread; iterate over a list() snapshot to avoid a
possible 'list changed size during iteration' RuntimeError.
2026-09-14 11:05:12 +08:00
Will Miao 326df32933 fix(llm): stop DeepSeek enrichment failing on json_schema rejection
Enriching a model with `llm_provider=deepseek` failed outright with
HTTP 400 "This response_format type is unavailable now".  Probing the
endpoint shows why:

    response_format absent      -> 200
    {"type": "json_object"}     -> 200
    {"type": "json_schema",...} -> 400

`chat_completion_json` preferred `json_schema` for a real reason -- LM
Studio and other local OpenAI-compatible servers reject `json_object`
but accept `json_schema` -- and guarded the fallback with a substring
test for `'response_format.type'` (the wording of those servers'
rejection).  DeepSeek's message is "This response_format type is
unavailable now", which does not contain that substring, so the guard
re-raised and the retry never ran.

Make the format a per-provider chain instead of a single guess:

- `_JSON_OBJECT_ONLY_PROVIDERS` lists providers known to reject
  json_schema (currently just deepseek).  They ask for `json_object`
  first, so the common case costs one request and no wasted retry.
- Everyone else keeps `json_schema` first, then downgrades through
  `json_object` and finally prompt-only mode.
- A downgrade now happens on any error mentioning `response_format`,
  which covers wording variants without swallowing unrelated failures:
  auth errors, unknown models, and rate limits still surface unchanged
  because their messages never name the parameter.

`json_object` is sufficient here: the skill prompt already specifies the
exact JSON shape, and `_try_salvage_json` repairs imperfect output.

Verified against the real configured endpoint with the real
`enrich_hf_metadata` prompt, prompt renderer, and ModelScope model card
for jj3550945163/Krea-2-LORA: a 9,815-character prompt returns
parseable JSON (base_model "Flux.1 Krea", description, tags, notes).

Three regression tests cover the DeepSeek ordering, the
json_schema -> json_object downgrade, and the no-retry-on-unrelated-400
path.  Full backend suite: 2856 passed.
2026-09-14 10:27:33 +08:00
Will Miao 31ef9ffa06 i18n: refresh the download copy for ModelScope in 9 locales
The download dialog's URL field still said "CivitAI URL(s)" and rejected
anything that was not CivitAI, and the hint listed only CivitAI / CivArchive /
Hugging Face. Four en.json values were refreshed in the previous commit and
propagated here:

- modals.download.civitaiUrl -> "Model URL(s)" (模型 URL / モデル URL / modèle /
  Modell / modelo / модель / מודל).
- modals.download.urlHint names all four supported sites.
- modals.download.errors.invalidUrl -> "Invalid model URL format"; it is the
  generic "unrecognised URL" error, so naming CivitAI was wrong.
- modals.download.errors.mixedSources names Hugging Face / ModelScope.

Brand names stay Latin per R3, "model" follows the §2/§5 rendering already in
force in each locale, and the Latin/Cyrillic/Hebrew files keep ASCII
punctuation. en.json is unchanged in this commit; exactly four lines change in
each of the nine locale files, with no reindentation — the sync script does not
refresh an existing key's value, so this was done by exact-literal replacement.

pytest tests/i18n: 20 passed and sync_translation_keys.py --dry-run is a no-op.
2026-09-14 07:42:57 +08:00
Will Miao 38d4c59b4c feat(download): support ModelScope repositories in the URL downloader
ModelScope became a linkable source, but downloading from it was impossible:
the URL picker only recognised huggingface.co, the file listing hit a
huggingface-only endpoint, the resolve URL was hardcoded, and the default
path template always wrote into a `huggingface/` directory.

Move the download knowledge into the providers so the handlers stay generic:

- `ModelSource` gains `list_files()`, `file_download_url()`,
  `default_revision` and `default_subdir`. `HuggingFaceSource` keeps the Hub
  tree API (`/api/models/{id}/tree/{rev}`, LFS-aware sizes, `main`).
  `ModelScopeSource` uses `/api/v1/models/{id}/repo/files?Revision=master`
  — which reports real byte sizes for LFS files, so no HEAD probe is needed,
  and which only accepts `master` (an HF-imported repo still 404s on `main`)
  — and downloads through `/models/{id}/resolve/{rev}/{path}`. That URL
  redirects to a CDN target carrying a time-limited `auth_key`, so it is
  rebuilt on every request and never cached, which is also what keeps
  resumable Range requests working.
- `hf_handlers.py`/`HfHandler` become `model_source_handlers.py`/
  `ModelSourceHandler` with `list_model_source_files` and
  `download_model_source`. New routes `/api/lm/model-source-files` and
  `/api/lm/download-model-source`; the old `/api/lm/hf-repo-files` and
  `/api/lm/download-hf-model` paths stay as aliases, and a payload without
  `platform` still means Hugging Face, so existing callers are unaffected.
- A downloaded sidecar now records `source_platform` + `source_url` (with the
  `hf_url` alias only for Hugging Face) instead of always writing `hf_url`,
  and `use_default_paths` files ModelScope downloads under
  `modelscope/<owner>/<repo>`. The now-unused shared HF aiohttp session and
  its shutdown hook are gone; providers open short-lived sessions.
- Frontend: `detectUrlType` returns the platform-neutral
  `model-source-repo` / `model-source-file` plus an explicit `platform`, the
  DownloadManager's `hf*` state and methods are renamed to `source*`, every
  `source === 'huggingface'` check becomes `isExternalModelSource()`, and
  batch groups are keyed by `platform:repo` so the same `owner/name` on two
  sites renders as two groups. A bare `owner/name` still means Hugging Face.
- `is_valid_source_id()` centralises repo-id validation (exactly
  `owner/name`, no traversal, no leading dot). This also fixes the old HF
  download check that rejected any dot in the name, i.e. legitimate repos
  such as `black-forest-labs/FLUX.1-dev`.

Verified against the live APIs: the example repo lists 8 weight files with
correct sizes, and a ranged GET of the built resolve URL returns 206 after
following the redirect to the CDN. Backend 2853 passed; frontend 1143 JS +
91 Vue passed. The nine locales carry the refreshed download copy in the
next commit.
2026-09-14 07:42:51 +08:00
Will Miao b9bf006998 i18n: translate model-source strings into 9 locales
Complete the 15 [TODO: Translate] keys the model-source feature left behind
(modelCard.actions.viewOnSource, loras.contextMenu.linkModelSource,
modals.linkModelSource.*, modals.model.versions.sourceGroupInfo,
toast.contextMenu.enrichNeedsSource, toast.contextMenu.enrichUnsupportedSource),
and refresh the two enrichment labels that feature made stale.

- Brands stay Latin per R3: Hugging Face / ModelScope / TensorArt appear
  verbatim, and {source} is substituted by the caller at runtime, so no locale
  embeds a transliterated platform name. The placeholder-URL value
  (modals.linkModelSource.urlPlaceholder) stays byte-identical to en.json per
  the §6 URL exception.
- "model source" / "model page" / "model card" are new nouns and each locale
  gets exactly one rendering; "AI enrichment" reuses the noun already in each
  file from the previous enrichHfAgent copy. All of it is recorded in §2.
- modelCard.actions.viewOnSource follows each locale's existing
  viewOnHuggingFace pattern rather than the neighbouring viewOnCivitai one, so
  de/ru/he/ja/ko do not gain a third "View on ..." shape.
- loras.contextMenu.enrichHfAgent and loras.bulkOperations.enrichHfAgent read
  "AI HF metadata" in all nine locales. The feature invalidated that by also
  covering ModelScope, so both values drop the HF qualifier (the key names keep
  the historical Hf, and the guidelines now say so).
- Script conventions: fr keeps ASCII apostrophes and a space before ':' (the
  file is 351 ASCII vs 26 U+2019 and the modal being replaced was ASCII); ko
  keeps ASCII ':' and '()' (188 vs 6); CJK locales keep full-width punctuation;
  every ellipsis is ASCII '...'. Placeholders are verbatim per R2.
- modals.linkModelSource.enrichNote is phrased as a rule with the current
  exception in parentheses, so the guidelines call that out for whoever adds
  the next link-only source.

pytest tests/i18n: 20 passed, and scripts/sync_translation_keys.py --dry-run is
a no-op (no missing and no stale keys). Frontend: 1130 JS + 91 Vue passed.
Backend: 2815 passed. en.json is untouched by this commit.
2026-09-14 07:28:27 +08:00
Will Miao 5ab0e88abc feat(links): support ModelScope and TensorArt as model sources
A model file could only ever be linked to huggingface.co: `set_hf_url`
validated the URL with a huggingface-only regex, the agent fetched the card
from a hardcoded HF URL, and the readme processor built every relative image
path off `https://huggingface.co/{repo}/resolve/main`. ModelScope publishes the
same model-card convention (README.md + YAML frontmatter, often carrying
`base_model:` and `trigger_words:`) behind a public, key-less API, so the
enrichment pipeline could already serve it - it was the plumbing that was
HF-shaped, not the idea.

Make the external source a first-class, provider-driven concept:

- New `py/services/model_sources/` registry. A `ModelSource` owns URL
  recognition (lenient for stored values, strict for user input), the
  canonical page URL, model-card fetching, the asset base URL and the
  capability flags. `HuggingFaceSource` is the previous logic relocated;
  `ModelScopeSource` reads `/models/{o}/{n}/resolve/{master|main}/README.md`
  and falls back to `/api/v1/models/{o}/{n}/repo`. `TensorArtSource` is
  link-only on purpose: tensor.art answers plain HTTP clients with a
  Cloudflare challenge and its internal API (ap-east-1.tensorart.cloud /
  cn.tensorart.net) rejects every /v1/model/* route with "invalid
  authorization header", so it declares supports_enrichment=False rather than
  failing silently later.
- Metadata gains `source_platform` + `source_url`; `hf_url` stays as a
  read/write alias, written only for Hugging Face, so existing sidecars,
  cached rows and third-party consumers keep working. Normalisation runs at
  the scanner, the persistent cache (both directions, plus two new columns
  behind an ALTER migration) and the linking handler - which is what stops a
  user who switches sources from leaving a stale `hf_url` on a ModelScope
  model.
- The agent pipeline keys off the provider instead of `hf_url`: the fast-fail
  gate now explains *why* a model is skipped (no source / unknown source /
  source without a reachable card), the prompt context exposes
  source_url/source_id/source_label/asset_base_url while still filling the
  legacy hf_url/repo aliases, and the four README image extractors take a
  base_url (defaulting to HF) so relative paths resolve against the right
  site. Version grouping generalises to hf: / ms: / ta: keys.
- `POST /api/lm/set-hf-url` keeps its path and its legacy payload keys but
  accepts `source_url`, validates against every provider and returns the
  platform. `GET /api/lm/model-sources` lets the UI render the supported-site
  list from the server.
- Frontend: a `modelSourceHelpers` mirror of the registry drives the link
  dialog, the card/modal globe (branded "View on ModelScope/TensorArt"), the
  version-group key and the enrichment gate; the versions tab no longer sends
  ms:/ta: keys to the CivitAI API.

TensorArt stays in the list because provenance is worth keeping even when the
card is unreadable - the dialog says so plainly ("Sites that don't expose one
(currently TensorArt) can only be linked") and the context menu disables
enrichment with a matching tooltip, instead of the user getting
"Unsupported URL".

Verified against the real ModelScope API: jj3550945163/Krea-2-LORA returns a
1882-byte card whose frontmatter carries base_model/tags/trigger_words, and
relative images resolve to .../resolve/master/....

Tests: backend 2815 passed; frontend 1130 JS + 91 Vue passed; pytest
tests/i18n and a Jinja compile pass over templates/. The nine locales carry
[TODO: Translate] for the new strings, completed in the next commit.
2026-09-14 07:24:08 +08:00
Will Miao 84146b62fd feat(other-models): announce the feature only when folders are available
Other Models management is opt-in and its folders come from
folder_paths.get_folder_paths(). In plugin mode ComfyUI registers vae,
upscale_models, text_encoders, clip_vision and controlnet out of the box, so
enabling the feature works immediately. Standalone only knows the keys present
in settings.json.folder_paths, and that file is edited by hand - there is no UI
for those keys - so a standalone user who followed the announcement banner
reached "Enable Other Models" and then an empty page.

Gate the announcement on the capability instead of on how the process was
started:

- Config.get_other_models_availability() probes every canonical other key
  (legacy clip collapses into text_encoders where the host exposes
  map_legacy) and reports which sub_types resolve to a folder that exists on
  disk. It deliberately ignores enable_other_models: the question is "could
  this work here at all?". An empty folder counts, because CivitAI downloads
  can target it.
- /api/lm/settings exposes it as the derived, non-persisted
  other_models_paths_available flag; a probe failure yields null and the
  banner fails open.
- BannerService only registers the announcement when the flag is not false.
  `=== false` (not falsy) keeps a cached/older payload working, and nothing is
  written to dismissed_banners, so the banner can return once folders exist.
- The Other page grows an "enabled but nothing to scan" empty state driven by
  config.other_roots, showing the settings.json snippet for standalone and a
  pointer to ComfyUI model paths otherwise, plus an Open Settings action. It
  also covers the corner where only a non-default sub_type has a folder.

Translate the six other.noPaths.* keys into all nine locales and record the
new "folder key" / "on disk" terminology in the i18n guidelines.

Backend tests and pytest tests/i18n could not run in this environment (no
pytest/platformdirs); the probe was exercised against a stubbed folder_paths.
Frontend: 120 files / 1101 JS tests passed.
2026-09-13 21:47:16 +08:00
Will Miao adeb40bfff fix(links): let CivitAI and HuggingFace links coexist (#1094)
A model could have CivitAI metadata and a HuggingFace link at the same time,
but only one of the two "View on ..." entries ever rendered, because both the
model modal and the card globe asked the `from_civitai` provenance flag which
source to show. `set_hf_url` wrote `false` and a CivitAI refresh wrote `true`,
so whichever ran last erased the other: linking HF hid "View on CivitAI" even
though the civitai payload was still in the sidecar, and (on the card) a later
refresh pointed the single globe icon back at CivitAI, hiding the HF entry.

Decide the links from the data itself instead:

- `set_hf_url` no longer touches `from_civitai`; it records where the metadata
  came from, and HF provenance is already tracked by `hf_url`.
- Add `hasCivitaiSource(civitai)` in the shared card/modal utils and gate the
  modal's CivitAI link, the card globe (title, enabled state, click target,
  new `data-has_civitai`) and the context-menu `civitai` action on actual
  CivitAI data (`modelId` / `model_id` / `id`). A dual-source model now shows
  both links, and a CivitAI-only model with no `hf_url` stays as before.
- Agent HF enrichment (`PostProcessor.is_hf_model`) keyed off
  `not from_civitai`, which stopped being a synonym for "has an HF source" once
  both sources can coexist (and already broke after a CivitAI refresh flipped
  the flag back to true). Key it off `hf_url` directly; the post-processor
  tests move to that discriminator and gain a dual-source case.

Regression tests: the set-hf-url handler preserves civitai + `from_civitai`
and no longer forces the flag false, the modal renders both links (including
with `from_civitai: false`), and the card globe targets/opens the right source
and is disabled when neither is available.

Backend: 2749 passed. Frontend: 1098 JS + 91 Vue tests passed.
2026-09-13 21:12:54 +08:00
Will Miao 8a21837ca2 i18n: name all five sub_types in the Other Models opt-in copy
Three pre-enable strings listed exactly the old default set (VAE, upscaler,
text encoder, CLIP vision), so they read as "these are what enabling
manages" - now wrong twice over, since clip_vision became opt-in and
ControlNet was never named.

Point them at the capability instead: other.disabled.description and
banners.otherModels.content enumerate all five sub_types, and
settings.folderSettings.enableOtherModelsHelp names all five folder
categories the master switch gates. Model-type names stay in Latin per the
model-type rule; de compounds as CLIP-Vision- und ControlNet-Ordner and the
slash-list locales keep their existing VAE / Upscaler / Text Encoder / ...
casing. No placeholders or HTML are involved.

Editing en.json leaves the nine locales stale, and the sync script only adds
missing keys, so each locale is updated in the same pass by exact-literal
replacement of the one line - no JSON round-trip, no formatting churn (three
changed lines per file). Record the refreshed strings and the
"capability, not defaults" rule in the i18n guidelines.
2026-09-13 20:12:52 +08:00
Will Miao 3302147a43 fix(other-models): make clip_vision opt-in like controlnet
DEFAULT_ENABLED_OTHER_SUB_TYPES managed vae, upscaler, text_encoder and
clip_vision while controlnet was the sole opt-in type. That split was not
defensible on demand breadth: ControlNet is the broader category by install
base, and clip_vision is the narrower one (IPAdapter/SVD image conditioning,
usually one to three files) whose CivitAI type is retired upstream.

Keep the default set to the dependency-style assets every pipeline needs and
where "which one am I actually using" is the real problem - VAE, upscalers
and text encoders - and treat clip_vision and controlnet symmetrically as
opt-in. The feature is still unreleased, so the change needs no migration.

- Sync all five surfaces holding a default: DEFAULT_ENABLED_OTHER_SUB_TYPES,
  DEFAULT_SETTINGS, both DEFAULT_SETTINGS_BASE/createDefaultSettings lists,
  updateOtherModelsControls()'s fallback and the Jinja fallback.
- The selection is persisted per user, so only the untouched default moves;
  existing default_other_roots entries for a disabled sub_type are preserved.
- Fix the Jinja fallback using `or`, which treated an all-unchecked empty
  allow-list as "unset" and re-checked every box on render; `is none` keeps
  the empty list empty.
- Document the revised defaults and rationale in the plan.

Tests assert the new default trio, the normalize fallback, that both opt-in
types stay out of the default scan, and the auto-set iteration test now
enables clip_vision explicitly since it exercises the loop, not the default.
2026-09-13 20:12:49 +08:00
Will Miao 4d87ae7637 fix(other-models): stop warning about legacy folder keys that alias
Enabling Other Models logged two warnings on a stock ComfyUI install:

  Detected the same folder '.../clip' under multiple other-model categories
  ('.../clip' is already mapped). Keeping the first category; please fix
  your path configuration.

Nothing was wrong with the configuration. ComfyUI's folder_paths rewrites
legacy names before every access (map_legacy: clip -> text_encoders,
unet -> diffusion_models) and registers both legacy directories under the
canonical key, so get_folder_paths("clip") returns exactly the same list as
get_folder_paths("text_encoders"). Both keys are in the enabled allow-list,
so the second pass hit the overlap guard for every text-encoder folder and
printed advice the user cannot act on. The path list itself was correct
(deduped), only the message was wrong.

- Config._collapse_legacy_folder_keys() drops a key when the host exposes
  map_legacy and resolves it to another queried key. That is provably
  lossless: an empty canonical list implies an empty alias list. The
  standalone MockFolderPaths has no map_legacy and its keys are independent
  settings.json entries, so every key is still queried there.
- _prepare_other_paths() now tracks the claiming sub_type alongside the
  business path and downgrades a same-sub_type duplicate to debug, keeping
  the warning for a genuine cross-category collision (and naming the other
  category in the message).

Regression tests cover the aliased-key layout (no warning, no redundant
query, both folders still managed) and the same-sub_type duplicate, and the
opt-in test is parametrized over controlnet and clip_vision.
2026-09-13 20:12:45 +08:00
Will Miao b1a653f18f fix(other-models): hide the folder sidebar by default on the Other page
Other-model downloads now default to a flat layout, so a fresh library shows an
empty folder tree there while the sidebar still consumes 230px. Default the
per-page visibility to hidden for "other" through a small per-page default set.

The preference stays persisted per page, so an explicit show/hide toggle wins
afterwards, and the existing edge indicator keeps the hidden sidebar
discoverable and recoverable. Primary pages keep their visible default.
2026-09-13 11:35:48 +08:00
Will Miao 6fe0543d2e fix(other-models): default downloads to a flat path, not {base_model}/{first_tag}
get_download_path_template() fell back to "{base_model}/{first_tag}" for any
unconfigured model type, so other-model downloads were silently nested under an
arbitrary CivitAI tag even though the settings UI exposes no template row for
"other" and priority_tags has no "other" entry (making {first_tag} resolve to
tags[0]).

Add DEFAULT_DOWNLOAD_PATH_TEMPLATES with other -> "" so unconfigured and
unknown types resolve to a flat layout under the already sub_type-scoped
default_other_roots; explicit settings.json values still win. Mirror the flat
default in the frontend DEFAULT_PATH_TEMPLATES and stop the download/move
default-path previews from rendering "/undefined" or a dangling slash.
2026-09-13 11:30:57 +08:00
Will Miao 931dfbe1d3 fix(ui): stop the header search field from crowding itself when space runs out
At ~628px the header overflowed horizontally by 53px: the labelled nav held
383px that flex could not reclaim, so the search field was clamped to its
200px floor and had only ~96px of text room, letting the placeholder collide
with the Ctrl+F cue and the inline toggles.

Three rules drove that:

- .header-search had a hard min-width: 200px, so it parked at a fixed width
  instead of shrinking with the space it was actually given.
- The input reserved 6.75rem for "options + filter + clear/cue", but that
  declaration never applied: search-filter.css is imported after header.css
  and its .search-container input (equal specificity) set the right padding.
  The inline chrome actually needs 126px, so text ran underneath it.
- Labels stayed on the nav down to 600px, where a labelled nav (~383px) and a
  readable search field (~300px) cannot coexist.

- Drop the min-width floors on .header-search and its container so the field
  compresses naturally.
- Reserve exactly the inline chrome (cue 58 + clear 28 + toggles 56 + gaps and
  edges 16 = 126px) and document why !important is required here.
- Add a 1366px breakpoint that hides the Ctrl+F cue and drops the reservation
  to 68px; the shortcut itself keeps working, only the visual hint goes.
- Move the nav icon fallback from 600px to 700px and keep the <=600px
  container tightening as its own query.

Verified in headless Chrome against the real stylesheet: no horizontal
overflow at any width (was 53px at 628px, 80px at 601px), and the placeholder
plus Ctrl+F cue never overlap (the same collision existed at ~1250px, where
the field now keeps 85px of text room instead of 2.8px).
2026-09-13 09:11:31 +08:00
Will Miao b5c1331911 feat(backend): make model existence checks other-aware
Implements the backend slice (B1-B7) of
lm-civitai-extension/docs/other-models-support.md, which lets the companion
browser extension detect, badge and download the opt-in Other Models types
(VAE / upscaler / text encoder / CLIP vision / ControlNet).

ModelLibraryHandler:
- _normalize_model_type() learns the CivitAI other aliases (vae, upscaler,
  textencoder, clip, clipvision, controlnet, other) and maps them to "other".
- _get_scanner_for_type() resolves "other" through the other scanner, but only
  while enable_other_models is on, so model-versions-status and
  model-version-download-status keep their legacy 400 when the feature is off.
- check_model_exists() / check_models_exist() consult the other scanner last
  (lora -> checkpoint -> embedding -> other) and report modelType "other".
  With the feature disabled both endpoints stay byte-identical to before and
  the other scanner is never touched.

DownloadManager:
- The four other-type default-path failures now carry a machine-readable
  "reason" (contract C4): other_models_disabled, other_sub_type_disabled,
  other_no_default_root, other_sub_type_undecidable. The user-facing "error"
  strings are unchanged; the key is additive and reaches the client because
  both download endpoints pass the result dict through verbatim.

Tests cover the opt-in on/off branches for both existence endpoints, mixed
lora + other ids in the batch endpoint, the CivitAI alias acceptance and the
400 regression for unknown types, and the exact reason/error pairs for all
four download failure modes.
2026-09-13 08:53:49 +08:00
Will Miao 37f2cba72d fix(ui): wrap toolbar controls by available space, not viewport width
The action bar forced .controls-right (Doctor) onto its own full-width row
below 1500px. On high-DPI displays a maximized window reports a CSS viewport
of ~1280-1440px, so the Doctor button wrapped even with ~500px of free space
next to the action buttons.

- .actions / .action-buttons now wrap only on real overflow (flex-wrap plus
  min-width: 0) instead of a viewport breakpoint.
- .controls-right relies on its auto margin to stay right-aligned on either
  row, so the width: 100% + margin-top: 8px override is gone.
- Lower the button min-width floor from 100px to 90px; the old floor alone
  made the row overflow the 1400px container at wide viewports.
- The <=1500px breakpoint now only tightens the buttons (min-width: 0,
  padding, gap) and no longer forces a wrap; drop the no-op 0.8em font-size
  override (base is already 0.85em).
- Keep the stacked mobile layout below 768px.

Verified in headless Chrome against the real stylesheet: one row with the
Doctor button inline down to 1200px (down to 1000px for shorter locales),
right-aligned wrap only when the content genuinely does not fit, and no
horizontal overflow at any width.
2026-09-13 08:42:43 +08:00
Will Miao 0e789cb38c revert(ui): move Doctor trigger back to the page toolbar
Undo the Doctor relocation from fe160134 while keeping that commit's
unrelated header changes (full-width header, 32px click targets,
role/tabindex plus Enter/Space activation).

- Restore the .doctor-control-group button in controls.html
- Drop the .doctor-toggle icon and the hamburger menu entry from the header
- Remove the Header.js 'doctor' dropdown action forwarding to the button
- Drop the header-scoped doctor-toggle styles
- Restore the .doctor-trigger styles (desktop and mobile) in doctor-modal.css
2026-09-13 08:13:55 +08:00
Will Miao f3b3393a16 i18n: translate Other Models feature strings into 9 locales
Complete the 36 keys left as [TODO: Translate] by the Other Models
feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet
management page and its opt-in toggles): settings.folderSettings.*,
other.*, initialization.other.*, toast.settings.otherRootsFailed and
banners.otherModels.*.

Model-type names (VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet)
stay in Latin per the model-type rule, so the five subType* values are
intentionally identical to en.json; "Other Models" is a page/feature
name and is translated. Document the new terminology in the i18n
translation guidelines and note the completed i18n phase in the plan.
2026-09-13 08:08:57 +08:00
Will Miao 480a3f4ea5 docs: record Other Models opt-in toggles in the plan (Phase 3)
Documents the settings keys and defaults, the enabled/disabled behaviour
matrix, the backend and frontend touch points, cache consistency, the
discoverability surfaces (hidden nav + announcement banner + download CTA)
and the minimal settings.json.example policy.
2026-09-13 07:59:28 +08:00
Will Miao 69a62d739c feat(frontend): opt-in Other Models toggles, hidden nav and announcement
- The Other nav entry is hidden while the feature is off
  (nav-item--hidden, toggled client-side after enabling) and now uses the
  fa-shapes icon.
- Shared utils/otherModels.js helpers (enable through the settings API,
  open the settings Library section) are reused by the disabled page, the
  announcement banner and the download modal.
- BannerService registers a one-time dismissible "other-models-announcement"
  banner while the feature is off; SettingsManager drops the banner and
  updates the nav when the master switch flips.
- A disabled download routing answer now surfaces a showActionToast with an
  "Enable Other Models" action.
- Settings UI: master toggle + five sub_type checkboxes whose default-root
  selects are disabled when unchecked; i18n keys added to en.json and synced
  (other locales keep TODO placeholders).
2026-09-13 07:59:28 +08:00
Will Miao 28fbb86dce feat(backend): gate Other Models behind opt-in management toggles
Other Models management is now opt-in: enable_other_models (default false)
plus the enabled_other_sub_types allow-list replace the unreleased additive
enabled_other_folders key.

- config._get_enabled_other_folder_keys() is the single scan gate; a new
  refresh_other_roots() rebuilds roots and preview roots on toggle.
- ModelScanner gains a _should_keep_cached_entry() hydration hook and
  on_library_changed(reconcile=...) so switching a sub_type off drops its
  entries (and hash/autov3 rows) at load time and switching it on rescans.
- OtherScanner filters location-derived entries accordingly.
- Other routes reject every other type while off (or a disabled sub_type) and
  expose an "other_disabled" page flag; download routing returns a disabled
  marker instead of guessing; the download manager refuses other-type
  downloads and default-path routing for switched-off sub_types.
- Doctor / init-status / refresh-all skip the other scanner while off; the
  scanner stays registered so staged pending-deletes still merge.
- Tests updated with explicit opt-in fixtures plus new gating coverage.
2026-09-13 07:59:28 +08:00
Will Miao f88fe2665c chore: keep settings.json.example minimal and document the rule
The example now only carries use_portable_settings, civitai_api_key and the
four core folder_paths keys (loras/checkpoints/unet/embeddings). Optional keys
such as the other-model folders, default_*_root and auto_organize_exclusions
are removed; their defaults live in DEFAULT_SETTINGS and reach the user's
settings.json on demand.

AGENTS.md now forbids adding optional/default keys to the example unless the
user explicitly asks for it.
2026-09-13 07:59:28 +08:00
Will Miao 3592eab48c Merge branch 'feature/other-models-page': Other Models page (VAE/upscaler/text encoder management + CivitAI downloads) 2026-09-12 16:41:09 +08:00
Will Miao 1dbdf5b00c docs: mark Phase 2 implemented in other-models plan 2026-09-12 15:56:47 +08:00
Will Miao fc3b2d7c13 feat(frontend): enable downloads on Other page and default_other_roots settings UI 2026-09-12 15:56:47 +08:00
Will Miao f2a7297cb9 feat(backend): CivitAI download support for other model types with subtype routing 2026-09-12 15:56:47 +08:00
Will Miao 57729375b6 docs: detail Phase 2 download design for Other Models page 2026-09-12 14:24:35 +08:00
Will Miao fa7ce725c1 feat(frontend): add Other Models page with subtype filter and badges 2026-09-12 11:25:51 +08:00
Will Miao 27da7b3ca3 feat(backend): add Other model type (VAE/upscaler/text encoder) scanner, service and routes 2026-09-12 11:25:51 +08:00
Will Miao 3070838a42 docs: plan for Other Models page (VAE/upscaler/text encoder management) 2026-09-12 09:29:42 +08:00
Will Miao fe160134d0 feat(ui): make app header full-width and move Doctor into header controls
- Drop the fixed max-width on .header-container so the header spans the
  viewport while the card grid keeps its own content width
- Keep header icon click targets at 32px at all breakpoints and add
  role/tabindex/aria-label plus Enter/Space activation
- Relocate the Doctor trigger from the page toolbar to the header icon
  group (also available on the statistics page and in the hamburger
  menu), removing the now-unused .doctor-trigger styles
2026-09-12 09:28:16 +08:00
Will Miao 6d3f82976f fix(scanner): serve folder tree from scan-recorded, persisted directory list (#1110)
The include_empty folder tree (download/move modals) walked every model
root synchronously on the event loop via get_all_folders(). On network
(NAS) roots this froze the whole server for the duration of the walk —
blocking WebSocket progress, aria2 RPC and the download queue — and the
5s TTL re-triggered the walk on nearly every modal interaction.

The scanners already visit every directory during cache scans, so record
the full directory list (including empty folders) there instead:

- _gather_model_data/_reconcile_cache collect directories during the
  existing walks; reconcile refreshes and persists the list even when no
  model files changed.
- ModelCache gains an all_folders field (None = never recorded).
- PersistentModelCache stores the list in a new folders table, with a
  cache_meta flag distinguishing 'recorded empty' from legacy snapshots.
- get_all_folders() is now a pure in-memory read. A legacy snapshot
  triggers a one-shot backfill walk in a worker thread (never on the
  event loop) that records and persists the list.
- Moves add the destination folder (and parents) incrementally instead
  of invalidating a TTL cache.
2026-09-11 23:03:24 +08:00
Will Miao 91b2735dad fix(recipes): make batch-import directory browser work on Windows (#1106)
The browse endpoint and its frontend were written with POSIX-only
assumptions, so on Windows pressing Browse immediately failed with
"Access denied to this directory":

- The frontend opened the browser at "/", which resolves to the
  current drive root on Windows.
- The allowlist check used Path("/"), which has no drive letter on
  Windows, so relative_to() rejected every drive-qualified path —
  anything outside the user profile was denied.

Fixes:
- Empty browse path now defaults to the user home directory instead of
  erroring; the frontend sends "" rather than the POSIX-only "/".
- The access check is platform-aware (drive-qualified on Windows,
  absolute on POSIX).
- Parent navigation uses the server-provided parent_path; the root
  check is now path.parent == path (the old str/anchor comparison
  self-looped at Windows drive roots).
- Browsing up from a Windows drive root shows a virtual list of
  available drives so users can switch drives without typing a path.
2026-09-11 22:23:55 +08:00
Will Miao 3112869a21 docs(technical): record Windows case-fold fallback follow-up in reconcile
The Windows-only case-insensitive match in ModelScanner._reconcile_cache
is the only pass left unverified by the recent realpath cleanup: realpath
may already cover case differences on Windows, and if the branch is ever
reachable it is O(files x cache entries). Records the reachability
question, the verification steps for a Windows run, and the two possible
fixes.
2026-09-11 22:23:55 +08:00
Will Miao aa630bf85b perf(services): skip per-file realpath work in cache reconciliation
A no-change Refresh still computed os.path.realpath for every model file
in the library and for every cached entry. Both values are only ever
consulted when a discovered file is missing from the cache, so on a
50k-file library they cost ~1.3s and ~0.6s while being used zero times.

- Compute the per-file realpath only after the exact cache match fails
- Build the physical-path alias map lazily on the first miss; the
  cross-run alias guard (overlapping roots / symlink layout changes)
  still keeps the cached entry instead of a delete + re-add, which would
  re-read metadata and re-hash the whole library
- Snapshot get_model_roots() once for the new-file pass instead of
  re-reading it for every added file
- Run the duplicate-path integrity pass only when the snapshot already
  contained duplicates or files were appended; a clean, unchanged cache
  has nothing to clean. Duplicates can only be introduced by external
  code rewriting raw_data or by this pass's own appends.

Zero-change reconcile drops from ~1400ms to ~120ms on 50k files, and an
alias flip still re-processes 0 files (#1108 investigation).
2026-09-11 22:23:55 +08:00
Will Miao e0052cd237 fix(download): align location-step root selection with backend diffusion routing
The download modal's location step decided between checkpoint and unet
roots using only the CivitAI file-type signal, while the backend also
falls back to DIFFUSION_MODEL_BASE_MODELS. Models like Anima (file type
"Model") were offered checkpoint roots in the UI even though
use_default_paths would route them to the unet root.

- Extract the two-tier decision into py/services/download_routing.py and
  reuse it in DownloadManager._execute_download
- Add POST /api/lm/download/routing so the UI asks the backend for the
  routing decision; fall back to the local file-type check on failure
- ModelVersionsTab: search both checkpoint and unet roots when resolving
  an existing version's download path
2026-09-11 12:41:03 +08:00
Will Miao 3cdc5ba7a2 fix(download): stop aria2 from leaking transfers when a download is cancelled
A cancel landing between aria2.addUri acceptance and the _transfers
registration found no tracked transfer, so DownloadManager tolerated the
"not found" and only cancelled the asyncio task — the daemon kept
downloading the file untracked while history showed the download as
cancelled.

- Register the gid in _transfers immediately after addUri returns,
  before any further await (state-store persist moved after it)
- Shield the addUri RPC so a mid-flight cancellation still learns the
  accepted gid and forceRemoves it before re-raising CancelledError
- On cancellation during the state persist, remove the daemon transfer
  unless it is paused (skip_download relies on paused gids surviving)
2026-09-11 08:14:14 +08:00
Will Miao 04485e384f feat(checkpoints): add Enrich HF Metadata (AI) to card context menu
The option only existed in the LoRA page menu. Move updateEnrichMenuItem
and enrichWithAgent into ModelContextMenuMixin so both pages share the
implementation, and add the menu item to the checkpoints template.
2026-09-09 17:30:42 +08:00
Will Miao a03dc4002f fix(move): recalculate sub_type when moving models across roots
Moving a checkpoint into a unet root (or vice versa) moved the file and
updated the in-memory cache, but three stale spots survived until a
manual cache rebuild:

- The moved .metadata.json kept the old sub_type, and the opportunistic
  sync_cache_from_metadata path (fired by get_model_metadata and example
  image metadata updates) trusted it, reverting the cache entry and the
  SQLite snapshot to the pre-move sub_type. Loader nodes filter strictly
  on sub_type, so the model stayed listed under the old type.
- The manager page discarded the move response's cache_entry, so the
  card badge (CKPT/DM) and context menu label kept showing the old type.

Fixes:
- move_model now re-resolves sub_type from the target location (new
  resolve_sub_type_for_path hook) and persists it into the moved
  .metadata.json.
- _sync_cache_from_metadata_impl runs desired entries through
  adjust_cached_entry so location-derived fields cannot be re-poisoned
  by stale metadata snapshots.
- MoveManager carries cache_entry.sub_type into the in-place card
  update so badge and context menu reflect the new type immediately.
2026-09-09 17:28:41 +08:00
Will Miao cc9d3bff42 fix(download): accept HuggingFace blob URLs in download dialog
detectUrlType only matched /resolve/ links, so pasting a HF web preview
(/blob/) URL fell through to direct-http and surfaced a misleading
'Invalid CivitAI URL format' error. Treat blob URLs as hf-resolve.
2026-09-09 11:47:17 +08:00
Will Miao 2672b3331b i18n: translate rematch summary modal strings into 9 locales 2026-09-09 11:07:42 +08:00
Will Miao 4963bf2b2e feat(recipes): show a summary modal after rematch runs
Replace the post-run toast cascade and the standalone L4 results modal
with a summary modal modeled on the batch download summary: 3-state
header, stat cards (matched / needs review / unresolved / errors),
an L4 review table with per-entry undo, and a copyable report. Wired
into the global, bulk and single-recipe rematch entries; complete
no-op runs keep the lightweight toast. Obsolete results-modal code,
styles and i18n keys are removed.
2026-09-09 10:38:10 +08:00
Will Miao 51cad6f852 i18n: translate recipe rematch options/results strings into 9 locales 2026-09-09 07:05:25 +08:00
Will Miao 1b5cbbbaa0 feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched
  entries can be undone via the existing restore flow
- Bulk missing-LoRA downloads mark unresolvable failures hash-invalid,
  flipping those entries from download to reconnect candidacy
- Recipe modal always offers a reconnect action next to download for
  missing LoRA entries
- Rematch runs collect an opt-in relaxed-matching choice (also reconnect
  missing models by file name) via a pre-run options dialog on the
  global, bulk and single-recipe entries
- L4 (filename-level) matches are listed in a results dialog with
  per-entry undo
2026-09-09 06:59:54 +08:00
Will Miao e747946f7a fix(onboarding): keep folder sidebar fixed-positioned during tutorial highlight
The .onboarding-target-highlight class sets position: relative, which
overrode .folder-sidebar's position: fixed (equal specificity, later
stylesheet). The sidebar left fixed positioning and moved in-flow, while
the spotlight/mask cutout stayed at the pre-highlight rect, leaving an
empty highlighted region during the folder sidebar step.
2026-09-07 19:32:58 +08:00
Will Miao 53fa22f39c fix(lora-loader): preserve repeated spaces inside lora names
Whitespace cleanup in cleanupLoraSyntax() and the autocomplete blur
formatter collapsed all whitespace runs, including inside <lora:...>
tags. A file named 'test -  0021.safetensors' was rewritten to
'test - 0021' in the node text, so runtime file resolution failed.

Protect lora tags with placeholders (or segment splitting) so only
whitespace between entries is normalized; names inside tags are kept
byte-for-byte.
2026-09-07 19:20:15 +08:00
Will Miao 82b34097fb refactor(metadata): remove vestigial top-level trainedWords field
The field dates back to a development-stage bug in the enrich-metadata
(agent) pipeline, which briefly wrote trigger words at the top level of
model metadata instead of the established civitai.trainedWords location.
The write path was fixed before the feature merged to main (PR #1013)
and never shipped in any release, so no writer has existed since.

Remove the leftover pieces:

- BaseModelMetadata.trainedWords field (py/utils/models.py); sidecars
  from that dev window now pass the key through _unknown_fields instead
- HF download handler's strip-empty-trainedWords special case, reverting
  to saving the metadata object directly (py/routes/handlers/hf_handlers.py)
- trainedWords in the LLM enrichment context (agent_service.py)
- matching fallbacks/fixtures in the enrich_hf_validation harness and
  post-processor test

Trigger words continue to live in civitai.trainedWords for all model
sources, which is what the UI, agent post-processor, and metadata sync
all read and write.
2026-09-07 16:24:15 +08:00
Will Miao a7995db009 fix(llm): add failure cooldown and lock for model catalog fetch 2026-09-07 10:17:48 +08:00
Will Miao 5ae4aef30e fix(llm): disable brotli for catalog fetch to prevent native crash (#1099, #1101)
models.dev is served by Cloudflare with brotli compression when the
client advertises it, and brotli is a required dependency here, so
aiohttp always negotiates br. A corrupted br stream can crash the
native decoder with a Windows access violation (a Python-level
exception handler cannot catch it), or produce garbage bytes.

Send an explicit "Accept-Encoding: gzip, deflate" header on the model
catalog and Ollama model-list requests so the server never returns
brotli. zlib handles corrupt gzip data by raising ContentEncodingError
(an aiohttp.ClientError subclass), which the existing handlers already
catch and degrade to a warning with an empty-catalog fallback.
2026-09-07 09:54:28 +08:00
willmiao 08023f0cd9 docs: auto-update supporters list in README 2026-09-06 14:29:39 +00:00
Will Miao 6e2185c182 chore(release): bump version to v1.2.2 2026-09-06 22:29:26 +08:00
Will Miao 41302e75ba fix(download): save multi-variant files under raw stored filenames (#1100)
The public REST API rewrites files[].name to "{model}_{version}" for
non-LoRA model types, so every precision variant of a multi-file version
shared one name and landed on disk with a random short-hash suffix.

Fetch the raw stored filename from the model-versions/mini endpoint
(always pinned with modelFileId) and use it for the on-disk name and
metadata when available; fall back silently to the REST name otherwise.
CivArchive already serves raw names and is skipped.
2026-09-06 22:23:48 +08:00
Will Miao a17399d667 feat(recipes): delegate CivitAI-image re-import to companion browser extension
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.

- POST /api/lm/recipe/{id}/reimport accepts optional payload params
  (image_url/name/resources/gen_params/base_model/tags); the payload path
  reuses the import-remote engine with reimport semantics (user-edit
  carryover, delete-after-save), and malformed/failed payloads fall back
  to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
  (documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
  delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
  and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
  all 9 locales.
2026-09-06 20:26:14 +08:00
Will Miao e2d85a0a21 fix(recipes): allow download for version-only recipe LoRAs (no modelId/hash)
Page-imported recipes can carry an exact CivitAI modelVersionId but no
modelId and no hash (CivitAI exposes no sha256 for e.g. Krea versions).
canDownloadLora() required (modelId && versionId) or a hash, so such
entries were misclassified as unrepairable and offered Reconnect instead
of Download.

- canDownloadLora: treat a bare version id as downloadable (it uniquely
  pins the file; the model id is resolved on demand at download time).
  A model id without an exact version id stays non-downloadable to avoid
  silently grabbing the latest version.
- resolveLoraDownloadIdentifiers: when a hash is absent but a version id
  exists, resolve the owning model id via /civitai/model/version/{id}
  (same endpoint the bulk download missing flow uses). Hash-only and
  direct (modelId+versionId) paths are unchanged.
2026-09-06 19:05:07 +08:00
Will Miao 303833bbae fix(llm): catch UnicodeDecodeError when fetching model catalog (#1099)
resp.json() raises UnicodeDecodeError (not JSONDecodeError) when the
remote body contains invalid UTF-8 bytes, which the exception handler
did not catch and could crash the app. Apply the same fix to both
_load_model_catalog and fetch_ollama_models so they fall back to an
empty catalog. Add regression tests for both paths.
2026-09-06 12:04:16 +08:00
Will Miao f86b7b55d6 feat(recipes): remove deprecated Repair Metadata feature
The recipe "Repair Metadata" action has been marked Deprecated in the UI
for a while and cannot reliably recover recipes imported from CivitAI URLs
whose REST meta has no resources/hashes and whose image has no embedded
metadata (e.g. CivitAI-only generation data). Drop the feature end to end.

Backend:
- remove repair routes (repair, cancel-repair, recipe/{id}/repair,
  repair-bulk, repair-progress) and their handler mappings/methods
- remove RecipeScanner repair_all_recipes / repair_recipe_by_id /
  _repair_single_recipe and REPAIR_VERSION
- remove WebSocketManager recipe-repair progress channel
- drop repair_version column from the persistent recipe cache
- rematch mutual-exclusion now only checks rematch

Frontend:
- remove repair entries from per-recipe, bulk and global context menus
- remove repairRecipe / repairSelectedRecipes / repairRecipes + cancelRepair
  and the repairBulk API client method/endpoint
- drop recipe-repair i18n keys (synced across locales; doctor keys kept)

Tests/docs: delete test_recipe_repair.py, update scaffolding/routes/ws/
persistent-cache/integration tests and i18n guideline examples.
2026-09-05 16:56:20 +08:00
Will Miao 782bb53784 fix(ui): restore equal-width toasts in toast container
Commit 86aa1d80 added align-items: flex-end to .toast-container and
dropped the .toast min-width to 200px. With flex-end alignment each
toast now shrinks to its own content width, so toasts of different
message lengths render at inconsistent widths. Drop the align-items
override so the container falls back to stretch, giving every toast a
single shared width as before the change.
2026-09-05 07:23:54 +08:00
Will Miao 139231e225 chore(skill): remove lora-manager-e2e skill, keep sandbox helpers in scripts/e2e/ 2026-09-05 07:10:43 +08:00
Will Miao 121d8d5cea fix(ui): lighten toolbar shortcut keycaps and fix contrast on active buttons 2026-09-05 00:21:11 +08:00
Will Miao ec147bd677 feat(ui): add setting to keep the action bar visible while scrolling (#1095)
Adds a 'Keep Action Bar Visible' toggle (default off) under
Settings > Interface > Layout Settings. When enabled, the controls bar
(Refresh, Download, etc.) and the breadcrumb nav are wrapped in a shared
sticky container (.sticky-topbar) so both stay pinned as one unit; when
disabled, the wrapper is display: contents and the original behavior
(only the breadcrumb stays visible) is preserved.
2026-09-04 23:53:27 +08:00
Will Miao 93fc28b499 chore(ui): rebuild vue-widgets bundle
Rebuilt from the preceding three commits' sources:

- active-filters chip and its dead settings-toggled broadcast removed
  (the built bundle also no longer embeds the scripts/app.js test shim
  that the chip's settings.js import used to pull in)
- scrollbar inset re-measured on programmatic value changes
- app/api bindings canonicalized to "../../../scripts/*" externals and
  settings.js no longer inlined (bound at runtime via "../settings.js"
  to the vanilla module instance), per the new build guard
2026-09-04 19:02:36 +08:00
Will Miao 7afed1a14b fix(ui): cross-link right-click menu in active-filters settings tooltip
The loramanager.lora_active_filters_autocomplete tooltip only mentioned
the /activefilters and /noactivefilters commands, while the prompt-node
tag-autocomplete tooltip cross-links every toggle entry point (typing in
the node and the node's right-click menu). 6ba64ebb added the right-click
menu entry without extending the tooltip to match; align the wording with
the established pattern.
2026-09-04 19:02:31 +08:00
Will Miao e6f5142e48 fix(ui): re-measure autocomplete scrollbar inset on programmatic value changes
The --lm-vscrollbar-width inset from 634ea7f2 was only refreshed on input
events, mount and mode changes. Programmatic value updates (widget.setValue
from "send lora to workflow", external value-change events) change the
textarea content without an input event, leaving the corner clear (x)
button overlapping a freshly appeared classic scrollbar until the next
keystroke. Mount-time pending value replay was already covered.

- onExternalValueChange and widget.onSetValue now call
  updateVScrollbarWidth() alongside the hasText update
- tests: cover both paths by overriding textarea metrics to an overflowing
  state and asserting the 15px gutter lands in the CSS var
2026-09-04 19:02:31 +08:00
Will Miao 87f05fb66c build(vue-widgets): keep shared runtime modules external in the widget bundle
Guard against the inlined-shim bug class that broke the removed
active-filters chip: importing web/comfyui/* modules from widget source
inlines them into lora-manager-widgets.js, and their own relative imports
then resolve against the repo filesystem at build time instead of the
vanilla files' runtime URL layout.

A resolveId plugin (enforce: pre) now returns explicit external markers:

- scripts/app.js and scripts/api.js imported at any "../../scripts/*"
  depth are rewritten to the canonical "../../../scripts/*" specifier so
  every app/api binding in the bundle is the real ComfyUI module. The
  repo-root scripts/app.js is a unit-test shim (in-memory settings store)
  and must never be bundled; the canonical depth is the only one that
  resolves from the emitted bundle's served location.
- web/comfyui/settings.js is externalized to "../settings.js" so the
  bundle binds to the SAME vanilla module instance the ComfyUI extension
  loader already runs - real settings store, registerExtension side
  effect executed exactly once, no duplicated module state.

A companion plugin warns on any web/comfyui/* import from widget source,
since an inlined copy still duplicates module-level side effects.

Notes from validating the mechanism: rollup output.paths resolves
returned paths to absolute filesystem locations (rejected), and a depth
regex inside rollupOptions.external matches raw specifiers before
resolveId hooks run and would emit the shim-relative depth verbatim
(rejected) - hence explicit { id, external: true } returns.
2026-09-04 19:02:25 +08:00
Will Miao cf64e5baa8 fix(ui): remove per-node active-filters chip from loras widgets
The indicator chip added for /activefilters discoverability was broken by
design of its import path: AutocompleteTextWidget.vue imported
web/comfyui/settings.js into the vue-widgets bundle, and settings.js's
"../../scripts/app.js" import resolved at build time to the repo-root test
shim (scripts/app.js, an in-memory settings store). The chip therefore read
and wrote an orphaned in-memory Map: clicking it flipped only its own
visual state and never touched the real ComfyUI setting that
autocomplete.js consults (use_active_filters query param).

Beyond the defect, a persistent per-node control for a global persisted
setting misleads users and needs cross-instance sync machinery, which the
footer hint, slash commands, right-click menu entry and settings dialog
already cover.

- AutocompleteTextWidget.vue: remove the chip button, its state/handlers,
  the settings.js import (the shim-inlining pathway) and all chip styles
- AutocompleteTextWidget.test.ts: drop the chip indicator describe block
  and the settings.js module mock; beforeEach import no longer needed
- settings.js: drop the lora-manager:setting-toggled window broadcast and
  its export — the chip was its only consumer, so every
  setLoraManagerSettingValue write no longer dispatches a dead event
- autocomplete.activeFilters.test.js: drop the broadcast assertion test
- loraLoader.activeFiltersMenu.test.js: drop SETTING_TOGGLED_EVENT_NAME
  from the settings.js mock

Discoverability of /activefilters // /noactivefilters is unchanged:
command-list footer, first-run hint, node context menu, settings dialog.
2026-09-04 19:02:16 +08:00
Will Miao 634ea7f299 fix(ui): keep autocomplete corner buttons clear of the textarea scrollbar
The absolutely-positioned clear (x) and active-filters filter buttons sit at
the textarea's right edge, so when content overflows and a classic
(non-overlay) vertical scrollbar appears the buttons overlap it. Measure the
scrollbar gutter (offsetWidth - clientWidth) when content overflows and
expose it as --lm-vscrollbar-width on .input-wrapper; the buttons' right is
now calc(base + var) so they shift left of the scrollbar only while one is
present (0 otherwise, incl. overlay-scrollbar platforms).

Refreshed on input, mount, canvas/Vue-DOM mode change and via a ResizeObserver
on the textarea (widget resize). Rebuilt the vue-widgets bundle.
2026-09-04 12:41:31 +08:00
Will Miao 6ba64ebb3c feat(ui): improve /activefilters discoverability on loras nodes
Mirror the /noautocomplete discoverability pattern for the loras\nactive-filters search toggle:\n\n- autocomplete.js: extend the slash-command-list footer and the\n  one-time first-run hint to loras nodes, advertising\n  /activefilters and /noactivefilters\n- lora_loader.js: add an 'Active Filters Search: ON/OFF' entry to the\n  right-click menu of all loras-autocomplete node classes\n- settings.js: broadcast a 'lora-manager:setting-toggled' window event\n  on every setLoraManagerSettingValue write\n- AutocompleteTextWidget.vue: add a persistent filter indicator chip\n  (loras mode only) that reflects and toggles the setting and stays in\n  sync via the setting-toggled event\n- tests: footer/hint/event coverage, context-menu tests for all four\n  node classes, widget indicator tests; rebuild vue-widgets bundle
2026-09-03 22:42:45 +08:00
Will Miao 03569c62df feat(ui): point help new-content indicator at the updated tabs and elements
- Replace timestamp comparison (help_last_viewed vs a hardcoded date) with
  a content-version marker (data-help-content-version) read from the
  rendered modal markup, so badge state always reflects the content
  actually served
- Only mark content as viewed when the modal is opened while it contains
  new content; opening a stale pre-upgrade page no longer suppresses the
  badge after a refresh
- Flag the Replay Tutorial button itself with a 'New' chip (hidden by
  default, one-time glow animation) and scroll it into view when
  revealed; tab-level dots now mark getting-started and shortcuts
  instead of documentation
- Translate help.newContentBadge into all 9 locales, reusing the
  established help.documentation.newBadge renderings
- Add HelpManager content-version unit tests (12 cases)
2026-09-03 21:40:18 +08:00
Will Miao a61840b366 i18n: translate onboarding/shortcuts/trigger-word keys into all 9 locales
- Fill all 41 [TODO: Translate] placeholders per locale (new onboarding
  steps, Shortcuts cheat-sheet tab, trigger-word copy/edit tooltip)
- Retranslate stale onboarding bulk/contextMenu step contents to match
  the updated en.json source
- Follows docs/i18n-translation-guidelines.md term maps, register, and
  punctuation rules; HTML tags and key names preserved verbatim
2026-09-03 19:20:54 +08:00
Will Miao 726fc178f1 feat(ui): add R/F/D action shortcuts and unify keycap hint style
- Bind R=refresh, F=fetch metadata, D=download in PageControls via
  eventManager (plain letters only, skipped while typing or when a
  modal is open); triggers reuse the buttons' existing click handlers
- Show key-hint chips on the refresh/fetch/download/bulk toolbar
  buttons; convert the bulk chip to a semantic <kbd>
- Redesign shortcut hints as a neutral theme-adaptive keycap:
  --shortcut-* variables in base.css now derive from --text-muted
  with a bottom-edge shadow, shared by the toolbar chips, the header
  search cue, the help-modal cheat sheet, and onboarding key hints
- Add shared isTypingContext() helper to uiHelpers
- Add an Actions group (R/F/D) to the Shortcuts cheat-sheet tab

Verified with vitest (926 passing, incl. 6 new shortcut cases) and a
sandboxed E2E run in real Chrome (light/dark rendering, hover state,
'?' opening the Shortcuts tab, clean console)
2026-09-03 19:10:16 +08:00
Will Miao 8260bd022d feat(ui): improve discoverability of hidden interactions
- Expand onboarding tour from 8 to 11 steps: marquee drag-select,
  drag card to sidebar folder, and the three context menus
  (card / bulk / global); enrich bulk-mode step with range-select
  and exit tips
- Add Replay Tutorial button to help modal Getting Started tab
- Add Shortcuts cheat-sheet tab to help modal, opened directly via
  the '?' key when not typing
- Fix trigger-word tooltip to mention double-click to edit
- Keep checkpoint/embedding send tooltips truthful (no replace mode)

Sync new i18n keys to all locales (placeholders pending translation)
2026-09-03 18:16:00 +08:00
Will Miao b309becdf9 fix(recipes): keep source_path empty on local-fallback re-import
Re-importing a file-imported recipe fell back to its own saved preview
image, then recorded that internal path as the new recipe's source_path.
Since the old preview is deleted with the old recipe, this left a
dangling source_path that showed up as a bogus source URL and blocked
any further re-import with 'no re-importable source'.

Only persist source_path when the re-import source is an accessible
external file; otherwise keep it empty. Also let a dangling non-URL
source_path fall back to the recipe's own image so existing affected
recipes can re-import again.
2026-09-03 17:13:24 +08:00
Will Miao 1e375bb8d9 i18n: translate common.scanProgress into all 9 locales 2026-09-03 11:47:42 +08:00
Will Miao 14da8a6f17 feat(ui): show live scan progress and ETA for cache refresh
Broadcast typed scan_progress messages over /ws/fetch-progress from the
manual refresh/rebuild paths of ModelScanner and RecipeScanner, and
render percent, processed/total, current file name and an EMA-smoothed
ETA in the loading overlay. Hardcoded refresh strings move to i18n
(common.scanProgress); WS connection failure falls back to the previous
static loading behavior.
2026-09-03 11:38:27 +08:00
Will Miao da71985c3e fix(autocomplete): strip lastAccepted boundary from exported workflows (#1093)
The hidden __lm_autocomplete_meta_* widget persisted lastAccepted
(insertedText/textSnapshot) into exported workflow JSON, leaking old
prompt text even after the user deleted it.

Patch app.graphToPrompt (shared by workflow export, Export API and
queueing) to strip lastAccepted from the serialized result's
widgets_values / widgets_values_named / output inputs. Only the
exported artifact is touched; live node state, undo snapshots,
copy/paste and local saves keep the boundary intact.
2026-09-03 08:29:55 +08:00
Will Miao 7c4c8b8f30 fix(ui): add disabled state and feedback to usage tips Add button
The Add button silently returned when no parameter or value was
provided, looking clickable but doing nothing. Keep it disabled until
both inputs are filled, validate the numeric value, surface save
failures via toast without clearing user input, and confirm additions
vs overwrites with success toasts. Includes translations for all
locales.
2026-09-02 23:15:23 +08:00
Will Miao 77109b3cf8 feat(autocomplete): group relative-path results by folder (#1091)
Autocomplete suggestions were ranked purely by relevance across the whole
library, so same-named loras from different subfolders interleaved and were
hard to tell apart. Results are now bucketed by folder (root first, then
alphabetically, with nested paths sorting naturally) while keeping the
existing relevance ordering within each folder group.
2026-09-02 22:01:44 +08:00
Will Miao 00095a5398 fix(autocomplete): sync active filters via server-side store (#1091)
The LoRA Manager page kept its active filters in localStorage, which the
ComfyUI-side autocomplete read directly. When the two run in different
browsers, origins, or the ComfyUI Desktop Electron shell, localStorage is
not shared and the active-filters search silently did nothing.

The manager page now mirrors its filter state to a server-side in-memory
store (PUT /api/lm/{prefix}/active-filters), pushed on every change via a
storage-listener hook and once on page load. The autocomplete widget sends
only use_active_filters=true, and the relative-paths endpoint injects the
stored filters into the search, with explicit query params taking
precedence.
2026-09-02 14:33:44 +08:00
Will Miao 6b41c3bbb4 fix(tests): deflake recipe modal resource item tests by disposing modal instances
RecipeModal instances keep fire-and-forget async chains (hydration
re-renders, mark-hash-invalid re-renders, 500ms reconnect/restore
re-renders) and deferred DOM wiring timers alive across tests. On slow
CI runners these land in the next test's window and overwrite or re-wire
the shared document.body with stale content and stale instance handlers,
failing a different test on every run.

Add a tracked-timer helper and a dispose() teardown hook to RecipeModal:
pending deferred work is cancelled, in-flight async chains become no-ops
after disposal, and the global click listener is detached. The test
afterEach now disposes every modal instance, making the file hermetic.
2026-09-02 12:40:37 +08:00
Will Miao b37238d790 fix(ui): disable modal backdrop blur under software rendering (#1092)
With hardware acceleration disabled, Chrome rasterizes in software and a
full-viewport backdrop-filter forces a per-frame CPU blur over everything
behind the modal, freezing the whole browser.

Detect software rendering via the unmasked WebGL renderer string at app
startup and drop the backdrop blur in that case. Also route the download
modal's sticky toolbar through the shared --modal-backdrop-blur variable
instead of a hardcoded blur(8px).
2026-09-02 12:24:32 +08:00
Will Miao bc33e32c6f feat(showcase): add wheel, swipe and keyboard navigation to the example gallery
- Wheel on the main viewer: horizontal deltas always switch examples;
  vertical deltas switch only at the modal scroll boundary, then stay in a
  sticky session (down = next, up = prev) until the pointer leaves the area
- Touch/pen horizontal swipe switches examples; the synthesized click after
  a swipe is swallowed so the media viewer does not open
- '[' / ']' switch examples while the gallery is expanded; ArrowLeft/Right
  stay reserved for model-level navigation
- Direction-aware slide transition on every switch for visual feedback
  (respects prefers-reduced-motion)
2026-09-02 11:52:59 +08:00
Will Miao 49704d801c fix: show lora info regardless of toggle state 2026-09-02 11:38:30 +08:00
Will Miao 34ca14d7fc fix(showcase): reset gallery position when loading a model's examples
The module-level galleryState kept activeIndex/expanded across models
(the modal is a singleton), so opening model B after navigating model A
started B's gallery at A's last index. Reset activeIndex, expanded and
lastNavDirection in loadExampleImages, the per-model entry point.
2026-09-01 22:54:36 +08:00
Will Miao f7b247f9e8 perf(showcase): cap main viewer image width at 2400 via display mode
- New OptimizationMode.DISPLAY (width=2400 for images, full quality for
  videos) and getDisplayUrl(); the in-modal main viewer renders at most
  ~1200 CSS px wide, so full-size originals wasted 50-70% bandwidth
- Main viewer and adjacent prefetch use display URLs; the full-size
  media viewer keeps using getShowcaseUrl for original quality
2026-09-01 22:45:00 +08:00
Will Miao 3005d2877e perf(showcase): direction-aware prefetch and lazy video thumbnails
- Track last navigation direction and prefetch one extra example ahead
  along it, so repeated prev/next clicks stay cache-hot
- Start strip video thumbnails at preload=none and enable metadata
  loading only when they scroll into view
2026-09-01 22:37:13 +08:00
Will Miao ed2a17970f perf(showcase): prefetch adjacent examples and shrink gallery thumbnails
- Warm the HTTP cache for examples adjacent to the active one after
  expand and on every navigation, so prev/next feels instant (images
  only, deduped, low fetch priority)
- Add GALLERY_THUMBNAIL optimization mode (width=160) for the 72px
  gallery strip instead of reusing the 450px card thumbnails
- Hint priorities: fetchpriority=high on the main media, low on
  strip thumbnails
2026-09-01 22:26:50 +08:00
Will Miao 9584fa85c9 feat(recipes): add location open and recipe ID copy to recipe modal
Add a de-emphasized meta footer to the recipe modal, mirroring the model
modal's hash footnote: a clickable file location on the left (opens the
recipe JSON via the generic open-file-location route, with the Docker
clipboard fallback) and a middle-truncated recipe ID with copy button on
the right.

The recipe detail API now exposes recipe_json_path so the frontend does
not have to guess the on-disk storage layout. Translations for the new
recipes.modal.* keys are filled in for all 9 locales, reusing the model
modal's openFileLocation wording per locale.
2026-09-01 21:58:47 +08:00
Will Miao 1fd7cc0123 fix(recipes): reject the empty-hash placeholder when resolving LoRA hashes
The SHA256 of an empty byte string (written by repackaging tools into
safetensors metadata, or produced by hashing an empty/unreadable file)
was previously resolved against CivitAI's by-hash API, which can contain
polluted entries for it (e.g. a broken SD 1.5 LoRA whose AutoV3 equals
the placeholder) and falsely attributed the wrong model to a recipe.

Guard all lookup paths for the 10/12/64-char AutoV2/AutoV3/full-SHA256
spellings: CivitaiClient.get_model_by_hash/_fetch_version_by_hash return
not-found without a request, and ModelHashIndex ignores the placeholder
in has_hash/get_path/add_autov3.

The Automatic1111 metadata parser keeps the LoRA item itself when its
hash is the placeholder: it matches by filename locally, or retains the
entry with an empty hash flagged hashInvalid (unresolvable-hash state in
the UI, with reconnect as the remedy) instead of dropping it or resolving
it to a polluted CivitAI entry.
2026-09-01 21:14:30 +08:00
Will Miao 39e7c1376c Support re-import for recipes without a source URL
Recipes imported by drag & drop / file-picker record no source_path and
were rejected by re-import. Fall back to the recipe's own saved image,
which still carries the original embedded generation metadata.

Re-import now re-parses that original metadata instead of the appended
recipe JSON block, so parser upgrades produce fresh results. The
already-optimized preview image is kept verbatim: only its WebP EXIF
chunk is rewritten in place to replace the recipe metadata block, and
the recipe JSON is rewritten with the new analysis plus carried-over
user edits.
2026-08-31 10:01:18 +08:00
Will Miao 2a3c632dc5 feat(recipes): add Unknown base-model filter bucket for undetermined recipes
Normalize undetermined recipe base_model to None in RecipeFormatParser
(previously ''). get_base_models now reports an "Unknown" bucket backed
by a dedicated __unknown__ marker, and the listing filter matches it
against recipes whose base model is falsy. Frontend renders the bucket
label as "Unknown" while filtering via the marker.

Tests: handler, scanner, parser, and frontend filtering.
2026-08-31 09:09:53 +08:00
Will Miao 8d46d26abe fix(tests): deflake recipe open stats tests by shrinking debounce in tests
The four tests that wait on the background debounced write race against
SAVE_DELAY (1.0s): _wait_for_save polls 100 x 0.01s = 1.0s, exactly equal to
the debounce, leaving zero slack. On a loaded CI runner the write lands after
the poll gives up, failing intermittently with 'Recipe open stats file was
never written' (5 of 62 backend runs since the tests landed).

Shrink SAVE_DELAY to 0.05s in _prepare so the write lands ~20x inside the
poll window. The debounce duration is not what these tests verify; production
default stays 1.0s.
2026-08-30 22:14:04 +08:00
Will Miao d761ac77f7 fix(recipes): align LoRA reconnect affordances with checkpoint rules
- Offer reconnect for name-only LoRA entries with no CivitAI
  identifiers, matching the checkpoint "broken" classification
  instead of rendering no action at all
- Mark a LoRA hash-invalid when a direct (modelId/versionId) download
  fails with a clearly unresolvable error, mirroring the checkpoint
  path; transient failures leave the entry untouched
2026-08-30 18:33:24 +08:00
Will Miao c8b9db5bf4 feat(recipes): add manual checkpoint reconnect for broken recipe entries
Checkpoint entries that cannot be restored by download (deleted,
unresolvable hash, or name-only remnants with no CivitAI identifiers)
now get the same remediation chain LoRAs already had:

- scanner: parameterized reconnect-suggestion ranking, update/restore/
  set-hash-invalid for the checkpoint entry, and clear hashInvalid on
  rematch write-back (was only done for LoRAs)
- persistence/handlers/routes: reconnect/restore/reconnect-suggestions/
  mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/*
- modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline
  form with suggestions, undo for reconnected entries); download
  failures mark the hash invalid only on explicit unresolvable signals
  (not found/deleted/404/410), matching the LoRA rule
- css: checkpoint undo button shares the LoRA undo styles
- i18n: the 14 new keys translated in all 9 locales
2026-08-30 18:02:15 +08:00
Will Miao bce7d1d30c docs(i18n): resolve R1 vs R8/§7 contradiction on proactive translation
R1 instructed agents to "translate the newly added keys in every locale"
right after syncing, while R8 and §7 make [TODO: Translate] placeholders
the sanctioned end state during feature development until the feature
owner explicitly asks for translations. Reword R1 and the AGENTS.md
Localization section to say stop after syncing and never translate
proactively.
2026-08-30 16:28:53 +08:00
Will Miao bccd494a56 feat(recipes): explain empty LoRA lists with collapsible "Why no LoRAs?" panel
Record import provenance on every recipe: a new import_info block
(channel, machine-readable no-LoRA reason, diagnostic details) built at
import time across all channels (batch import, single URL, local file,
upload, widget save, re-imports) and persisted in the recipe JSON plus
the SQLite persistent cache (new import_info_json column with ALTER
TABLE migration).

The recipe modal renders the empty LoRA list with a collapsed details
panel showing the import method, the reason (CivitAI API returned no
LoRA resource data, API meta missing, no embedded metadata, ComfyUI
workflow metadata, video, unparsable format), and recorded diagnostics.
Legacy recipes without import_info fall back to heuristics labeled as
inferred. Genuine no-LoRA generations show no panel.

CivitAI images are always classified by API meta shape: the onsite
generator writes A1111-style EXIF without LoRA references, so parsed
EXIF cannot prove "no LoRAs used".

Adds recipes.resources.noLoras* i18n keys (all 10 locales) plus
frontend vitest and backend pytest coverage.
2026-08-30 16:28:41 +08:00
498 changed files with 85998 additions and 8345 deletions
-146
View File
@@ -1,146 +0,0 @@
---
name: lora-manager-e2e
description: "End-to-end testing and validation for LoRa Manager features. Use ONLY for sandboxed E2E validation of LoRa Manager standalone mode: start the standalone server on a free port with --settings-path, drive the web UI (http://127.0.0.1:{PORT}/loras) via Chrome DevTools MCP, and verify frontend-to-backend integration. NOT for UI behavior checks that unit tests (Vitest/jsdom) can cover. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox."
---
# LoRa Manager E2E Testing
End-to-end testing of LoRa Manager standalone mode using Chrome DevTools MCP.
## When to Use — and When NOT To
E2E runs are slow and token-heavy. Reach for them only when the question genuinely
spans server + browser (routing, scan persistence, websocket updates, EXIF writes).
- **Default to unit/component tests first**: `npm run test:js` (Vitest/jsdom) covers
DOM rendering, modal behavior, event handling and API-client calls deterministically
in seconds. Backend logic goes through `pytest`. A UI-behavior question answered by
jsdom MUST NOT be escalated to E2E.
- **Use E2E only when** the behavior cannot be observed without a live server and a
real browser, e.g. template rendering through the aiohttp server, scanner → SQLite
persistence → API → DOM round-trips, or real EXIF/image writes.
- If you start an E2E and realize a unit test would answer the question, stop and
switch.
**Browser driver is fixed: Chrome DevTools MCP.** Do not substitute kimi-webbridge —
it operates on the user's real browser (real tabs, real sessions, synthetic
`isTrusted=false` events), which breaks the isolation this skill requires and lacks
the console/network inspection E2E debugging relies on. kimi-webbridge is for
interactive browsing with the user's real login sessions, not for sandboxed E2E.
## Conventions
- **`{PORT}`**: default candidate `8188`, but it is **commonly occupied by a live
ComfyUI** — always check first (`ss -tlnp | grep ':{PORT}'`) and use a free port
(e.g. `8199`). Substitute the chosen port everywhere below. Never kill a process
you did not start for this E2E.
- **`<repo-root>`**: the repository/worktree root; run all commands from there.
- **`<sandbox>`**: a throwaway dir, e.g. `/tmp/opencode/<plan>-e2e`.
## SANDBOX (MANDATORY)
> Every E2E run MUST target a throwaway sandbox, never real user data.
1. **Explicit settings directory**: always launch with `--settings-path <sandbox>/settings`.
This pins ALL runtime data (`settings.json`, `cache/`, `backups/`, `logs/`, `stats/`,
`wildcards/`) under the sandbox. **Never** create `<repo-root>/settings.json` — the repo
folder is usually the real ComfyUI plugin folder and a portable settings file there is
read by the real instance.
2. **Sandboxed library paths**: point `folder_paths` / `recipes_path` /
`example_images_path` at disposable dirs under `<sandbox>` — never the real library,
real recipe dir, or real settings:
```json
{
"folder_paths": {
"loras": ["<sandbox>/models/loras"],
"checkpoints": ["<sandbox>/models/checkpoints"],
"unet": ["<sandbox>/models/checkpoints"],
"diffusers": []
},
"recipes_path": "<sandbox>/recipes",
"example_images_path": "<sandbox>/example_images"
}
```
3. **Real-data protection proof**: before starting and after finishing, snapshot the real
config and recipe library and confirm they are byte-identical; also confirm
`<repo-root>` gained no `settings.json` or `cache/`:
```bash
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > <sandbox>/settings.before.sha256
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > <sandbox>/recipes-count.before.txt
# AFTER the run: record again and diff. Any change = the run leaked into real data.
```
## Quick Start
```bash
cd <repo-root>
# 1. Sandbox
mkdir -p <sandbox>/settings <sandbox>/models/{loras,checkpoints} <sandbox>/{recipes,example_images}
# write <sandbox>/settings/settings.json per the SANDBOX example
# 2. Port
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
# 3. Server — MUST be fully detached (a plain background & dies with the shell);
# the helper enforces this and manages its own pidfile
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
--port {PORT} --settings-path <sandbox>/settings --wait --timeout 30 --detach
ss -tlnp | grep ':{PORT}' # verify listening BEFORE proceeding
# 4. Chrome with remote debugging, then connect Chrome DevTools MCP (verify via list_pages)
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
```
Then drive the UI with the MCP tools (`take_snapshot`, `click`, `fill`, `fill_form`,
`evaluate_script`, `wait_for`, `list_network_requests`, `list_console_messages`) —
see [references/mcp-cheatsheet.md](references/mcp-cheatsheet.md) for patterns.
Server restart after config/fixture changes:
```bash
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
--port {PORT} --settings-path <sandbox>/settings --restart --wait --detach
# then reload the browser page (ignoreCache=True)
```
`--restart` only kills the E2E server the script itself started (via its pidfile) and
aborts instead of killing unrelated processes on the port.
## Abort Rule
A sandboxed E2E should finish in well under 30 minutes. If any phase exceeds ~2x its
expected duration (server readiness > 60 s, MCP connect > 2 min, a single scenario >
10 min), or any single tool call fails 3+ times in a row, **STOP** — do not retry
blindly. Report `BLOCKED` with the phase, last observed state (server PID,
`ss -tlnp` output, page snapshot, last API response) and suspected cause. A clean
BLOCKED report beats an hour of retries.
## Troubleshooting
- **"browser is already running" / `list_pages` fails**: a stale Chrome holds the
profile dir. Find it (`ps -ef | grep -i '[c]hrome.*user-data-dir'`), confirm it is a
leftover QA Chrome (not the live ComfyUI, not your current MCP browser), kill only
that PID, then retry `list_pages`.
- **MCP refuses to write screenshots into the worktree**: save to `/tmp` via
`take_screenshot(filePath="/tmp/...")` and copy into the evidence dir from the shell.
## Cleanup
1. Stop the standalone server: `kill <recorded-pid>` (only the PID you started), then
confirm `ss -tlnp | grep ':{PORT}'` is empty.
2. Close browser pages (keep at least one open).
3. `rm -rf <sandbox>`; verify `<repo-root>` gained no `settings.json` or `cache/`.
4. Re-run the real-data protection check from the SANDBOX section and record the result.
## References & Scripts
- [references/mcp-cheatsheet.md](references/mcp-cheatsheet.md) — Chrome DevTools MCP
command patterns (navigation, waiting, snapshots, forms, network, console, performance).
- [references/test-scenarios.md](references/test-scenarios.md) — detailed test scenarios
(list display, metadata editing, recipes, settings, import/export).
- [references/recipe-rematch-fixtures.md](references/recipe-rematch-fixtures.md) —
fixture format, fresh-state reset and known gaps for recipe rematch/repair E2E runs.
- `scripts/start_server.py` — start/restart the standalone server
(`--port --settings-path --restart --wait --timeout --detach`); refuses to touch
unrelated processes on the port.
- `scripts/wait_for_server.py` — poll readiness (`--port --timeout`).
@@ -1,360 +0,0 @@
# Chrome DevTools MCP Cheatsheet for LoRa Manager
Quick reference for common MCP commands used in LoRa Manager E2E testing.
> **Port convention**: `{PORT}` is the port chosen for the E2E run (default candidate `8188`, but only if actually free — see the SKILL.md Port Selection section; use e.g. `8199` when `8188` is occupied by a live ComfyUI). Always run against the **sandboxed** standalone server, never a live instance.
## Navigation
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Reload page with cache clear
navigate_page(type="reload", ignoreCache=True)
# Go back/forward
navigate_page(type="back")
navigate_page(type="forward")
```
## Waiting
```python
# Wait for text to appear
wait_for(text="LoRAs", timeout=10000)
# Wait for specific element (via evaluate_script)
evaluate_script(function="""
() => {
return new Promise((resolve) => {
const check = () => {
if (document.querySelector('.lora-card')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
}
""")
```
## Taking Snapshots
```python
# Full page snapshot
snapshot = take_snapshot()
# Verbose snapshot (more details)
snapshot = take_snapshot(verbose=True)
# Save to file
take_snapshot(filePath="test-snapshots/page-load.json")
```
## Element Interaction
```python
# Click element
click(uid="element-uid-from-snapshot")
# Double click
click(uid="element-uid", dblClick=True)
# Fill input
fill(uid="search-input", value="test query")
# Fill multiple inputs
fill_form(elements=[
{"uid": "input-1", "value": "value 1"},
{"uid": "input-2", "value": "value 2"},
])
# Hover
hover(uid="lora-card-1")
# Upload file
upload_file(uid="file-input", filePath="/path/to/file.safetensors")
```
## Keyboard Input
```python
# Press key
press_key(key="Enter")
press_key(key="Escape")
press_key(key="Tab")
# Keyboard shortcuts
press_key(key="Control+A") # Select all
press_key(key="Control+F") # Find
```
## JavaScript Evaluation
```python
# Simple evaluation
result = evaluate_script(function="() => document.title")
# Async evaluation
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
return await response.json();
}
""")
# Check element existence
exists = evaluate_script(function="""
() => document.querySelector('.lora-card') !== null
""")
# Get element count
count = evaluate_script(function="""
() => document.querySelectorAll('.lora-card').length
""")
```
## Network Monitoring
```python
# List all network requests
requests = list_network_requests()
# Filter by resource type
xhr_requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get specific request details
details = get_network_request(reqid=123)
# Include preserved requests from previous navigations
all_requests = list_network_requests(includePreservedRequests=True)
```
## Console Monitoring
```python
# List all console messages
messages = list_console_messages()
# Filter by type
errors = list_console_messages(types=["error", "warn"])
# Include preserved messages
all_messages = list_console_messages(includePreservedMessages=True)
# Get specific message
details = get_console_message(msgid=1)
```
## Performance Testing
```python
# Start trace with page reload
performance_start_trace(reload=True, autoStop=False)
# Start trace without reload
performance_start_trace(reload=False, autoStop=True, filePath="trace.json.gz")
# Stop trace
results = performance_stop_trace()
# Stop and save
performance_stop_trace(filePath="trace-results.json.gz")
# Analyze specific insight
insight = performance_analyze_insight(
insightSetId="results.insightSets[0].id",
insightName="LCPBreakdown"
)
```
## Page Management
```python
# List open pages
pages = list_pages()
# Select a page
select_page(pageId=0, bringToFront=True)
# Create new page
new_page(url="http://127.0.0.1:{PORT}/loras")
# Close page (keep at least one open!)
close_page(pageId=1)
# Resize page
resize_page(width=1920, height=1080)
```
## Screenshots
```python
# Full page screenshot
take_screenshot(fullPage=True)
# Viewport screenshot
take_screenshot()
# Element screenshot
take_screenshot(uid="lora-card-1")
# Save to file
take_screenshot(filePath="screenshots/page.png", format="png")
# JPEG with quality
take_screenshot(filePath="screenshots/page.jpg", format="jpeg", quality=90)
```
## Dialog Handling
```python
# Accept dialog
handle_dialog(action="accept")
# Accept with text input
handle_dialog(action="accept", promptText="user input")
# Dismiss dialog
handle_dialog(action="dismiss")
```
## Device Emulation
```python
# Mobile viewport
emulate(viewport={"width": 375, "height": 667, "isMobile": True, "hasTouch": True})
# Tablet viewport
emulate(viewport={"width": 768, "height": 1024, "isMobile": True, "hasTouch": True})
# Desktop viewport
emulate(viewport={"width": 1920, "height": 1080})
# Network throttling
emulate(networkConditions="Slow 3G")
emulate(networkConditions="Fast 4G")
# CPU throttling
emulate(cpuThrottlingRate=4) # 4x slowdown
# Geolocation
emulate(geolocation={"latitude": 37.7749, "longitude": -122.4194})
# User agent
emulate(userAgent="Mozilla/5.0 (Custom)")
# Reset emulation
emulate(viewport=None, networkConditions="No emulation", userAgent=None)
```
## Drag and Drop
```python
# Drag element to another
drag(from_uid="draggable-item", to_uid="drop-zone")
```
## Common LoRa Manager Test Patterns
### Verify LoRA Cards Loaded
```python
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
wait_for(text="LoRAs", timeout=10000)
# Check if cards loaded
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
return {
count: cards.length,
hasData: cards.length > 0
};
}
""")
```
### Search and Verify Results
```python
fill(uid="search-input", value="character")
press_key(key="Enter")
wait_for(timeout=2000) # Wait for debounce
# Check results
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name || c.textContent);
return { count: cards.length, names };
}
""")
```
### Check API Response
```python
# Trigger API call
evaluate_script(function="""
() => window.loraApiCallPromise = fetch('/loras/api/list').then(r => r.json())
""")
# Wait and get result
import time
time.sleep(1)
result = evaluate_script(function="""
async () => await window.loraApiCallPromise
""")
```
### Monitor Console for Errors
```python
# Before test: clear console (navigate reloads)
navigate_page(type="reload")
# ... perform actions ...
# Check for errors
errors = list_console_messages(types=["error"])
assert len(errors) == 0, f"Console errors: {errors}"
```
## Troubleshooting
### Stale profile lock ("browser is already running" / `list_pages` fails)
A Chrome profile held by a stale Chrome from a prior MCP session makes `list_pages`
fail with "browser is already running". Fix:
1. Find the stale Chrome that owns the profile dir (e.g. `~/.config/chrome-dev-profile`):
```bash
ps -ef | grep -i '[c]hrome.*user-data-dir'
```
2. Confirm it is a QA Chrome from a completed task (NOT the live ComfyUI server, NOT
your current MCP instance).
3. Kill ONLY that stale Chrome (`kill <stale-pid>`), then retry `list_pages`.
### Screenshot-write restrictions
The MCP may refuse to write into paths outside its configured workspace roots
(e.g. `.omo/evidence/screenshots/` under a worktree that canonicalizes to an unmapped
path). Save the screenshot to `/tmp` via the MCP, then copy it into the evidence dir:
```bash
# MCP: take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
# Shell:
mkdir -p <repo-root>/.omo/evidence/screenshots
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
```
### Time budgets & abort rule
See SKILL.md "Time Budgets & Abort Guidance": if a phase exceeds ~2x its budget or a
tool call retries 3+ times in a row, STOP and report BLOCKED with the last observed
state (server PID + `ss -tlnp`, page snapshot, last API response). Do not loop.
@@ -1,72 +0,0 @@
# Recipe Rematch/Repair E2E — Fixtures, Fresh State, Known Gaps
Specialized guidance for recipe rematch/repair E2E runs, extracted from the SKILL.md
main flow. Read the SKILL.md SANDBOX section first — everything here assumes a
sandboxed run.
## Fixture Rules (validated by the task-8 E2E)
Seed the **sandboxed** `recipes_path` with hand-written fixture recipes:
1. **Filename constraint**: each file MUST be named `f"{id}.recipe.json"` **and** the
in-JSON `id` field MUST equal the filename. Discovery accepts any `*.recipe.json`,
but persistence resolves the path via `get_recipe_json_path` and
`_save_recipe_persistently` returns `False` on a mismatch → the fixture would be
counted as an error.
- `recipe-a.recipe.json` → in-JSON `"id": "recipe-a"`
2. **File format**: mirror an existing recipe JSON — top-level `id`, `file_path`,
`title`, `loras`, `fingerprint`, `gen_params`; lora entries per the persistence
conventions (`hash`, `file_name`, `modelVersionId`, `isDeleted`, ...).
3. **Companion image**: each recipe needs an image (e.g. a `.webp` generated with PIL)
referenced by `file_path`, used for EXIF verification
(`ExifUtils.append_recipe_metadata` writes a `"Recipe metadata: ..."` marker; a
freshly generated `.webp` with no marker is the clean "untouched" control).
4. **autov3 three-state contract**: for L3 (autov3-only, renamed-file) fixtures the
local model's `.metadata.json` sidecar MUST have the `autov3` key **ABSENT** (the
"unchecked" state), NOT `""` — `""` is the TERMINAL "checked but unavailable" state
that L3 deliberately skips. The scanner computes + persists `autov3` from the file
header during the normal library scan (`model_scanner.py` `_process_model_file`), so
the live L3 match resolves through the local autov3/hash cache; the
computed-autov3 branch for unchecked items is covered by the unit suite.
5. **Fixture design for a rematch run** (mirrors the task-8 E2E):
- `recipe-a`: lora entry `isDeleted=True`, `hash` = 12-char autov3 computed from the
local model (`calculate_autov3`, `py/utils/file_utils.py`), whose local model file
was RENAMED after the recipe was written so `file_name` differs (proves L3 match
without filename).
- `recipe-b`: parser-convention checkpoint entry (uses `id`, no `modelVersionId`)
matching a local checkpoint via L2 — the local checkpoint's `.metadata.json` MUST
carry civitai version data with that `id` so `version_index` contains it (L2
cannot match otherwise).
- `recipe-c`: healthy recipe (no deleted entries) → must remain untouched.
The scanner computes and persists model hashes during the library scan, so the sandbox
model dirs just need the model files + `.metadata.json` sidecars. With
`--settings-path`, all derived data lands under the sandbox settings dir (`cache/`,
`backups/`, `logs/`, `stats/`, `wildcards/`), and NO `cache/` appears in the repo root.
## Fresh State Between Entry-Point Runs
Each entry point (global / per-recipe / selection-bulk) must start from the same
deleted state. Between runs (keep a pristine copy in `<sandbox>/recipes-before/`):
```bash
# 1. Reset fixtures to the before-state snapshot
cp <sandbox>/recipes-before/*.recipe.json <sandbox>/recipes/
# 2. Clear the recipe/FTS caches (with --settings-path these live under the sandbox
# settings dir, NOT <repo-root>/cache)
rm -f <sandbox>/settings/cache/recipe/*.sqlite
rm -rf <sandbox>/settings/cache/fts/*
# 3. Restart the server (fresh process, fresh scan)
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
--port {PORT} --settings-path <sandbox>/settings --restart --wait --timeout 30 --detach
# 4. Re-verify the server is listening + reload the browser page
```
## Cancellation Testing (KNOWN GAP)
Testing the rematch-cancel path E2E requires a run long enough to cancel mid-flight. A
tiny 3-recipe fixture set completes in **seconds** — too fast to reliably cancel. The
cancel path is currently **unit-covered only** (`rematch_all_recipes` cancellation
tests); do not block an E2E run on cancel-path verification. If you must attempt it,
you would need an artificially large/deferred fixture set to create a cancellable
window — treat this as a research task, not part of the standard E2E.
@@ -1,280 +0,0 @@
# LoRa Manager E2E Test Scenarios
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
> **Run preconditions (from SKILL.md)**: every run uses the **sandboxed** standalone
> server on a free port `{PORT}` (default candidate `8188`, only if actually free — pick
> e.g. `8199` when `8188` is occupied by a live ComfyUI). Fixtures live in the sandboxed
> `recipes_path` as `f"{id}.recipe.json"` files with matching in-JSON `id`; the real user
> config and real library are never touched (record protection proof before/after).
> Abort if a phase exceeds ~2x its budget or a tool call retries 3+ times (SKILL.md
> "Time Budgets & Abort Guidance").
## Table of Contents
1. [LoRA List Page](#lora-list-page)
2. [Model Details](#model-details)
3. [Recipes](#recipes)
4. [Settings](#settings)
5. [Import/Export](#importexport)
---
## LoRA List Page
### Scenario: Page Load and Display
**Objective**: Verify the LoRA list page loads correctly and displays models.
**Steps**:
1. Navigate to `http://127.0.0.1:{PORT}/loras`
2. Wait for page title "LoRAs" to appear
3. Take snapshot to verify:
- Header with "LoRAs" title is visible
- Search/filter controls are present
- Grid/list view toggle exists
- LoRA cards are displayed (if models exist)
- Pagination controls (if applicable)
**Expected Result**: Page loads without errors, UI elements are present.
### Scenario: Search Functionality
**Objective**: Verify search filters LoRA models correctly.
**Steps**:
1. Ensure at least one LoRA exists with known name (e.g., "test-character")
2. Navigate to LoRA list page
3. Enter search term in search box: "test"
4. Press Enter or click search button
5. Wait for results to update
**Expected Result**: Only LoRAs matching search term are displayed.
**Verification Script**:
```python
# After search, verify filtered results
evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name);
return { count: cards.length, names };
}
""")
```
### Scenario: Filter by Tags
**Objective**: Verify tag filtering works correctly.
**Steps**:
1. Navigate to LoRA list page
2. Click on a tag (e.g., "character", "style")
3. Wait for filtered results
**Expected Result**: Only LoRAs with selected tag are displayed.
### Scenario: View Mode Toggle
**Objective**: Verify grid/list view toggle works.
**Steps**:
1. Navigate to LoRA list page
2. Click list view button
3. Verify list layout
4. Click grid view button
5. Verify grid layout
**Expected Result**: View mode changes correctly, layout updates.
---
## Model Details
### Scenario: Open Model Details
**Objective**: Verify clicking a LoRA opens its details.
**Steps**:
1. Navigate to LoRA list page
2. Click on a LoRA card
3. Wait for details panel/modal to open
**Expected Result**: Details panel shows:
- Model name
- Preview image
- Metadata (trigger words, tags, etc.)
- Action buttons (edit, delete, etc.)
### Scenario: Edit Model Metadata
**Objective**: Verify metadata editing works end-to-end.
**Steps**:
1. Open a LoRA's details
2. Click "Edit" button
3. Modify trigger words field
4. Add/remove tags
5. Save changes
6. Refresh page
7. Reopen the same LoRA
**Expected Result**: Changes persist after refresh.
### Scenario: Delete Model
**Objective**: Verify model deletion works.
**Steps**:
1. Open a LoRA's details
2. Click "Delete" button
3. Confirm deletion in dialog
4. Wait for removal
**Expected Result**: Model removed from list, success message shown.
---
## Recipes
### Scenario: Recipe List Display
**Objective**: Verify recipes page loads and displays recipes.
**Steps**:
1. Navigate to `http://127.0.0.1:{PORT}/recipes`
2. Wait for "Recipes" title
3. Take snapshot
**Expected Result**: Recipe list displayed with cards/items.
### Scenario: Create New Recipe
**Objective**: Verify recipe creation workflow.
**Steps**:
1. Navigate to recipes page
2. Click "New Recipe" button
3. Fill recipe form:
- Name: "Test Recipe"
- Description: "E2E test recipe"
- Add LoRA models
4. Save recipe
5. Verify recipe appears in list
**Expected Result**: New recipe created and displayed.
### Scenario: Apply Recipe
**Objective**: Verify applying a recipe to ComfyUI.
**Steps**:
1. Open a recipe
2. Click "Apply" or "Load in ComfyUI"
3. Verify action completes
**Expected Result**: Recipe applied successfully.
---
## Settings
### Scenario: Settings Page Load
**Objective**: Verify settings page displays correctly.
**Steps**:
1. Navigate to `http://127.0.0.1:{PORT}/settings`
2. Wait for "Settings" title
3. Take snapshot
**Expected Result**: Settings form with various options displayed.
### Scenario: Change Setting and Restart
**Objective**: Verify settings persist after restart.
**Steps**:
1. Navigate to settings page
2. Change a setting (e.g., default view mode)
3. Save settings
4. Restart server: `python scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach`
5. Refresh browser page
6. Navigate to settings
**Expected Result**: Changed setting value persists.
---
## Import/Export
### Scenario: Export Models List
**Objective**: Verify export functionality.
**Steps**:
1. Navigate to LoRA list
2. Click "Export" button
3. Select format (JSON/CSV)
4. Download file
**Expected Result**: File downloaded with correct data.
### Scenario: Import Models
**Objective**: Verify import functionality.
**Steps**:
1. Prepare import file
2. Navigate to import page
3. Upload file
4. Verify import results
**Expected Result**: Models imported successfully, confirmation shown.
---
## API Integration Tests
### Scenario: Verify API Endpoints
**Objective**: Verify backend API responds correctly.
**Test via browser console**:
```javascript
// List LoRAs
fetch('/loras/api/list').then(r => r.json()).then(console.log)
// Get LoRA details
fetch('/loras/api/detail/<id>').then(r => r.json()).then(console.log)
// Search LoRAs
fetch('/loras/api/search?q=test').then(r => r.json()).then(console.log)
```
**Expected Result**: APIs return valid JSON with expected structure.
---
## Console Error Monitoring
During all tests, monitor browser console for errors:
```python
# Check for JavaScript errors
messages = list_console_messages(types=["error"])
assert len(messages) == 0, f"Console errors found: {messages}"
```
## Network Request Verification
Verify key API calls are made:
```python
# List XHR requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Look for specific endpoints
lora_list_requests = [r for r in requests if "/api/list" in r.get("url", "")]
assert len(lora_list_requests) > 0, "LoRA list API not called"
```
@@ -1,215 +0,0 @@
#!/usr/bin/env python3
"""
Example E2E test demonstrating LoRa Manager testing workflow.
This script shows how to:
1. Start the standalone server
2. Use Chrome DevTools MCP to interact with the UI
3. Verify functionality end-to-end
Note: This is a template. Actual execution requires Chrome DevTools MCP.
Port: pick a FREE port for the run — 8188 is commonly occupied by a live
ComfyUI (see the skill's Port Selection section). Set PORT below to e.g. 8199
when 8188 is taken. Always run against a SANDBOXED standalone server.
"""
import subprocess
import sys
# Choose the E2E port. 8188 is only the default candidate; use 8199 (or any
# free port checked with `ss -tlnp`) when 8188 is occupied by a live ComfyUI.
PORT = "8188"
def run_test():
"""Run example E2E test flow."""
print("=" * 60)
print("LoRa Manager E2E Test Example")
print("=" * 60)
# Step 1: Start server (detached so it survives the shell)
print("\n[1/5] Starting LoRa Manager standalone server...")
result = subprocess.run(
[sys.executable, "start_server.py", "--port", PORT, "--wait", "--timeout", "30", "--detach"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Failed to start server: {result.stderr}")
return 1
print("Server ready!")
# Step 2: Open Chrome (manual step - show command)
print("\n[2/5] Open Chrome with debug mode:")
print(
f"google-chrome --remote-debugging-port=9222 "
f"--user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras"
)
print("(In actual test, this would be automated via MCP)")
# Step 3: Navigate and verify page load
print("\n[3/5] Page Load Verification:")
print(
f"""
MCP Commands to execute:
1. navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. wait_for(text="LoRAs", timeout=10000)
3. snapshot = take_snapshot()
"""
)
# Step 4: Test search functionality
print("\n[4/5] Search Functionality Test:")
print(
"""
MCP Commands to execute:
1. fill(uid="search-input", value="test")
2. press_key(key="Enter")
3. wait_for(text="Results", timeout=5000)
4. result = evaluate_script(function=`
() => {
const cards = document.querySelectorAll('.lora-card');
return { count: cards.length };
}
`)
"""
)
# Step 5: Verify API
print("\n[5/5] API Verification:")
print(
"""
MCP Commands to execute:
1. api_result = evaluate_script(function=`
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, status: response.status };
}
`)
2. Verify api_result['status'] == 200
"""
)
print("\n" + "=" * 60)
print("Test flow completed!")
print("=" * 60)
return 0
def example_restart_flow():
"""Example: Testing configuration change that requires restart."""
print("\n" + "=" * 60)
print("Example: Server Restart Flow")
print("=" * 60)
print(
f"""
Scenario: Change setting and verify after restart
Steps:
1. Navigate to settings page
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
2. Change a setting (e.g., theme)
- fill(uid="theme-select", value="dark")
- click(uid="save-settings-button")
3. Restart server
- subprocess.run([python, "start_server.py", "--port", "{PORT}", "--restart", "--wait", "--detach"])
4. Refresh browser
- navigate_page(type="reload", ignoreCache=True)
- wait_for(text="LoRAs", timeout=15000)
5. Verify setting persisted
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
- assert theme == "dark"
"""
)
def example_modal_interaction():
"""Example: Testing modal dialog interaction."""
print("\n" + "=" * 60)
print("Example: Modal Dialog Interaction")
print("=" * 60)
print(
"""
Scenario: Add new LoRA via modal
Steps:
1. Open modal
- click(uid="add-lora-button")
- wait_for(text="Add LoRA", timeout=3000)
2. Fill form
- fill_form(elements=[
{"uid": "lora-name", "value": "Test Character"},
{"uid": "lora-path", "value": "/models/test.safetensors"},
])
3. Submit
- click(uid="modal-submit-button")
4. Verify success
- wait_for(text="Successfully added", timeout=5000)
- snapshot = take_snapshot()
"""
)
def example_network_monitoring():
"""Example: Network request monitoring."""
print("\n" + "=" * 60)
print("Example: Network Request Monitoring")
print("=" * 60)
print(
f"""
Scenario: Verify API calls during user interaction
Steps:
1. Clear network log (implicit on navigation)
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. Perform action that triggers API call
- fill(uid="search-input", value="character")
- press_key(key="Enter")
3. List network requests
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
4. Find search API call
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
- assert len(search_requests) > 0, "Search API was not called"
5. Get request details
- if search_requests:
details = get_network_request(reqid=search_requests[0]["reqid"])
- Verify request method, response status, etc.
"""
)
if __name__ == "__main__":
print("LoRa Manager E2E Test Examples\n")
print("This script demonstrates E2E testing patterns.\n")
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
run_test()
example_restart_flow()
example_modal_interaction()
example_network_monitoring()
print("\n" + "=" * 60)
print("All examples shown!")
print("=" * 60)
+50
View File
@@ -0,0 +1,50 @@
# .comfyignore — keep the Comfy Registry archive to runtime-only files.
#
# comfy-cli builds node.zip as: git-tracked files − .comfyignore matches
# + [tool.comfy].includes (we declare none). Patterns use .gitignore
# (gitwildmatch) syntax, evaluated against paths relative to the repo root.
# https://docs.comfy.org/registry/publishing
#
# Why this file exists: the registry security scan flags a version on ANY
# finding, even severity "info", so every shipped file is scan surface.
# Dev-only files (tests, docs, tooling scripts, agent notes, Vue sources)
# accounted for ~44 of the 115 findings that flagged 1.2.2–1.2.4.
# Test suites
/tests/
# Developer documentation, plans and internal notes
/docs/
/.omo/
/.specs/
/AGENTS.md
/update_logs.md
# Agent skill definitions (repo tooling, not loaded by ComfyUI)
/.agents/
# CI and repository automation
/.github/
# Repository tooling scripts — maintenance only, never imported at runtime.
# scripts/api.js + scripts/app.js are ComfyUI stubs consumed by the vitest suite.
/scripts/
# Vue widget sources and build inputs. The prebuilt bundle that actually ships
# lives in web/comfyui/vue-widgets/. Dropping the sources also stops the startup
# auto-builder from running its mtime check (and an npm install) on end-user
# machines, since py/vue_widget_builder.py skips the check when src/ is absent.
/vue-widgets/
# Root npm tooling — frontend test runner only
/package.json
/package-lock.json
/vitest.config.js
/pytest.ini
/requirements-dev.txt
# Sourcemaps are build artifacts, never loaded at runtime
web/comfyui/vue-widgets/*.js.map
# Keep this file itself out of the archive
/.comfyignore
+4
View File
@@ -10,11 +10,15 @@ civitai/
stats/ stats/
wildcards/ wildcards/
backups/ backups/
# Portable-mode centralized sidecar storage (<repo>/sidecars): user data that
# must survive pulls and stay out of git status
/sidecars/
logs/ logs/
node_modules/ node_modules/
coverage/ coverage/
.coverage .coverage
model_cache/ model_cache/
recipe_cache/
# agent / dev tooling # agent / dev tooling
.opencode/ .opencode/
+59 -1
View File
@@ -72,6 +72,11 @@ python scripts/sync_translation_keys.py
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he). Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
the other locales are the expected end state during feature development. Do NOT translate
proactively — translate only when the feature owner explicitly asks (see
`docs/i18n-translation-guidelines.md` §7).
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the **Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
brand names are never translated), per-locale preferred renderings, placeholder rules, and brand names are never translated), per-locale preferred renderings, placeholder rules, and
@@ -161,10 +166,14 @@ The system runs in two modes:
### Model Types & Routes ### Model Types & Routes
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns - API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*`, `/other/*` patterns
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc. - Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
- Request handlers in `py/routes/handlers/` implement route logic - Request handlers in `py/routes/handlers/` implement route logic
- All routes use aiohttp, return `web.json_response` or `web.Response` - All routes use aiohttp, return `web.json_response` or `web.Response`
- Endpoints consumed by the companion browser extension (lm-civitai-extension)
MUST also accept `GET` with query-string params: the extension is GET-only by
convention (see its AGENTS.md), even for state-changing operations such as
`GET /api/lm/recipe/{recipe_id}/reimport`
### Recipe System ### Recipe System
@@ -181,6 +190,17 @@ The system runs in two modes:
- `py/config.py` manages folder paths for models and handles symlink mappings - `py/config.py` manages folder paths for models and handles symlink mappings
- Auto-saves paths to `settings.json` in ComfyUI mode - Auto-saves paths to `settings.json` in ComfyUI mode
- `settings.json.example` is intentionally minimal (see Important Notes); all
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
- **`folder_paths` vs `extra_folder_paths` — different purposes, do not conflate:**
- `folder_paths` (primary model roots): in ComfyUI plugin mode these come
from the ComfyUI host; in standalone mode they are the ONLY source of
model library paths and are currently edited by hand in `settings.json`.
- `extra_folder_paths` is a **ComfyUI-plugin-mode feature**: paths visible
ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large
model library slows ComfyUI itself down, while LoRA Manager handles large
libraries without performance issues — so users keep ComfyUI's library
small and add the bulk via `extra_folder_paths`.
### Frontend UI Architecture ### Frontend UI Architecture
@@ -210,6 +230,26 @@ The system runs in two modes:
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom; setup in `tests/frontend/setup.js` - Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom; setup in `tests/frontend/setup.js`
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + `@vue/test-utils` - Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + `@vue/test-utils`
### UI Verification (manual default)
UI/layout changes are verified by the user by eye — do NOT spin up a sandbox,
standalone server, or browser automation to "prove" a visual fix. Ask the user to
look instead. The full browser E2E ceremony (server + Chrome DevTools MCP +
screenshots) is slow, token-heavy, and fragile; reserve it for genuine
server+browser integration bugs, and only when the user explicitly agrees.
If a cross-layer issue ever needs a live server, the sandboxed helpers live in
`scripts/e2e/` (`start_server.py`, `wait_for_server.py`). Non-negotiable rules:
- Always launch with `--settings-path <sandbox>/settings` and sandboxed
`folder_paths` under `/tmp` — the repo folder is the real plugin folder and a
`settings.json` there is read by the live instance. Never touch real config or
real model libraries.
- Never kill a process you did not start; `start_server.py` tracks its own PIDs
via pidfile and refuses to touch unrelated processes on the port.
- Abort after ~30 minutes or 3 consecutive tool failures; report `BLOCKED` with
observed state instead of retrying blindly. Clean up sandbox and server after.
## Key Integration Points ## Key Integration Points
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`) - **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
@@ -221,6 +261,24 @@ The system runs in two modes:
## Important Notes ## Important Notes
- ALWAYS use English for comments (per copilot-instructions.md) - ALWAYS use English for comments (per copilot-instructions.md)
- **`.civitai.info` files are NOT LoRA Manager sidecars.** They are written by
third-party apps; LoRA Manager treats them as read-only and only consumes
them during migration/import. Never write, modify, or delete them, and never
propose doing so as a fix — LoRA Manager's own metadata lives in the
`.metadata.json` sidecar it owns.
- **Sidecar/preview path derivation must go through `py/utils/sidecar_paths.py`**
helpers (never inline `splitext + ".metadata.json"`): the centralized storage
mode (`sidecar_storage_mode` / `sidecar_storage_path` settings) relocates
`.metadata.json` files and preview images under a mirror tree, so any
hand-built path is wrong in that mode. `.civitai.info` stays co-located with
the model file in both modes. The new settings keys live only in
`DEFAULT_SETTINGS` — `settings.json.example` stays minimal (see below).
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
(model-category folders, `default_*_root`, `auto_organize_exclusions`, etc.)
to this file unless the user explicitly asks for it. Defaults belong in
`DEFAULT_SETTINGS` in `py/services/settings_manager.py`.
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json` - Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
- Symlinks require normalized paths. - Symlinks require normalized paths.
**Business paths vs real paths**: All stored paths and operation routing use the **Business paths vs real paths**: All stored paths and operation routing use the
+29 -2
View File
File diff suppressed because one or more lines are too long
+32 -46
View File
@@ -18,12 +18,12 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_info import LoraInfoLM from .py.nodes.lora_info import LoraInfoLM
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from .py.nodes.create_hook_lora import CreateHookLoraLM from .py.nodes.create_hook_lora import CreateHookLoraLM
from .py.nodes.load_image_metadata import LoadImageMetadataLM
from .py.nodes.metadata_overwrite import MetadataOverwriteLM from .py.nodes.metadata_overwrite import MetadataOverwriteLM
from .py.metadata_collector import init as init_metadata_collector from .py.metadata_collector import init as init_metadata_collector
except ( except (
ImportError ImportError
): # pragma: no cover - allows running under pytest without package install ): # pragma: no cover - allows running under pytest without package install
import importlib
import pathlib import pathlib
import sys import sys
@@ -31,46 +31,33 @@ except (
if str(package_root) not in sys.path: if str(package_root) not in sys.path:
sys.path.append(str(package_root)) sys.path.append(str(package_root))
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM # pytest collects this file as a top-level module: its Package collector walks
TextLM = importlib.import_module("py.nodes.text").TextLM # up from tests/ while __init__.py exists, so the repo root becomes a package
LoraManager = importlib.import_module("py.lora_manager").LoraManager # node and this file is imported without one. Relative imports cannot resolve
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM # there, so pull the same objects through the top-level "py" package that the
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM # sys.path entry above makes importable.
CheckpointLoaderLM = importlib.import_module( from py.lora_manager import LoraManager
"py.nodes.checkpoint_loader" from py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
).CheckpointLoaderLM from py.nodes.checkpoint_loader import CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM from py.nodes.unet_loader import UNETLoaderLM
TriggerWordToggleLM = importlib.import_module( from py.nodes.trigger_word_toggle import TriggerWordToggleLM
"py.nodes.trigger_word_toggle" from py.nodes.prompt import PromptLM
).TriggerWordToggleLM from py.nodes.text import TextLM
LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM from py.nodes.lora_stacker import LoraStackerLM
LoraStackCombinerLM = importlib.import_module( from py.nodes.lora_stack_combiner import LoraStackCombinerLM
"py.nodes.lora_stack_combiner" from py.nodes.save_image import SaveImageLM
).LoraStackCombinerLM from py.nodes.debug_metadata import DebugMetadataLM
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM from py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM from py.nodes.wanvideo_lora_select_from_text import WanVideoLoraTextSelectLM
WanVideoLoraSelectLM = importlib.import_module( from py.nodes.lora_pool import LoraPoolLM
"py.nodes.wanvideo_lora_select" from py.nodes.lora_randomizer import LoraRandomizerLM
).WanVideoLoraSelectLM from py.nodes.lora_cycler import LoraCyclerLM
WanVideoLoraTextSelectLM = importlib.import_module( from py.nodes.lora_info import LoraInfoLM
"py.nodes.wanvideo_lora_select_from_text" from py.nodes.lora_syntax_to_path import LoraSyntaxToPath
).WanVideoLoraTextSelectLM from py.nodes.create_hook_lora import CreateHookLoraLM
LoraPoolLM = importlib.import_module("py.nodes.lora_pool").LoraPoolLM from py.nodes.load_image_metadata import LoadImageMetadataLM
LoraRandomizerLM = importlib.import_module( from py.nodes.metadata_overwrite import MetadataOverwriteLM
"py.nodes.lora_randomizer" from py.metadata_collector import init as init_metadata_collector
).LoraRandomizerLM
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
LoraSyntaxToPath = importlib.import_module(
"py.nodes.lora_syntax_to_path"
).LoraSyntaxToPath
CreateHookLoraLM = importlib.import_module(
"py.nodes.create_hook_lora"
).CreateHookLoraLM
MetadataOverwriteLM = importlib.import_module(
"py.nodes.metadata_overwrite"
).MetadataOverwriteLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
PromptLM.NAME: PromptLM, PromptLM.NAME: PromptLM,
@@ -93,6 +80,7 @@ NODE_CLASS_MAPPINGS = {
LoraSyntaxToPath.NAME: LoraSyntaxToPath, LoraSyntaxToPath.NAME: LoraSyntaxToPath,
CreateHookLoraLM.NAME: CreateHookLoraLM, CreateHookLoraLM.NAME: CreateHookLoraLM,
MetadataOverwriteLM.NAME: MetadataOverwriteLM, MetadataOverwriteLM.NAME: MetadataOverwriteLM,
LoadImageMetadataLM.NAME: LoadImageMetadataLM,
} }
WEB_DIRECTORY = "./web/comfyui" WEB_DIRECTORY = "./web/comfyui"
@@ -104,12 +92,10 @@ try:
# Auto-build in development, warn only if fails # Auto-build in development, warn only if fails
check_and_build_vue_widgets(auto_build=True, warn_only=True) check_and_build_vue_widgets(auto_build=True, warn_only=True)
except ImportError: except ImportError:
# Fallback for pytest # Fallback for pytest (see the note in the import block above): go through the
import importlib # top-level "py" package, which that block has already put on sys.path.
from py.vue_widget_builder import check_and_build_vue_widgets
check_and_build_vue_widgets = importlib.import_module(
"py.vue_widget_builder"
).check_and_build_vue_widgets
check_and_build_vue_widgets(auto_build=True, warn_only=True) check_and_build_vue_widgets(auto_build=True, warn_only=True)
except Exception as e: except Exception as e:
import logging import logging
+485 -431
View File
File diff suppressed because it is too large Load Diff
+127 -8
View File
@@ -62,27 +62,146 @@ Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM
### enrich_hf_metadata ### enrich_hf_metadata
Enriches HuggingFace-downloaded models with metadata extracted by an LLM from the HF model card. Enriches models linked to an external model site with metadata extracted by an LLM from the site's model card (README).
**Entry point**: Right-click context menu → "Enrich Metadata (Agent)" **Entry point**: Right-click context menu → "Enrich Metadata with AI"
**Supported model sources**:
| Platform | Link | AI enrichment | Direct download |
| --- | --- | --- | --- |
| Hugging Face | yes | yes | yes |
| ModelScope (`modelscope.cn`) | yes | yes | yes |
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
| TensorArt | yes | no (see below) | no |
| OpenModelDB | yes | yes (card data from the catalogue, no README) | yes |
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
repository published on one is routinely absent from the other — so each is
registered as its own source (`ModelScopeSource` / `ModelScopeIntlSource` in
`py/services/model_sources/modelscope.py`). The host therefore decides which
API and CDN a model resolves against, and the two deployments get separate
version groups (`ms:` / `msai:`) and default download directories. Keep the two
tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
OpenModelDB is the upscaler catalogue: model ids are flat tokens (no `owner/name`), there are no revisions and no README — `fetch_model_card_context()` reads everything (description, license, tags, example images) from the disk-cached bulk catalogue in `py/services/openmodeldb_client.py`. Only PyTorch resources (`.pth`/`.safetensors`) are downloadable, and only via mirrors that serve raw bytes: HTML-gateway hosts (`mediafire.com`, `mega.nz`, `drive.google.com`) are skipped in favour of a direct mirror, and a model with only gateway mirrors reports a manual-download hint instead of a file list. Filenames are derived from URL path segments (mediafire buries them mid-path) or synthesized as `{model_id}.{type}` for folder links.
**What it does**: **What it does**:
1. Reads the model's `.metadata.json` to get the `hf_url` 1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
2. Fetches the README.md from the HuggingFace repository 2. Fetches the model card through the provider in `py/services/model_sources/` — the README via `fetch_model_card()`, plus any extras the site keeps outside it via `fetch_model_card_context()`
3. Sends the README + local metadata to the LLM for structured extraction 3. Sends the README + site-provided extras + local metadata to the LLM for structured extraction
4. Writes extracted fields to `.metadata.json`: 4. Writes extracted fields to `.metadata.json`:
- `base_model` — only if current value is empty - `base_model` — only if current value is empty
- `trainedWords` — trigger words (LoRA only, if none exist) - `trainedWords` — trigger words (LoRA only, if none exist)
- `modelDescription` — concise summary (if none exists) - `modelDescription` — the site's author description (if any) followed by the README rendered as HTML
- `tags` — merged with existing tags, deduplicated - `tags` — merged with existing tags, deduplicated
- `civitai.images` — example images
- `metadata_source` — audit trail: `agent:enrich_hf_metadata` - `metadata_source` — audit trail: `agent:enrich_hf_metadata`
- `llm_enriched_at` — ISO timestamp - `llm_enriched_at` — ISO timestamp
5. Downloads and optimizes preview image (if LLM found one in the README) 5. Downloads and optimizes a preview image, using the per-file example image the
site publishes when the README has none
6. Updates the scanner cache 6. Updates the scanner cache
7. Broadcasts WebSocket progress events 7. Broadcasts WebSocket progress events
#### Site-provided card extras (`fetch_model_card_context`)
A model card is not always just `README.md`. ModelScope keeps the author's
summary (`Description`), the site-curated tags (`OfficialTags`), and — per
published version — the model filenames together with that file's example
images (`MuseInfo.versions[].coverImages`) and trigger words in its
model-detail API. AIGC repositories there often ship an auto-generated
boilerplate README and put everything useful in `Description`, so reading only
the README yields almost nothing.
Providers opt in by overriding `ModelSource.fetch_model_card_context()`, which
returns a `ModelCardContext`. The wanted file is identified by its sha256 when
the caller knows it (the scanner already records one) and by **basename**
otherwise, so each checkpoint in a collection repo gets its own images — and
keeps getting them after the user renames the weights, which is the only
identifier a rename cannot invalidate. Sites with no such extras inherit an
empty context, and the pipeline behaves exactly as before.
The README and the repository metadata describe the whole repository, not one
file, so `execute_skill()` creates a `ModelSourceCache` for the duration of a
run and passes it down. Enriching the eight checkpoints of one ModelScope
repository costs two HTTP requests instead of sixteen; only the per-file
selection is redone for each file. Nothing is cached across runs, and download
URLs never go through it.
#### Deterministic data is applied whether or not an LLM is configured
`AgentService._load_source_card()` runs for every source-backed enrichment, and
the post-processor applies what it returns before the LLM output is merged. A
user with **no** provider configured therefore still gets the author summary,
the example images, the preview, the site-curated tags, the trigger words and
the README rendered as the model description.
The LLM is always consulted when one is configured — invoking **Enrich Metadata
with AI** must call the provider every time, and the site data is never treated
as a reason to skip it. The deterministic values act as fallbacks that fill
gaps the LLM leaves behind:
| Field | Deterministic source | LLM role |
| --- | --- | --- |
| `model_name` | site display name (`Name`), written only while the value is still the file stem | — |
| `modelDescription` | author summary + README as HTML | — |
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
| `civitai.images` | site example images, then README images | — |
| `preview_url` | first available example image | may propose one from the README |
| `tags` | site-curated tags, always merged in | proposes additional content tags |
| `civitai.description` | author summary | richer 1-2 sentence summary wins |
| `base_model` | site hints resolved against the canonical vocabulary (`py/services/agent/base_model_resolver.py`) | mapping it is the LLM's job; the resolver only fills in when the LLM returns nothing |
| `trainedWords` | per-file site trigger words, then YAML `instance_prompt` | primary extraction |
| `usage_tips` | regex over an explicitly stated strength range | primary extraction |
| `notes` | — | LLM-only |
Models with no source, an unknown source, or a source without model-card access (TensorArt) are skipped with an explicit reason and counted in the run summary.
**Model types**: LoRA, Checkpoint, Embedding **Model types**: LoRA, Checkpoint, Embedding
### Download-time hydration
The same deterministic mapping runs automatically when a model is downloaded
from a model source, so a ModelScope or Hugging Face download lands with the
populated card a CivitAI download produces instead of a bare filename and
hash. Nothing needs to be triggered by hand and no provider is called.
`py/services/model_sources/hydration.py` owns this path:
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
creates the sidecar (hash, source link, scanner-cache entry) and then calls
`hydrate_from_source()`. It also runs for a file that was already on disk, so
models downloaded before this existed get topped up on the next attempt.
* Metadata is created through the **owning scanner**
(`scanner._create_default_metadata()`) rather than
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
applies: `CheckpointScanner` and `OtherScanner` store
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
the generic helper would read a 10 GB checkpoint end to end inside the
download request. Hydration copes with the empty hash — `_matching_versions()`
falls back to the repository basename, which the download just wrote.
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
it does not stamp `llm_enriched_at`.
* `model_name` is only written while it still equals the file stem: once a user
renames a model, that choice is kept.
* Only a model whose stored `source_platform`/`source_url` match the repository
being downloaded is updated; a local file that merely shares a name must not
receive another model's card.
* The README and repository payload describe the *repository*, so a short-lived
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
keeps a batch over one repository to two HTTP requests.
* Every failure — unreachable site, changed payload shape, broken post-processor
— is logged and swallowed. Metadata hydration can never fail a download.
* Neither stage advances the byte counter, so both are announced to the
progress UI (`_report_phase()` → `{"status": "metadata", "stage": ...}`) as
they start. Without that the bar sits at 100% reporting `0 B/s` for several
seconds and the download looks stuck. `stage` and `platform` are
machine-readable; the wording is localised in `LoadingManager`.
## Adding a New Skill ## Adding a New Skill
### 1. Create the skill directory ### 1. Create the skill directory
@@ -129,7 +248,7 @@ Use `{{variable}}` placeholders that will be replaced with data from the `prepar
```markdown ```markdown
You are an expert assistant... You are an expert assistant...
Model URL: {{hf_url}} Model URL: {{source_url}}
README content: README content:
{{readme_content}} {{readme_content}}
+562 -6
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
It applies to **human translators and AI agents** alike. Read it before editing anything in It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`. `locales/`.
Source of truth: `locales/en.json` (10 locales, 1810 leaf keys; all locales share the exact Source of truth: `locales/en.json` (10 locales, 2128 leaf keys; all locales share the exact
same key structure). same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -13,6 +13,180 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales > stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales
> (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**, > (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**,
> not a to-do list — future edits should preserve these renderings and only add what is new. > not a to-do list — future edits should preserve these renderings and only add what is new.
>
> **Status (2026-09, Other Models):** the `other` model type (VAE / Upscaler / Text Encoder /
> CLIP Vision / ControlNet) and the Other Models opt-in toggles added 36 new keys; all of them
> are now translated in all 9 locales (terminology in §2 "Other Models feature"). There are no
> remaining `[TODO: Translate]` placeholders in any locale.
>
> **Status (2026-09, revision):** `other.disabled.description`, `banners.otherModels.content` and
> `settings.folderSettings.enableOtherModelsHelp` were refreshed in `en.json` to name all five
> sub_types (they had listed four, which read as "these are what enabling manages") and
> re-translated in all 9 locales in the same pass. `clip_vision` and `controlnet` are now both
> opt-in, so the first two describe **capability** and the third the **master switch**, not the
> default set — keep all three enumerating the full five (`VAE / upscaler / text encoder /
> CLIP vision / ControlNet` in `en`; locale slash-list casing follows each file's existing
> `VAE / Upscaler / Text Encoder / …` style, de compounds as `CLIP-Vision- und ControlNet-Ordner`).
>
> **Status (2026-09, "no folders found" state):** the Other Models page gained an *enabled but
> nothing to scan* empty state with 6 new keys (`other.noPaths.*`); translated in all 9 locales
> in the same pass. The `folder_paths` JSON snippet shown in that state lives in
> `templates/other.html`, **not** in the locale files, so it is never translated — only the
> surrounding prose is. Terminology added in §2.
>
> **Status (2026-09, model sources):** models can now be linked to ModelScope and TensorArt
> alongside Hugging Face, which added 15 keys (`modelCard.actions.viewOnSource`,
> `loras.contextMenu.linkModelSource`, `modals.linkModelSource.*`,
> `modals.model.versions.sourceGroupInfo`, `toast.contextMenu.enrichNeedsSource`,
> `toast.contextMenu.enrichUnsupportedSource`) and refreshed the two `enrichHfAgent` labels,
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The
> `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
> the §6 exception). Terminology in §2, "Model source feature".
>
> **Status (2026-09, folder sidebar):** the model-root sidebar gained on-disk folder management
> (create / rename / delete folders, show empty folders, tree vs list view) plus its `...`
> view-options menu, adding 35 `sidebar.*` keys. Those were the only `[TODO: Translate]`
> placeholders left behind by the feature series, and all 35 are now translated in all 9
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
> "Folder sidebar feature".
>
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
> under `common` because both editors render it). All 9 locales are translated (renderings in
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
> modal's arrow-key navigation.
> **Status (2026-09, standalone no-paths guidance):** the standalone branch of the
> `other.noPaths` empty state now shows the real `settings.json` path plus an
> `other.noPaths.openSettingsFolder` button (each locale reuses its
> `settings.openSettingsFileLocation.label` rendering), and `descriptionStandalone` was
> reworded in `en.json` — from "none of the configured folders exist on disk" to "no
> other-model folders were found; add the folder keys you need to the `folder_paths`
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
> the ComfyUI variant (`descriptionComfyUI`).
> **Status (2026-09, settings Organization tab):** the settings modal split its overloaded
> Library tab, adding the single `settings.nav.organization` key (renderings in §2,
> "Settings Organization tab"). All 9 locales are translated, so the "no remaining
> placeholders" claim holds again.
> **Status (2026-09, filename templates):** the Filename Templates feature (per-model-type
> download filename templates + bulk "Apply to Library Now" rename, with an empty template
> restoring recorded original filenames) added 26 keys across `settings.filenameTemplates.*`,
> `loras.bulkOperations.filenameTemplateProgress.*`, `modals.filenameTemplateConfirm.*` and
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
> locales are translated (terminology in §2, "Filename Templates feature").
> **Status (2026-09, folder delete verification):** the folder delete modal no longer trusts the
> sidebar's "empty folder" prediction — it dry-runs the delete against the backend and renders
> the answer, so a folder whose models are all *excluded* (invisible to the model lists, still
> real weight files on disk) is refused with an explanation instead of contradicting itself.
> That added 5 keys (`sidebar.deleteFolderModal.notEmptyMessageCount`,
> `.notEmptyMessageExcluded`, `.busyTitle`, `.checking`, `sidebar.deleteFolderResult.notEmptyWithCount`);
> all 9 locales are translated (terminology in §2, "Folder sidebar feature"), so the
> "no remaining placeholders" claim holds again.
> **Status (2026-09, sidecar storage):** optional centralized storage for `.metadata.json`
> sidecars and preview images added 23 keys — `settings.sections.sidecarStorage`,
> the 18 `settings.sidecarStorage.*` labels/help/status/confirm strings, and the 4
> `modals.sidecarMigrationConfirm.*` titles/button. The pull request merged them as
> `[TODO: Translate]` copies; all 9 locales are now translated (terminology in §2,
> "Sidecar storage feature"), so no placeholder remains and the "no remaining placeholders"
> claim holds again.
> **Status (2026-09, sidecar storage UX follow-up):** the migration UX follow-up added
> 7 `settings.sidecarStorage.open*`/path-display keys, `modals.sidecarMigrationConfirm.destination`,
> and the 13-key `modals.sidecarMigrationResult.*` summary block (which replaces
> `settings.sidecarStorage.migrateSuccess` — the result modal is now the success feedback,
> mirroring `modals.metadataFetchSummary.*`/`modals.downloadBatchSummary.*` stat-card and
> failure-table conventions; reuse each locale's existing renderings of those sibling keys).
> All 9 locales are translated in the same pass (terminology in §2, "Sidecar storage feature").
> **Status (2026-10, unknown base model routing):** the download-routing inversion added
> 4 keys (`settings.unknownBaseModelRouting.label`, `.help`, `.options.diffusionModel`,
> `.options.checkpoint`) — the option labels reuse each locale's existing
> `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings. The same pass also
> translated the leftover `doctor.issues.sidecar_mirror_orphans.title` ("Centralized
> Sidecars", §2 "Sidecar storage feature" terminology). All 9 locales are translated,
> so the "no remaining placeholders" claim holds again. Terminology in §2, "Download
> routing feature".
> **Status (2026-10, routing-override follow-up):** the download modal's location step
> gained a manual "Destination type" toggle (Checkpoint | Diffusion Model) for when the
> auto routing misdetects, adding 2 keys (`modals.download.routingOverride.label`,
> `.tooltip`). The tooltip quotes each locale's `modals.download.useDefaultPath` label
> verbatim (switching turns it off for the session), using that locale's UI-label quoting
> style. All 9 locales are translated (terminology in §2, "Download routing feature"),
> so the "no remaining placeholders" claim holds again.
> **Status (2026-10, OpenModelDB):** the OpenModelDB metadata-provider toggle added 2 keys
> (`settings.metadataArchive.enableOpenmodeldbApi(Help)`); all 9 locales are translated
> (terminology in §2, "OpenModelDB feature"), so the "no remaining placeholders" claim
> holds again.
> **Status (2026-10, Civitai ids in model modal):** the model modal's hash footnote now
> shows the Civitai model id and version id (right-aligned, with copy buttons), adding
> 4 keys (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId`,
> `modals.model.actions.copyCivitaiId` / `.civitaiIdCopied`). The same pass removed the
> search-options "hash" toggle (`header.search.filters.hash`) because hash/id search is
> now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"),
> so the "no remaining placeholders" claim holds again.
> **Status (2026-10, showcase layout option):** the model modal's example images became
> switchable between the on-demand gallery and the classic vertical list (issue #1136),
> adding 6 keys (`settings.layoutSettings.showcaseLayout`, `.showcaseLayoutHelp`,
> `.showcaseLayoutOptions.gallery` / `.vertical`, `modals.model.showcase.layoutGallery` /
> `.layoutList`). All 9 locales are translated (terminology in §2, "Showcase layout
> feature"), so the "no remaining placeholders" claim holds again.
> **Status (2026-10, Buzz download prices):** the paid/early-access obtainability feature added
> 13 keys — the two `globalContextMenu.checkModelUpdates.gateEvents.*` counts, five
> `modals.model.versions.badges.*` (sale, Blue Buzz, "free now" and its tooltip, and the early
> access end-date tooltip) and the six `settings.priceTracking.*` strings — plus the section
> header. All 13 are now translated in all 9 locales. **Buzz** and **Blue Buzz** stay as-is
> everywhere (CivitAI currency names, R3). Two source fixes came with the pass: the unused
> `settings.priceTracking.label` key was removed (no template renders it; the toggle uses
> `enabled`/`enabledHelp`) and `settings.sections.priceTracking` was reworded to
> "Buzz Download Prices" so the header matches what the feature does (prices are displayed;
> nothing is tracked for alerts). Register follows each file's existing norm: 你 (zh-CN),
> 您 (zh-TW), Sie (de), tú (es), вы (ru). No remaining `[TODO: Translate]` placeholders.
> **Status (2026-10, scoped scan):** the Refresh dropdown gained a per-root scope section
> (`loras.controls.refresh.scopeSection` / `.rootOffline` / `.rootModels`) and the scan result
> toasts (`toast.api.refreshCompleteScoped`, `.refreshKeptUnreachable`, `.scanRootUnreachable`),
> 6 keys in total, translated in all 9 locales in the same pass (renderings in §2, "Scoped scan
> and root availability"). The sidebar's folder-level follow-up added 2 more
> (`sidebar.scanFolder`, `sidebar.scanFolderResult.missing`), translated in a second pass, so the
> "no remaining placeholders" claim holds again.
> **Status (2026-10, reconcile walk progress):** a regular Refresh now reports the reconcile
> walk per model root (which roots are being checked, how many model files have been seen, and
> an ETA) and splits the progress bar into walk (0-50 %) and new-file (50-99 %) phases, which
> added the single `common.scanProgress.walkFiles` fragment. All 9 locales are translated
> (renderings in §2, "Scan progress (walk phase)"), so the "no remaining placeholders" claim
> holds again. The same pass scoped the client-side ETA to the current stage in
> `static/js/api/baseModelApi.js`; no other locale string changed.
> **Status (2026-10, multi-root folder resolution):** the folder sidebar used to turn a tree
> node into a path by prefixing the *default* model root, so a folder living under another root
> failed to delete ("Folder no longer exists") and a same-named folder in another root could be
> the one that got hit. Folder operations now resolve a node through the backend
> (`GET /api/lm/{prefix}/resolve-folder`) and act on the directories that really exist. When
> several roots hold the folder, the delete modal lists **one checkbox row per copy** — each row
> carries its own guard verdict, every deletable copy is ticked by default, and a copy that
> still holds models (or is a symbolic link) is unticked, disabled and explained instead of
> silently dropped. Deleting a subset leaves the node in the sidebar, because the folder list is
> a union over the model roots; `deleteFolderModal.keptNote` warns about that before the click.
> The scanner bookkeeping is root-aware for the same reason: deleting one copy no longer hides
> the node until the next scan, and no longer purges the model cards of its same-named twin in
> another root (rename keeps the twin's records untouched too). That added 26 keys —
> `sidebar.deleteFolderModal.{missingTitle,missingMessage,symlinkTitle,symlinkMessage,confirmMulti,deleting,keptNote}`,
> `sidebar.deleteFolderResult.{missing,symlink,successMulti,partial}`,
> `sidebar.renameFolderResult.{missing,symlink}`, the 12 `sidebar.folderRoot.*`
> chooser/status strings and `sidebar.folderResult.unresolved` — all translated in all 9
> locales (terminology below). The `en.json` wording was normalized to the established **model
> root** noun while translating (`library root` → `model root`, R5); the count-bearing
> `successMulti` uses the singular `success` key when only one copy was removed.
--- ---
@@ -23,7 +197,9 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
same nested key set. `tests/i18n/test_i18n.py` enforces this. same nested key set. `tests/i18n/test_i18n.py` enforces this.
- When a new UI string is added to `en.json`, run - When a new UI string is added to `en.json`, run
`python scripts/sync_translation_keys.py` (adds the missing keys to all locales with `python scripts/sync_translation_keys.py` (adds the missing keys to all locales with
placeholder copies), then translate the newly added keys in every locale. `[TODO: Translate]` placeholder copies) — **then stop**. Do NOT translate proactively:
placeholders are the expected end state during feature development, and translations are
filled in only when the feature owner explicitly asks (workflow details in §7).
- Never reorder, re-indent, or reformat a locale file "for tidiness". The sync script - Never reorder, re-indent, or reformat a locale file "for tidiness". The sync script
preserves formatting; manual reformatting creates noisy diffs. preserves formatting; manual reformatting creates noisy diffs.
@@ -135,7 +311,7 @@ and must be normalized. `en` = keep the English word as-is.
| Term | Use | Fix | | Term | Use | Fix |
|---|---|---| |---|---|---|
| recipe | Rezept/Rezepte | 5 leftover English "Recipe" keys → Rezept (e.g. `globalContextMenu.repairRecipes.label`, `toast.recipes.recipeSaved`) | | recipe | Rezept/Rezepte | leftover English "Recipe" keys → Rezept (e.g. `toast.recipes.recipeSaved`) |
| base model | pick Basis-Modell or Basismodell | currently 27× hyphenated vs 15× closed | | base model | pick Basis-Modell or Basismodell | currently 27× hyphenated vs 15× closed |
| metadata | Metadaten | 4 keys use "Modelldaten" (`onboarding.steps.fetch.title/content`) → Metadaten | | metadata | Metadaten | 4 keys use "Modelldaten" (`onboarding.steps.fetch.title/content`) → Metadaten |
| bulk | pick Massen- or Sammelmodus | `loras.controls.bulk.action` = "Massen" reads as "crowds" — use "Massenbearbeitung"/"Mehrfachauswahl" | | bulk | pick Massen- or Sammelmodus | `loras.controls.bulk.action` = "Massen" reads as "crowds" — use "Massenbearbeitung"/"Mehrfachauswahl" |
@@ -191,7 +367,7 @@ and must be normalized. `en` = keep the English word as-is.
| Checkpoint | Checkpoint or チェックポイント (pick one) | 3 variants: Checkpoint (~14), checkpoint lowercase (4), チェックポイント (4, e.g. `settings.priorityTags.modelTypes.checkpoint`) | | Checkpoint | Checkpoint or チェックポイント (pick one) | 3 variants: Checkpoint (~14), checkpoint lowercase (4), チェックポイント (4, e.g. `settings.priorityTags.modelTypes.checkpoint`) |
| Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence | | Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence |
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード | | bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify | | recipe counter | 件 or 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
### ko ### ko
@@ -220,6 +396,385 @@ and must be normalized. `en` = keep the English word as-is.
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ | | hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) | | register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
### Other Models feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet)
The `other` model type exposes five sub_types. They are **model-type names**, so they follow
R3 and stay in Latin in every locale. The `settings.folderSettings.subType*` values are
therefore **intentionally byte-identical to `en.json`** (same precedent as
`settings.priorityTags.modelTypes` / `checkpoints.modelTypes.checkpoint`) — a §6 sweep must
not "fix" them.
| Term | Rendering | Note |
|---|---|---|
| VAE | `VAE` everywhere | acronym, always upper-case |
| Upscaler | `Upscaler` everywhere | CivitAI `ModelType` name |
| Text Encoder | `Text Encoder` everywhere | de compounds as `Text-Encoder-Stammordner` |
| CLIP Vision | `CLIP Vision` everywhere | de compounds as `CLIP-Vision-Stammordner` |
| ControlNet | `ControlNet` everywhere | brand casing, capital N |
In prose these names sit next to localized nouns the same way `Diffusion Model` does
(zh `VAE 根目录`, ja `VAEルート`, ko `VAE 루트`, ru `Корневая папка VAE`).
**"Other Models" is the page/feature name, not a model type — translate it:**
| Locale | `other.title` | `header.navigation.other` |
|---|---|---|
| fr | Autres modèles | Autres |
| zh-CN | 其他模型 | 其他 |
| zh-TW | 其他模型 | 其他 |
| ja | その他のモデル | その他 |
| ko | 기타 모델 | 기타 |
| de | Weitere Modelle | Andere |
| es | Otros modelos | Otros |
| ru | Другие модели | Другое |
| he | מודלים אחרים | אחרים |
`settings.folderSettings.otherSubTypes` ("Managed Types") must name **model** types, matching
each locale's `header.filter.modelTypes` rendering (zh `管理的模型类型`, ja `管理するモデルタイプ`,
de `Verwaltete Modelltypen`, …).
The "no folders found" empty state (`other.noPaths.*`) uses two phrases that must stay
consistent whenever that copy is edited. `folder key` means the `folder_paths` key name
(`vae`, `upscale_models`, … — Latin per the table above); `on disk` means the folder must
physically exist:
| Phrase | Rendering |
|---|---|
| folder key | zh-CN 文件夹键 · zh-TW 資料夾鍵 · ja フォルダーキー · ko 폴더 키 · fr clé de dossier · de Ordnerschlüssel · es clave de carpeta · ru ключ папки · he מפתח תיקייה |
| on disk | zh-CN 在磁盘上 · zh-TW 在磁碟上 · ja ディスク上 · ko 디스크에 · fr sur le disque · de auf dem Datenträger · es en el disco · ru на диске · he בדיסק |
`settings.json` and `ComfyUI` stay verbatim in every locale; "reload this page" / "restart
LoRA Manager" reuse each locale's existing restart wording (`settings.extraFolderPaths.*`).
### Model source feature (Hugging Face / ModelScope / TensorArt)
A model file can be linked to the page of an external model site. **Hugging Face**,
**ModelScope** and **TensorArt** are brand names and stay Latin in every locale (R3); the
generic nouns around them are translated:
| Term | Rendering |
|---|---|
| model source | zh-CN 模型来源 · zh-TW 模型來源 · ja モデルソース · ko 모델 소스 · fr source de modèle · de Modellquelle · es fuente de modelo · ru источник модели · he מקור מודל |
| model page | zh-CN 模型页面 · zh-TW 模型頁面 · ja モデルページ · ko 모델 페이지 · fr page du modèle · de Modellseite · es página del modelo · ru страница модели · he עמוד המודל |
| model card | zh-CN 模型卡 · zh-TW 模型卡 · ja モデルカード · ko 모델 카드 · fr fiche de modèle · de Modellkarte · es ficha de modelo · ru карточка модели · he כרטיס מודל |
| AI enrichment (noun) | reuse the existing pair per locale: zh-CN 增强 · zh-TW 增強 · ja 補完 · ko 보강 · fr enrichissement (par IA) · de Anreicherung (KI-) · es enriquecimiento (con IA) · ru обогащение (с помощью ИИ) · he העשרה (AI) |
`modelCard.actions.viewOnSource` ("View on {source}") follows each locale's existing
`viewOnHuggingFace` pattern — de `Auf … ansehen`, ru `Открыть …`, he `צפייה ב-…`,
ja `… で見る`, ko `…에서 보기`, zh `在 … 查看`, fr `Voir sur …`, es `Ver en …`. `{source}` is
replaced at runtime with the untranslated platform name, so the brand never appears inside the
translated text.
`modals.linkModelSource.enrichNote` states the rule that only sites exposing a readable model
card can be enriched and names TensorArt as the current exception. Keep the parenthetical
exception in sync if another link-only source is ever added — the sentence is deliberately
phrased as a rule, not as an apology for one site.
The context-menu and bulk-operation enrichment entry points read **"Enrich Metadata with AI"**
in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale may reintroduce
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
(the key names keep the historical `Hf`; only the values changed).
The gated/private-repository download support added `settings.huggingfaceApiKey*` (label,
placeholder, help, and the three status strings). "Access token" renderings, and the status
strings reuse each locale's existing `civitaiApiKey*` forms ("Configured" / "Not configured" /
"Set up") verbatim:
| Term | Rendering |
|---|---|
| access token | zh-CN 访问令牌 · zh-TW 存取權杖 · ja アクセストークン · ko 액세스 토큰 · fr jeton d'accès · de Access Token (Latin, like `CivitAI API Key`) · es token de acceso · ru токен доступа · he אסימון גישה |
| gated repository | zh-CN 受限(gated)仓库 · zh-TW 受限(gated)倉庫 · ja ゲート付きリポジトリ · ko 게이트가 설정된 저장소 · fr dépôt restreint (gated) · de gated Repository (loanword) · es repositorio restringido (gated) · ru закрытый (gated) репозиторий · he מאגר מוגבל (gated) |
The help text tells the user to create a **read-only** token at
`huggingface.co/settings/tokens` and to accept the repository's terms on its page first —
keep both clauses: a token alone does not unlock a gated repository.
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
(the `folder key` row); the rest is new surface:
| Term | Rendering |
|---|---|
| folder | zh-CN 文件夹 · zh-TW 資料夾 · ja フォルダ · ko 폴더 · fr dossier · de Ordner · es carpeta · ru папка · he תיקייה |
| model root (as in "no model root is configured") | zh-CN 模型根目录 · zh-TW 模型根目錄 · ja モデルルート · ko 모델 루트 · fr racine de modèle · de Modell-Stammverzeichnis · es raíz de modelo · ru корневая папка моделей · he שורש מודלים — note `sidebar.modelRoot` alone is the shorter 根目录 / 根目錄 / ルート / 루트 / Racine / Stammverzeichnis / Raíz / Корень / שורש |
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
Deleting a folder **never cascades over model files** — the backend refuses it and the
`sidebar.deleteFolderModal.notEmptyMessage*` keys state the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The three variants split by what the modal knows:
`notEmptyMessage` (no counts), `notEmptyMessageCount` (`{count}`, the blocking models are all
listed) and `notEmptyMessageExcluded` (`{count}` + `{excluded}`, at least one is hidden by the
`exclude` flag — the case where the folder legitimately looks empty). `checking` ("Checking the
folder contents...", ASCII ellipsis) shows while the backend dry run is pending, `busyTitle`
titles the already-pending-staged-delete state, and `notEmptyWithCount` mirrors
`deleteFolderResult.notEmpty` with the count for the stale-tree toast.
| Term | Rendering |
|---|---|
| excluded from the library | zh-CN 已从模型库中排除 · zh-TW 已從模型庫中排除 · ja ライブラリから除外 · ko 라이브러리에서 제외 · fr exclu de la bibliothèque · de von der Bibliothek ausgeschlossen · es excluido de la biblioteca · ru исключены из библиотеки · he מוחרגים מהספרייה |
| un-exclude (verb) | zh-CN 取消排除 · zh-TW 取消排除 · ja 除外を解除 · ko 제외를 해제 · fr annuler l'exclusion · de den Ausschluss aufheben · es anular la exclusión · ru снять исключение · he לבטל את ההחרגה |
| "Manage Excluded Models" quoted in prose | zh-CN “管理已排除的模型” · zh-TW 「管理已排除的模型」 · ja 「除外モデルを管理」 · ko '제외된 모델 관리' · fr « Gérer les modèles exclus » · de „Ausgeschlossene Modelle verwalten“ · es «Gestionar modelos excluidos» · ru «Управление исключёнными моделями» · he «ניהול מודלים מוחרגים» |
A UI label quoted inside prose follows each locale's existing help-text style (zh-CN “ ”,
zh-TW/ja 「 」, ko ASCII `' '`, fr/ru/es/he « », de „ “) — see `settings.hideEarlyAccessUpdates.help`
/ `settings.civitaiHost.help` as the precedent. `קובצי מודלים` is the Hebrew model-file noun
(`notEmptyMessage`); keep it identical in all four Hebrew keys.
The `{name}` / `{count}` / `{excluded}` / `{message}` tokens in `sidebar.createFolderResult.*`,
`sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` are verbatim §1-R2
placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessageCount`,
`notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key
carrying `{excluded}`.
#### Multi-root folder operations (resolution, copies, symlinks)
The sidebar's folder tree is a union over the model roots, so a relative folder can stand for
several directories at once. Copy is called **copy** (one directory per root), never "version"
or "instance"; a directory the backend refuses to touch is called out as a **symbolic link**,
and a per-row verdict reads **no models** when the copy is deletable:
| Term | Rendering |
|---|---|
| copy (one directory per root holding the same relative folder) | zh-CN 副本 · zh-TW 副本 · ja コピー · ko 복사본 · fr copie · de Kopie · es copia · ru копия · he עותק |
| symbolic link | zh-CN 符号链接 · zh-TW 符號連結 · ja シンボリックリンク · ko 심볼릭 링크 · fr lien symbolique · de symbolischer Link · es enlace simbólico · ru символическая ссылка · he קישור סמלי |
| unchecked (row / note wording) | zh-CN 未勾选 · zh-TW 未勾選 · ja チェックを外した · ko 선택하지 않은 · fr non cochée · de nicht angehakt · es no marcada · ru неотмеченная · he שלא סומן |
| "no models" (row verdict) | zh-CN 无模型 · zh-TW 無模型 · ja モデルなし · ko 모델 없음 · fr aucun modèle · de keine Modelle · es sin modelos · ru моделей нет · he אין מודלים |
| model root — plural ("more than one model root", "from {count} model roots") | zh-CN 模型根目录(多个模型根目录)· zh-TW 模型根目錄(多個模型根目錄)· ja モデルルート(複数のモデルルート)· ko 모델 루트(여러 모델 루트)· fr racine de modèle (plusieurs racines de modèle) · de Modell-Stammverzeichnis (mehrere Modell-Stammverzeichnisse) · es raíz de modelo (más de una raíz de modelo) · ru корневая папка моделей (несколько корневых папок моделей) · he שורש מודלים (יותר משורש מודלים אחד) |
`{count}` / `{total}` / `{failed}` / `{excluded}` / `{name}` are verbatim §1-R2 placeholders in
`deleteFolderModal.confirmMulti`, `deleteFolderResult.successMulti` / `.partial` and
`folderRoot.notEmptyStatus` / `.notEmptyExcludedStatus`. The row verdicts are **fragments, not
sentences**: they sit under a path inside the chooser box, so no locale capitalizes them or adds
a period (`de` keeps its lowercase start, `fr` too). `folderRoot.rootsHint` ends with each
locale's colon (`:` for CJK, ASCII elsewhere). `partial` reuses the locale's "N of M" shape and
keeps the `—` before the failed count; `successMulti` keeps the locale's plain
`deleteFolderResult.success` verb and appends the root count.
### Settings Organization tab
The settings modal's fourth nav tab groups everything about how files are arranged on
disk: download path templates, priority tags, and auto-organize exclusions. The label is
the **noun for arranging files**, matching each locale's existing
`settings.sections.autoOrganize` rendering minus the "auto":
| Locale | `settings.nav.organization` |
|---|---|
| fr | Organisation |
| zh-CN | 整理 |
| zh-TW | 整理 |
| ja | 整理 |
| ko | 정리 |
| de | Organisation |
| es | Organización |
| ru | Организация |
| he | ארגון |
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
### Sidecar storage feature (centralized `.metadata.json` / preview storage)
The Library settings tab hosts an optional mode that stores `.metadata.json` sidecars and
preview images either **alongside** each model file or in a single **centralized** mirror tree,
plus the manual migration that moves existing files between the two. Everything lives in
`settings.sections.sidecarStorage` (the section header inside the Library tab),
`settings.sidecarStorage.*` and `modals.sidecarMigrationConfirm.*`.
- **`sidecar` is a technical noun, not a brand**, so each locale either borrows it or uses its
own companion-file word — one rendering per file:
| Term | Rendering |
|---|---|
| sidecar (noun) | zh-CN 附属文件 · zh-TW 附屬檔案 · ja サイドカーファイル · ko 사이드카 파일 · fr fichier sidecar · de Sidecar-Datei · es archivo sidecar · ru sidecar-файл · he קובץ לוואי |
| centralized storage | zh-CN 集中存储 · zh-TW 集中儲存 · ja 集中保存 · ko 중앙 집중식 저장 · fr stockage centralisé · de zentrale Speicherung · es almacenamiento centralizado · ru централизованное хранилище · he אחסון מרכזי |
| alongside model files | zh-CN 与模型文件放在一起 · zh-TW 與模型檔案放在一起 · ja モデルファイルの隣 · ko 모델 파일 옆 · fr à côté des fichiers de modèle · de neben den Modelldateien · es junto a los archivos de modelo · ru рядом с файлами моделей · he לצד קובצי המודלים |
| migrate (verb/noun) | zh-CN 迁移 · zh-TW 遷移 · ja 移動 · ko 이동 · fr migrer / migration · de verschieben / Migration · es migrar / migración · ru перенести / перенос · he להעביר / העברה |
| mirror (verb) | zh-CN 镜像 · zh-TW 對應 · ja ミラーリング · ko 미러링 · fr refléter · de spiegeln · es reflejar · ru повторять структуру · he לשקף |
| preview images | zh-CN 预览图片 · zh-TW 預覽圖片 · ja プレビュー画像 · ko 미리보기 이미지 · fr images d’aperçu · de Vorschaubilder · es imágenes de vista previa · ru изображения превью · he תמונות תצוגה מקדימה |
| (effective) storage location | zh-CN (实际)存储位置 · zh-TW (實際)儲存位置 · ja (実際の)保存場所 · ko (실제) 저장 위치 · fr emplacement de stockage (effectif) · de (tatsächlicher) Speicherort · es ubicación de almacenamiento (efectiva) · ru (фактическое) расположение хранилища · he מיקום האחסון (בפועל) |
| Open Folder (button) | zh-CN 打开文件夹 · zh-TW 開啟資料夾 · ja フォルダを開く · ko 폴더 열기 · fr Ouvrir le dossier · de Ordner öffnen · es Abrir carpeta · ru Открыть папку · he פתח תיקייה |
| installation folder | zh-CN 安装目录 · zh-TW 安裝目錄 · ja インストールフォルダ · ko 설치 폴더 · fr dossier d’installation · de Installationsordner · es carpeta de instalación · ru папка установки · he תיקיית ההתקנה |
- `ja`/`ko` follow the file's existing storage-relocation verb (ja 移動, ko 이동, from
`settings.folderSettings.recipesPathMigrating`) rather than a transliteration of "migration";
`ru` uses перенос for the same reason, and `de` keeps the loan noun `Migration` while the verbs
use `verschieben`.
- **`.metadata.json`**, **`.civitai.info`** and the default-path literal
`(<settings dir>/sidecars)` stay byte-identical in every locale — they are file names and a
path, not prose (§6 exception). Hebrew drops the wrapping parentheses to avoid bidi mirroring
and writes the literal bare.
- `migrationDeferred` names a navigation path ("Settings → Library → Sidecar Storage"), so each
locale renders it with its **own** settings label and Library tab label
(`common.actions.settings` + `settings.nav.library` + the new section label), using the same
arrow and quoting style its other nav-path strings already use — zh-CN “设置 → 库 → …”,
zh-TW/ja 「設定 > … > …」, ko `설정 → …` bare, fr/de/es bare
(`Paramètres` / `Einstellungen` / `Configuración` → …), ru «Настройки → …»,
he `הגדרות > …` bare (cf. `other.noPaths.descriptionStandalone`).
- The migrate-button label is quoted inside `confirmToCentralized` / `confirmToAlongside` with
each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」, ko `' '`, fr/ru/es/he « »,
de „ “), matching `settings.sidecarStorage.migrateButton` verbatim so the two never drift.
### Filename Templates feature
Per-model-type templates that name downloaded model files; "Apply to Library Now"
bulk-renames existing files, and an **empty template restores the recorded original
filenames** (recorded in each model's metadata at its first rename). "Template" follows
each locale's existing download-path-template noun (zh-CN 模板 vs zh-TW 範本 — note the
split); progress strings mirror `loras.bulkOperations.autoOrganizeProgress` verbatim with
the locale's "moved" verb swapped for its "renamed" verb, and the toasts mirror the
`autoOrganize*` / `downloadTemplates*` toast shapes.
| Term | Rendering |
|---|---|
| filename template(s) | zh-CN 文件名模板 · zh-TW 檔案名稱範本 · ja ファイル名テンプレート · ko 파일명 템플릿 · fr modèle(s) de nom de fichier · de Dateinamen-Vorlage(n) · es plantilla(s) de nombres de archivo · ru шаблон(ы) имён файлов · he תבנית שם קובץ / תבניות שמות קבצים |
| Apply to Library Now (button) | zh-CN 立即应用到库 · zh-TW 立即套用至模型庫 · ja ライブラリに今すぐ適用 · ko 지금 라이브러리에 적용 · fr Appliquer à la bibliothèque maintenant · de Jetzt auf Bibliothek anwenden · es Aplicar a la biblioteca ahora · ru Применить к библиотеке сейчас · he החל על הספרייה כעת |
| Restore original filenames (modal title / button) | zh-CN 恢复原始文件名?/ 恢复原始文件名 · zh-TW 要還原原始檔案名稱嗎?/ 還原原始檔案名稱 · ja 元のファイル名を復元しますか?/ 元のファイル名を復元 · ko 원본 파일명을 복원하시겠습니까? / 원본 파일명 복원 · fr Restaurer les noms de fichier d'origine ? / Restaurer les noms de fichier d'origine · de Ursprüngliche Dateinamen wiederherstellen? / Ursprüngliche Dateinamen wiederherstellen · es ¿Restaurar los nombres de archivo originales? / Restaurar nombres de archivo originales · ru Восстановить исходные имена файлов? / Восстановить исходные имена файлов · he לשחזר שמות קבצים מקוריים? / שחזר שמות קבצים מקוריים |
| "renamed" (progress/toast counter) | zh-CN 已重命名 · zh-TW 已重新命名 · ja リネーム · ko 이름 변경 · fr renommés · de umbenannt · es renombrados · ru переименовано · he שונו שמותם |
### Chip reordering (model tags / trigger words)
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
`⠿` grip where the chip body is click-to-edit), so the copy sits in `common.reorder.dragHandle`
instead of a feature namespace. It is used twice per editor: as the grip tooltip and as the
hint shown in the edit controls row. There is deliberately **no keyboard shortcut** — an
`Alt + Arrow` binding fought the browser's own Alt + Arrow handling and the modal's arrow-key
navigation, so reordering is pointer-only and the grip is a decorative, non-focusable
affordance. Do not reintroduce a shortcut or a "position X of Y" screen-reader string without
re-adding the corresponding keys.
`dragHandle` is a fragment, not a sentence: it labels both the grip and the hint, so keep it
short and imperative and do not append a keyboard hint in any locale.
| Term | Rendering |
|---|---|
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
The grip itself is an icon and is never translated.
### Download routing feature (unknown base model routing)
The `settings.unknownBaseModelRouting.*` keys (Settings → Library → Folder Settings) decide
which library a checkpoint download lands in when CivitAI reports a baseModel that is in
neither the known-checkpoint nor the known-diffusion-model list. The option labels reuse
each locale's `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings
(model-type names, R3 — ja/ko keep the Latin loanword), pluralized only where the locale
pluralizes (de Diffusionsmodelle, es Modelos de difusión, fr Modèles de diffusion,
ru Диффузионные модели, he מודלי דיפוזיה; CJK stays singular, "Checkpoint(s)" follows
`header.navigation.checkpoints`).
| Term | Rendering |
|---|---|
| routing (noun, of a download into a library) | zh-CN 路由 · zh-TW 路由 · ja 振り分け · ko 라우팅 · fr routage · de Routing · es enrutamiento · ru маршрутизация · he ניתוב |
| unknown base model | zh-CN 未知基础模型 · zh-TW 未知基礎模型 · ja 不明なベースモデル · ko 알 수 없는 베이스 모델 · fr modèle de base inconnu · de unbekanntes Basismodell · es modelo base desconocido · ru неизвестная базовая модель · he מודל בסיס לא מוכר |
| destination type (download-modal toggle label) | zh-CN 目标类型 · zh-TW 目標類型 · ja 保存先タイプ · ko 대상 유형 · fr type de destination · de Zieltyp · es tipo de destino · ru тип назначения · he סוג יעד |
The baseModel family names in the help text (`SD 1.x/2.x/3.x, SDXL, Pony, Illustrious,
NoobAI`) are CivitAI baseModel values and stay verbatim in every locale.
The routing-override toggle (`modals.download.routingOverride.*`) sits on the checkpoints
page of the download modal; its two button labels come from `checkpoints.modelTypes.*`
directly (model-type names, R3). The tooltip quotes the `modals.download.useDefaultPath`
label verbatim with each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」,
ko `' '`, fr « … », de „ … “, es/ru/he «…»).
### OpenModelDB feature
**OpenModelDB** is a brand name and stays Latin in every locale (R3, same as CivitAI /
CivArchive); `openmodeldb.info` is a URL and stays verbatim. **Upscaler** follows the
Other Models rule (model-type name, Latin everywhere). The label/help mirror each
locale's existing `settings.metadataArchive.enableCivarchiveApi(Help)` phrasing, and
"metadata" uses the §5 rendering per locale.
| Term | Rendering |
|---|---|
| catalogue (the OpenModelDB catalogue) | zh-CN 目录 · zh-TW 目錄 · ja カタログ · ko 카탈로그 · fr catalogue · de Katalog · es catálogo · ru каталог · he קטלוג |
### Civitai ids feature (model/version id in the model modal)
The model modal's hash footnote shows the Civitai **model id** and **version id** with
copy buttons (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId` labels,
`modals.model.actions.copyCivitaiId` tooltip, `.civitaiIdCopied` toast). **"ID" stays
Latin in every locale** (same precedent as `recipes.*.copyId`), and `Civitai` is the
brand (R3) — it is never translated or transliterated; the casing mirrors `en.json`
verbatim (R9). The copy/copied strings reuse each locale's existing clipboard patterns
(`modals.model.actions.copyHash` / `openFileLocation.copied`).
| Term | Rendering |
|---|---|
| Model ID (label) | zh-CN 模型 ID · zh-TW 模型 ID · ja モデル ID · ko 모델 ID · fr ID du modèle · de Modell-ID · es ID del modelo · ru ID модели · he מזהה מודל |
| Version ID (label) | zh-CN 版本 ID · zh-TW 版本 ID · ja バージョン ID · ko 버전 ID · fr ID de version · de Versions-ID · es ID de versión · ru ID версии · he מזהה גרסה |
Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these
labels, matching `recipes.*.copyId` (העתק מזהה מתכון).
### Showcase layout feature (gallery / vertical list toggle)
The model modal's example images can switch between a one-at-a-time **gallery** and the
classic **vertical list**, via a Settings select (`settings.layoutSettings.showcaseLayout*`,
mirroring the `recipesLayout*` select shape) and an in-modal segmented toggle whose two
icon buttons are labelled by `modals.model.showcase.layoutGallery` / `.layoutList`
(kept short — they are icon-button tooltips).
"Showcase Layout" is rendered as the **example-images layout** in most locales (the
section's user-facing content), reusing each locale's fixed "example images" noun
(`modelCardFooterActionOptions.exampleImages`); ja keeps its established ショーケース
loanword instead. The `layoutList` tooltip reuses the fixed "list view" noun from the
folder-sidebar row (above), so it stays byte-consistent with `sidebar.listView` where
that form fits a tooltip (ru shortens both toggle labels to bare «Галерея» / «Список»).
| Term | Rendering |
|---|---|
| showcase layout (settings label) | zh-CN 示例图片布局 · zh-TW 範例圖片版面 · ja ショーケースのレイアウト · ko 예시 이미지 레이아웃 · fr Disposition des images d'exemple · de Beispielbilder-Layout · es Diseño de imágenes de ejemplo · ru Макет примеров изображений · he פריסת תמונות דוגמה |
| example images | zh-CN 示例图片 · zh-TW 範例圖片 · ja 例画像 · ko 예시 이미지 · fr images d'exemple · de Beispielbilder · es imágenes de ejemplo · ru примеры изображений · he תמונות דוגמה |
| gallery / vertical list (option labels) | zh-CN 画廊 / 纵向列表 · zh-TW 圖庫 / 垂直清單 · ja ギャラリー / 縦並びリスト · ko 갤러리 / 세로 목록 · fr Galerie / Liste verticale · de Galerie / Vertikale Liste · es Galería / Lista vertical · ru Галерея / Вертикальный список · he גלריה / רשימה אנכית |
| gallery view / list view (toggle tooltips) | zh-CN 画廊视图 / 列表视图 · zh-TW 圖庫檢視 / 清單檢視 · ja ギャラリー表示 / リスト表示 · ko 갤러리 보기 / 목록 보기 · fr Vue galerie / Vue liste · de Galerieansicht / Listenansicht · es Vista de galería / Vista de lista · ru Галерея / Список · he תצוגת גלריה / תצוגת רשימה |
### Scoped scan and root availability
A refresh can be restricted to one model root (the Refresh ▾ menu) or one folder (the sidebar
context menu). The menu labels a root and reports whether it can be read at all; the result
toasts report what the scan changed and what it deliberately left alone because a path could not
be read (an unreachable drive is no longer treated as deleted).
| Term | Rendering |
|---|---|
| `scopeSection` ("Scan one folder") | zh-CN 只扫描一个文件夹 · zh-TW 只掃描一個資料夾 · ja フォルダを 1 つだけスキャン · ko 폴더 하나만 스캔 · fr Analyser un seul dossier · de Nur einen Ordner scannen · es Escanear solo una carpeta · ru Сканировать только одну папку · he סרוק תיקייה אחת בלבד |
| `rootOffline` ("Offline") | zh-CN 离线 · zh-TW 離線 · ja オフライン · ko 오프라인 · fr Hors ligne · de Offline · es Sin conexión · ru Недоступен · he לא זמין |
| `rootModels` ("{count} models") | zh-CN {count} 个模型 · zh-TW {count} 個模型 · ja {count} 個のモデル · ko 모델 {count}개 · fr {count} modèles · de {count} Modelle · es {count} modelos · ru {count} модел(ей) · he {count} מודלים |
| `refreshCompleteScoped` ("Scanned {scope}: {added} new, {removed} removed") | zh-CN 已扫描 {scope}:新增 {added},移除 {removed} · zh-TW 已掃描 {scope}:新增 {added},移除 {removed} · ja {scope} をスキャンしました:新規 {added} 件、削除 {removed} 件 · ko {scope} 스캔 완료: 새 모델 {added}개, 제거 {removed}개 · fr {scope} analysé : {added} nouveau(x), {removed} supprimé(s) · de {scope} gescannt: {added} neu, {removed} entfernt · es {scope} escaneado: {added} nuevo(s), {removed} eliminado(s) · ru {scope}: просканировано — новых {added}, удалено {removed} · he {scope} נסרק: {added} חדשים, {removed} הוסרו |
| `refreshKeptUnreachable` ("{count} models kept: {paths} not reachable") | zh-CN 已保留 {count} 个模型:{paths} 当前不可访问 · zh-TW 已保留 {count} 個模型:{paths} 目前無法存取 · ja {count} 個のモデルを保持しました:{paths} にアクセスできません · ko 모델 {count}개 유지됨: {paths}에 접근할 수 없음 · fr {count} modèles conservés : {paths} inaccessible(s) · de {count} Modelle beibehalten: {paths} nicht erreichbar · es {count} modelos conservados: {paths} no accesible(s) · ru Сохранено моделей: {count} — {paths} недоступны · he נשמרו {count} מודלים: {paths} אינם זמינים |
| `scanRootUnreachable` ("{scope} is not reachable right now. Nothing was changed.") | zh-CN {scope} 当前不可访问,未做任何改动。 · zh-TW {scope} 目前無法存取,未做任何變更。 · ja {scope} に現在アクセスできません。変更は行われていません。 · ko {scope}에 현재 접근할 수 없습니다. 변경된 내용은 없습니다. · fr {scope} est actuellement inaccessible. Aucune modification n’a été apportée. · de {scope} ist derzeit nicht erreichbar. Es wurde nichts geändert. · es {scope} no es accesible ahora mismo. No se ha cambiado nada. · ru {scope} сейчас недоступен. Изменений не внесено. · he {scope} אינו זמין כעת. לא בוצעו שינויים. |
| `sidebar.scanFolder` ("Scan this folder") | zh-CN 扫描此文件夹 · zh-TW 掃描此資料夾 · ja このフォルダをスキャン · ko 이 폴더 스캔 · fr Analyser ce dossier · de Diesen Ordner scannen · es Escanear esta carpeta · ru Сканировать эту папку · he סרוק תיקייה זו |
| `sidebar.scanFolderResult.missing` | identical to `sidebar.renameFolderResult.missing` / `deleteFolderResult.missing` in every locale (the folder vanished between the menu opening and the click) — reuse that rendering rather than writing a third variant |
`{scope}` is a root label (`G: loras`) or a folder path, `{paths}` is a comma-joined list capped
at three entries, and both counts arrive pre-formatted. "Offline" describes a configured root
whose directory cannot be read right now (drive switched off, unmounted share) — it is a state of
the *root*, never a property of the models inside it, which stay in the library.
### Scan progress (walk phase)
The manual Refresh dialog renders its status line from `common.scanProgress.*`. During the
reconcile walk the backend does not know the real file count yet (counting *is* the walk), so
the client shows the roots being walked plus a running count instead of a `processed/total`
ratio: `Checking for changes... G:, Y: (12,345 files) | ~3 min remaining`. The bar covers
0-50 % for the walk and 50-99 % for the new-file pass.
| Term | Rendering |
|---|---|
| `walkFiles` ("{count} files") | zh-CN {count} 个文件 · zh-TW {count} 個檔案 · ja {count} 件のファイル · ko 파일 {count}개 · fr {count} fichiers · de {count} Dateien · es {count} archivos · ru {count} файл(ов) · he {count} קבצים |
`walkFiles` is a **fragment, not a sentence**: the root labels, the `(` `)`, the ` | ` before
the ETA and the ETA text itself all come from `static/js/api/baseModelApi.js`, so no locale
carries punctuation here (the ASCII parentheses match the sibling `stages.process_models`
count, `(5/10)`). `{count}` is substituted with an already-formatted number
(`toLocaleString()`), so no locale adds its own digit grouping. "files" means the **model
files the walk looked at**, not every file on disk — the noun mirrors each locale's
`stages.count_models` rendering, and `ru` uses the `файл(ов)` form because the count ticks
live and can be any number (`notEmptyMessageCount` precedent).
--- ---
## 3. Cross-cutting confusion hot-spots (must-fix list) ## 3. Cross-cutting confusion hot-spots (must-fix list)
@@ -310,8 +865,9 @@ blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipe
The only values that remain intentionally identical to `en.json` are non-translatable: The only values that remain intentionally identical to `en.json` are non-translatable:
URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`), URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`),
example token lists (`character, concept, style(toon|toon_style)`), service/provider names example token lists (`character, concept, style(toon|toon_style)`), service/provider names
(`CivitAI → CivArchive → Archive DB`), and the external playlist title (`CivitAI → CivArchive → Archive DB`), model-type names (`settings.priorityTags.modelTypes.*`,
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist"). `settings.folderSettings.subTypeVae` … `subTypeControlnet` — see §2), and the external playlist
title (`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
+216
View File
@@ -0,0 +1,216 @@
# Load Image Metadata (LoraManager)
Load a source image and reuse its prompts, local models, LoRAs, and sampling settings.
The node lives under **Lora Manager → loaders**. Restart ComfyUI after installing
this change and refresh the page. This Python node needs no Vue widget build.
## Wiring a checkpoint workflow
1. Upload/select an image in **Load Image Metadata (LoraManager)**.
2. Convert `ckpt_name` on **Checkpoint Loader (LoraManager)** to an input and
connect `model_name`. Leave its randomization control fixed.
3. Connect the checkpoint's MODEL and CLIP to **Lora Loader (LoraManager)**.
Connect the metadata node's `lora_stack` to that loader. Leave its LoRA widget
empty unless you intentionally want additional LoRAs.
4. Connect the LoRA loader's CLIP to two CLIP Text Encode nodes. Connect metadata
`positive` and `negative` to their text inputs, and their conditioning outputs
to KSampler. Connect the LoRA loader's MODEL to KSampler.
5. Convert KSampler's seed, steps, cfg, sampler_name, scheduler, and denoise
widgets to inputs and connect the corresponding metadata outputs.
6. For text-to-image, connect width/height to an appropriate Empty Latent node.
For img2img, encode the `image` output with the appropriate VAE instead.
7. Connect KSampler's samples and the checkpoint's VAE to VAE Decode, then Save Image.
8. Connect `readable_report` to a text display node for prompts, sampling settings,
model/LoRA names, local resolution status and warnings. The original `report`
output remains notes followed by formatted JSON; it is not a pure JSON string.
`model_name`, `sampler_name`, and `scheduler` are declared as untyped (`*`)
outputs so they can feed the dropdown widget inputs on both
**Load Checkpoint**/**KSampler** and the LoRA Manager loaders. Typing them
`COMBO` does not work: ComfyUI only accepts a `COMBO` output into a node that
declares its dropdown as `COMBO`/`IO.Combo`, while classic dropdowns expose a
plain option list, and the server rejects the link with "Return type mismatch
between linked nodes". `model_name` contains the matching local checkpoint or
diffusion-model filename. The report identifies the resolved type; connect it to
the appropriate loader. Lookup searches both categories regardless of how the
original metadata labels the model.
For a diffusion-model workflow, connect `model_name` to **Unet Loader
(LoraManager)** and select the correct text encoder(s), VAE, latent node and
architecture-specific conditioning separately. These settings do not reconstruct
an entire workflow or guarantee pixel-identical reproduction.
## Selection and overrides
`prefer_saved_image_metadata` is enabled by default. It prefers the saved
A1111-style generation parameters (including ComfyUI exports in that format)
over the workflow. The report identifies this source; `sampler_node_id` is
ignored in this mode when valid saved parameters are available. If saved
parameters are absent or malformed, the node tries workflow metadata and
reports any parsing failure.
Disable the flag to prefer workflow extraction. Only active samplers are
eligible: muted/bypassed sampler nodes and samplers inside muted/bypassed
subgraph instances are excluded. This uses the saved UI workflow's mode flags
when available, including nested subgraphs, and any modes in the API graph.
Explicitly selecting an inactive sampler produces an error report and the
usual saved-parameter/default recovery; it never extracts that inactive stage.
With one supported active sampler, leave `sampler_node_id` blank. With several, enter
its original node ID. Reports list candidate IDs when selection is ambiguous.
Native subgraphs in API prompt metadata use colon-qualified paths: `1481:1783`
means node 1783 inside subgraph instance 1481. Nested paths such as `10:20:30`
are supported; slash notation (`1481/1783`) is also accepted. A container ID
(`1481`) or leaf ID (`1783`) is accepted only if it identifies one sampler.
An exact sampler ID takes precedence over abbreviated matching.
Selection follows that sampler's graph, rather than mixing branches. Supported
sampling nodes include KSampler, KSamplerAdvanced and SamplerCustomAdvanced with
standard RandomNoise, CFGGuider/BasicGuider, BasicScheduler and KSamplerSelect
components. BasicGuider's CFG is 1; its architecture-specific lack of negative
conditioning is reported. Known Image Saver parameter/selector outputs and
rgthree seed values can be read without executing those nodes.
Detail Daemon's underlying sampler name is recovered, but its sampling effects
are explicitly unsupported. Other custom model/conditioning nodes can still
require defaults or overrides. If a requested stage cannot be read and global
image parameters are used instead, the report explicitly says those parameters
cannot verify the selected stage. Subgraph traversal requires the expanded API
prompt; UI-workflow-only subgraph definitions are not expanded or executed.
Extraction errors do not stop this node. If an API prompt uses unsupported
samplers, the node first tries the image's saved generation parameters. Any
remaining unavailable or invalid extracted fields use the SDXL starter defaults
(width/height fall back to the source image dimensions instead);
valid extracted fields are preserved. `readable_report` starts with **❌ ERROR**
and explains each recovery or substitution. This also applies to existing nodes
saved with `missing_settings=strict`; that legacy option no longer blocks
extraction recovery. New nodes default to `use_defaults`.
The report uses emoji section markers (🖼️ image, 📦 model, ⚙️ sampling, 🧩 LoRAs,
➕/➖ prompts) and ❌/⚠️/ℹ️ status markers. It is plain text, so colors depend on the
connected display node. Missing/ambiguous local files still appear in
`missing_files`. An empty model output requires selecting a local model manually.
Invalid explicit overrides and unreadable image files remain execution errors.
`overrides_json` replaces extracted values, for example:
```json
{
"scheduler": "normal",
"model_name": "portraits/model.safetensors",
"seed": 12345,
"loras": [["styles/ink.safetensors", 0.7, 0.3]]
}
```
Supported keys: `positive`, `negative`, `model_name`, `seed`,
`steps`, `cfg`, `sampler_name`, `scheduler`, `width`, `height`, `denoise`, `loras`.
LoRA entries are `[name, model_strength, clip_strength]`; `"loras": []` explicitly
clears the extracted stack. Legacy `checkpoint_name` and `unet_name` override
keys remain accepted as aliases for `model_name`; supply only one model key.
Exact relative or absolute local
business paths disambiguate duplicate basenames. Matching falls back to a unique
filename or extensionless filename, then an exact unique catalog `file_name` or
`model_name` alias. Version dots are preserved when stripping known file
extensions. It never downloads or fuzzy-matches models, and stale entries whose
files no longer exist are excluded.
Images with no metadata automatically use a bottle-inspired SDXL starter preset,
even with an existing saved `strict` setting: a glass-bottle/galaxy landscape
prompt, negative `text, watermark`, seed 0, 20 steps, CFG 7, Euler/normal,
1024×1024 and denoise 1, with no LoRAs. These settings are clearly identified as
synthetic defaults in both reports. Source image pixels and mask are unchanged.
Overrides take precedence. The node selects `sd_xl_base_1.0.safetensors` only
when uniquely indexed; otherwise choose an SDXL checkpoint manually or supply
`model_name`. Malformed or unsupported metadata also recovers with an explicit ERROR report.
## Supported metadata and limits
- PNG API prompt metadata; JPEG/WebP EXIF parameter comments; ComfyUI WebP
`prompt:`/`workflow:` EXIF fields.
- Standard KSampler, core checkpoint/UNet/LoRA loaders, LoRA Manager checkpoint,
UNet, LoRA/text loaders and LoRA stacks. LoRA application order and separate
model/CLIP strengths are preserved, including intentional repeated entries.
Different LoRA chains on model and prompt CLIP branches require an explicit
stack override rather than being silently merged.
- Literal CLIPTextEncode text and supported primitive value connections. Prompt
polarity comes from sampler wiring, never from words such as “ugly”.
- A1111/Forge generation text with explicit sampler alias mappings. Recognized
LoRA directives become stack entries and are removed from prompt text. Literal
tags in ComfyUI encoder text remain literal; graph loaders determine its stack.
- A1111 `Automatic`/absent schedules do not reliably identify a ComfyUI schedule.
The node substitutes `normal` and reports the missing information as an ERROR;
an explicit override can select a different schedule.
- UI-workflow-only fallback supports known core widget layouts, with a report
warning. Saved widgets can differ from executed values (for example a seed
randomized after generation). Custom widget layouts are not guessed.
- KSamplerAdvanced partial/noise settings require an explicit denoise override;
this is an intentional approximation, not a reconstruction of those controls.
- Distinct SDXL/Flux encoder prompts, combined/regional/zeroed conditioning,
arbitrary custom nodes, dynamic wildcards and unsupported custom sampling components are
not automatically reconstructed. Supply explicit overrides or retain the
original workflow for those cases.
- Width/height come from a recognized latent source or fall back to source-image
dimensions; resized/upscaled images can therefore need dimension overrides.
Only the synthetic starter preset for metadata-free images uses a fixed
1024×1024 regardless of the source image size.
- VAE, text encoder choice, CLIP skip, ControlNet and architecture-specific
conditioning still need the appropriate nodes. No embedded code is executed
and no external metadata service is contacted.
LoRA Manager must have indexed the required models. Library resolution includes
its configured extra folders and preserves business paths through symlinks.
## Extraction without a local catalog
The parser extracts names before attempting local resolution. In recovery mode,
`report` includes `source_resources` with original model names, LoRA names and
strengths, and embedded resource hashes even when none are installed. The model
output sockets remain empty and the resolved stack excludes missing files.
Combined sampler labels such as `Euler a SGM Uniform`, `Euler Normal` and
`er_sde simple` are split into sampler and scheduler. Multiline parameter blocks
and their nested JSON resource lists are supported. If prompt LoRA tags are
absent, one hash-name entry and one weighted resource can be matched offline;
multiple entries require an explicit mapping rather than guessing from order.
A single resource also disambiguates duplicated identical prompt tags.
The `Model` field in A1111-style metadata does not distinguish checkpoints from
standalone diffusion models. The node searches both indexed categories by name,
then reports the matched type. Local model type and filename cannot be verified
without an indexed library. Multiple equally good matches are reported as
ambiguous; specify a relative path through `model_name` to disambiguate.
## “Image contains no supported generation metadata”
For older versions, this means extraction failed before any library lookup.
The current node uses the starter preset when metadata is entirely absent. The error identifies the
actual server file, its format, byte size and metadata keys. PNG text chunks are
read both before and after pixel data. If no generation metadata remains, upload
the original saved file: clipboard copies and re-encoded/exported images may
lose it. `use_defaults` supplies replacement settings; it does not recover the
original prompts or seed.
## Missing local resources
`missing_files` is a text output listing unresolved checkpoints/UNets and LoRAs.
LoRA entries include both model and CLIP weights and the resolution failure.
It is empty when all requested resources resolve. Missing and ambiguous LoRAs
are excluded from `lora_stack`, including in strict mode, so downstream loaders
receive only resolved files. Valid entries keep their original order and weights.
Unresolved model-name sockets are empty: select a model manually or override its
name before connecting that socket to a loader.
## Output layout and upgrade
The outputs start with `image`, `mask`, `positive`, `negative`, **`model_name`**,
**`lora_stack`**, **`lora_stack_text`**, followed by the sampling settings and reports.
`lora_stack_text` lists each resolved stack path with model and CLIP weights in
application order. It is empty for an empty stack; unresolved files appear only
in `missing_files`, with their requested weights.
This replaces the former separate checkpoint/UNet sockets and renames `lost_list`
to `missing_files`. Restart ComfyUI, refresh, and recreate existing instances of
this node; reconnect the model and stack outputs to avoid stale saved slot indices.
Sampling and report output indices remain unchanged. No Vue build is required.
+47
View File
@@ -11,6 +11,33 @@ This document defines the complete schema for `.metadata.json` files used by Lor
--- ---
## Storage Location (Alongside vs Centralized)
By default, `.metadata.json` sidecars and preview images live **alongside** their model files. An optional centralized mode stores them under a single root directory instead. Two settings control this (Settings → Library → Sidecar Storage):
| Setting | Values | Default |
|---------|--------|---------|
| `sidecar_storage_mode` | `"alongside"` \| `"centralized"` | `"alongside"` |
| `sidecar_storage_path` | Absolute path string; empty = `<settings dir>/sidecars` | `""` |
In centralized mode, sidecars and previews mirror each model root's directory structure:
```
<sidecar_root>/<root_component>/<rel_dir>/<name>.metadata.json
```
- `<rel_dir>` is the model's directory relative to the model root containing the file; the longest matching root wins, so nested roots mirror under the most specific root.
- `<root_component>` identifies the model root and **survives the root being moved or renamed**. It starts as the deterministic `<sanitized basename>-<path digest>` — so mirrors created by older builds, and mirrors left behind by a relocated sidecar root, still resolve — and is then pinned in `<sidecar_root>/.lm-sidecar-roots.json` alongside the root's last known path and a few sample subdirectories. Two roots sharing a basename (e.g. `/mnt/a/loras` and `/mnt/b/loras`) always get distinct components and never collide. Each path component is sanitized to filesystem-safe characters.
- **Moving or renaming a model root does not strand its sidecars.** On the next run the mirror identity is re-anchored to the root's new path (matched by basename and recorded sample directories), so favorites, notes, tags and usage tips keep resolving. An existing hash-named mirror from an older build is adopted as-is on first use.
- If an identity cannot be re-anchored unambiguously (e.g. two same-named candidate roots), nothing is guessed: the mirror stays on disk untouched and surfaces as an orphan in **Doctor → Centralized Sidecars** (and in the log). Restoring the original root path re-links it automatically.
- `.civitai.info` files always stay next to the model file, in both modes.
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings). The migration covers excluded (hidden) models too, so un-excluding one later never strands its sidecar in the old layout. The result payload includes a `sidecar_root` field with the resolved centralized root, and the settings UI shows the outcome counters plus an "Open Folder" shortcut.
- Changing `sidecar_storage_path` while centralized likewise needs a root relocation: `{"direction": "relocate_root", "old_root": "<previous path>"}` moves the whole mirror tree to the new root (the settings UI offers this automatically). The identity map travels with the tree, and its entries win over any map the destination acquired beforehand — so a mirror that was re-anchored earlier keeps its name even if something resolved against the new path before the relocation ran.
- The settings UI always shows the resolved effective storage root (via the `sidecar_storage_root*` fields in `GET /api/lm/settings`), with `POST /api/lm/sidecars/open-location` opening it in the file manager. When the resolved root lies inside the plugin installation folder (portable settings mode), the UI warns: reinstalling or clean-updating the plugin would delete the sidecars, so an explicit path outside the installation folder is recommended. The repo `.gitignore` excludes the portable-mode default (`/sidecars/`).
- All sidecar/preview path derivation goes through the helpers in `py/utils/sidecar_paths.py`; never construct paths inline. In the default `alongside` mode these helpers do no extra I/O at all — the identity map is only loaded and reconciled when centralized storage is actually in use.
---
## Base Fields (All Model Types) ## Base Fields (All Model Types)
These fields are present in all model metadata files. These fields are present in all model metadata files.
@@ -272,9 +299,29 @@ The `metadata_source` field indicates which provider last updated the metadata:
|-------|--------| |-------|--------|
| `"civitai_api"` | Civitai API | | `"civitai_api"` | Civitai API |
| `"civarchive"` | CivArchive API | | `"civarchive"` | CivArchive API |
| `"openmodeldb"` | OpenModelDB catalogue (upscaler models only; hash-matched) |
| `"archive_db"` | Metadata Archive Database | | `"archive_db"` | Metadata Archive Database |
| `null` | No external source (user-defined only) | | `null` | No external source (user-defined only) |
When `metadata_source` is `"openmodeldb"`, the `civitai` payload is a
CivitAI-shaped version dict synthesized from the OpenModelDB catalogue entry
(no numeric `id`/`modelId`), and the OpenModelDB-native details live in its
`openmodeldb` block (`id`, `url`, `authors`, `architecture`,
`architectureName`, `scale`, `license`, `date`).
In that payload, `images[].url` is always a displayable asset: paired
comparisons use the site-hosted thumbnail because the `LR`/`SR` originals
are ephemeral imgdiff viewer sessions that 404 outside them (the original
viewer link is kept as `images[].meta.comparisonUrl` for reference), and the
model-level thumbnail leads the list since the card preview derives from
`images[0]`. `files[].name` is derived from any URL path segment with a
model extension (mediafire-style mirrors bury it mid-path) or synthesized as
`{model_id}.{type}` for folder links.
Models downloaded from OpenModelDB additionally carry `source_platform:
"openmodeldb"` and `source_url` (the model page URL); their download-time
hydration is recorded as `metadata_source: "source:openmodeldb"`.
--- ---
## Auto-Update Behavior ## Auto-Update Behavior
@@ -0,0 +1,107 @@
# Plan: Filename Template Follow-ups
**Issue:** [#1071 — Lora Renaming](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1071)
**Status:** Core feature **implemented** (2026-09-19, commit `2bc9860b`,
preceded by the settings-tab split in `327da046`). Follow-ups 1 and 2 were
resolved together on 2026-09-19 by redefining the empty template as
"revert to recorded original filename" (see below). Follow-up 3 remains open.
## What shipped in `2bc9860b`
- Per-model-type `download_filename_templates` setting (empty = keep current
filename; opt-in). Placeholders: `{model_name}`, `{version_name}`,
`{base_model}`, `{author}`, `{first_tag}`, `{hash_short}`,
`{original_name}`.
- `calculate_filename_for_model()` in `py/utils/utils.py` renders the
template; templates containing path separators are rejected.
- Downloads apply the template post-download
(`DownloadManager._apply_download_filename_template`); rename conflicts
keep the original name and never fail the download.
- `ModelLifecycleService.rename_model` records `original_file_name` in the
`.metadata.json` sidecar (first rename wins via `setdefault`).
- Bulk apply: `GET|POST /api/lm/{prefix}/apply-filename-template`
(`FilenameTemplateUseCase`, shares the auto-organize lock, WS progress type
`filename_template_progress`).
- Settings UI: "Filename Templates" subsection in the new **Organization**
settings tab (`templates/components/modals/settings/organization.html`),
with validation, live preview, and per-type "Apply to Library Now".
Sandbox E2E verified: rename incl. companion files (previews, sidecars),
metadata pointer updates, `original_file_name` recording, idempotency,
conflict handling (failure counted, batch continues), empty-template no-op,
GET variant.
## Follow-ups 1 & 2 — RESOLVED: empty template = revert to recorded original
Follow-up 1 asked to reword the ambiguous "Valid (keep original filename)"
empty-template message; Follow-up 2 asked for a bulk revert to the recorded
`original_file_name`. Both were resolved by a single semantic change: **an
empty template now means "restore the recorded original filename"** instead of
"leave the current filename untouched".
Rationale: for never-renamed models a revert is a no-op (no recorded
original), for renamed models it restores the pre-rename name, and new
downloads with an empty template keep the download name as before — so the
two contexts (download path and bulk apply) share one coherent meaning, and
no separate revert feature or `{recorded_original}` placeholder is needed.
Implemented changes:
- `FilenameTemplateUseCase._process_model`: an empty template now resolves
the target name from the sidecar's `original_file_name` via the injected
`metadata_loader` (default `load_local_metadata`); models without a
recorded original or whose original matches the current name are skipped.
Cache entries do not project `original_file_name`, so the sidecar is read
per model.
- `SettingsManager.js`: removed the empty-template early return and the
apply-button disable (`updateFilenameTemplateApplyButton` deleted — the
button is now always enabled). The browser-native `confirm()` was replaced
with `filenameTemplateConfirmModal`
(`templates/components/modals/confirm_modals.html`), a **self-managed**
modal (like `DirectoryPickerModal`, NOT registered with ModalManager):
ModalManager's "close current modal on open" behavior would kill the
settings modal underneath. It stacks via `z-index: 10010`
(`delete-modal.css`), handles ESC in capture phase with
`stopPropagation`, and shows apply vs revert wording
(`modals.filenameTemplateConfirm.titleApply` / `titleRevert` /
`revertButton`; messages reuse `settings.filenameTemplates.confirmApply` /
`confirmRevert`).
- `locales/en.json`: reworded `help` / `applyHelp`, replaced
`validation.keepOriginal` with `validation.restoreOriginal`
("Valid (empty template restores original filenames)"), added
`confirmRevert`, removed the now-unused `emptyTemplateInfo`. Other locales
re-synced with `[TODO: Translate]` placeholders — retranslation waits for
the feature owner's request per `docs/i18n-translation-guidelines.md` §7.
- Tests: revert / no-record-skip / same-name-skip cases in
`tests/services/test_use_cases.py`; modal confirm-and-revert and
cancel paths in
`tests/frontend/managers/settingsManager.filenameTemplates.test.js`.
Sandbox E2E verified (standalone server, sandboxed settings + library under
`/tmp`, 2026-09-19): template apply renames and records
`original_file_name`; empty-template apply reverts to the recorded name;
revert target occupied by a newer file counts as failure and keeps the
current name; models without a recorded original are skipped;
apply → revert → re-apply cycles repeat cleanly.
Standing caveats (unchanged):
- The revert target may collide with an existing file — the existing conflict
handling (count as failure, keep current name) covers this.
- `original_file_name` only exists for models renamed after `2bc9860b`;
older renames have no recorded original and are skipped.
- `original_file_name` is kept (not cleared) after a revert, so
apply → revert → re-apply stays repeatable.
## Follow-up 3 — Cross-page refresh after bulk apply
**Problem:** the settings-modal "Apply to Library Now" button calls
`resetAndReload(true)`, which refreshes only the page type currently open.
Applying the checkpoint template while on the loras page leaves the loras
view refreshed but does not touch the checkpoints page state (same
limitation as the existing bulk auto-organize flow in
`static/js/managers/SettingsManager.js#applyFilenameTemplate`).
**Fix options:** broadcast a generic "library changed" event that every
page's state listens to, or accept the limitation (the other page reloads
its cache on next visit). Low priority.
+383
View File
@@ -0,0 +1,383 @@
# Plan: Scoped Scan (one root / one folder at a time)
**Issue:** [#1108](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1108) — scan a single
folder/root instead of the whole library.
**Status:** **P1, P2 and Wave 6 implemented** (2026-10-07). P1 shipped in `470d85cc` (translations
in `bd184559`), P2 in `12930ce7` (translations in `0941f992`). Supersedes the earlier draft's
per-root *status panel* and persisted *unreachable-subtree* state (both dropped, see
"Must NOT have").
Implementation notes / deviations from the draft below:
* The scope dataclass is public (`ReconcileScope`) because it crosses the scanner → service →
route boundary; the draft called it `_ReconcileScope`.
* `_reconcile_cache()` now returns a summary dict, exposed as
`ModelScanner.last_reconcile_summary` and returned by `BaseModelService.scan_models()`, so the
scan HTTP response carries the counts (the WS `completed` payload carries the same fields).
* Root labels are computed over the **whole configured set** and passed into the walk tracker, so
the progress line and the menu never disagree.
* Discovery: `Config._dedupe_existing_paths()` drops roots that do not exist **at config load**, so
a drive that is already off when LM starts is not in `get_model_roots()` at all. Two consequences:
it cannot appear in the refresh menu (nothing to select), and its cached entries are kept because
they are outside every configured root prefix. The `root_unreachable` path therefore covers the
*mid-session* case (drive switched off while LM runs), which is the workflow that motivated the
issue; both paths keep the entries, which is what matters.
* Known limitation to keep in mind: after a scoped scan (or one with unreadable paths) the recorded
empty-folder list is unioned instead of replaced, so an empty folder deleted on disk can linger in
the sidebar until the next full refresh. Models are unaffected.
## TL;DR (For humans)
**What you'll get:** the Refresh ▾ menu can scan **one model root** (one drive) and leave every
other root untouched. Refreshing while a drive is switched off no longer deletes that drive's
models from the cache/DB — they are kept and reported. Browsing the grid while a drive is off no
longer silently strips `preview_url` from that drive's models.
**Why this approach:** a scan scope is a **path scope**, not a device scope, so it also works for
layouts where one root contains symlinks to other drives. The safety net collapses into one rule —
*anything this walk could not read is left untouched* — and that rule is nearly free: the root
existence check already exists, `os.walk(onerror=...)` only fires on failure, and the cached-entry
prefix attribution is already computed for the walk-progress weights.
**What it will NOT do:** no active probing of nested symlinks; no persisted "unreachable subtree"
table; no per-card offline badges (separate change); no root enable/disable switch; the main
Refresh button keeps meaning "scan everything"; no configuration-convention/documentation push to
re-layout libraries that use cross-drive symlinks (dropped by request).
**Effort:** Medium (~1.5–2 days for P1 including tests)
**Risk:** Medium-low — the risk sits in the folder-tree (`all_folders`) merge under a scoped scan
and in the exact pruning predicate; both get explicit tests.
**Decisions to sanity-check:**
1. A full Refresh now treats a *configured but unreachable* root as "keep + report" instead of
"everything under it was deleted". This is a behaviour change (release-note it).
2. Keep the near-zero-cost **first-level symlink** reachability check (reuses
`config._path_mappings`); nested symlinks stay uncovered.
3. Root labels become set-aware (`G: loras` / `usb/loras`, auto-deduped by parent segments).
This also changes the walk-progress line.
## Scope
### Must have (P1)
- **Scope-aware reconcile**: `_reconcile_cache(scope=...)` where scope is
`{roots: [path] | None, folder: rel | None}`. Files inside the scope reconcile normally
(add / repair / remove); everything outside is neither re-read nor removed.
- **Path-level pruning guard**: cached entries under a path this walk could not read are excluded
from `missing` and reported. Three sources:
- configured root that fails the reachability check (already filtered at
`model_scanner.py:1468`),
- directories `os.walk` failed to enter (`onerror` collector; covers Windows junctions to an
offline drive, permission errors, I/O errors),
- first-level symlink mappings whose target is not a directory (`config._path_mappings`).
- **Folder tree correctness under scope**: `all_folders` becomes
`(old outside scope) ∪ (old under unreachable paths) ∪ (discovered)`, so a scoped scan cannot
collapse the sidebar tree and an offline root keeps its folders.
- **Result payload** for the toast: `added`, `removed`, `repaired`, `scanned_roots` (labels),
`skipped_roots` (`{path, label, kept}`), `unavailable_paths` (`{path, kept}`, capped),
`kept_unreachable`.
- **API**: `GET /api/lm/{prefix}/scan?full_rebuild=false&roots=<path>` (repeatable);
`GET /api/lm/{prefix}/roots` gains `root_details: [{path, label, reachable, models}]` while
keeping `roots: [str]` unchanged (other callers depend on it).
- **Frontend**: Refresh ▾ menu lists the current page's roots (label + model count, offline rows
greyed out and non-clickable), main button unchanged, root items disabled while a scan runs;
scoped completion toast reports counts and any kept/skipped paths.
- **Preview fix**: a preview 404 must not clear `preview_url` when the file's parent directory is
itself unreachable (`preview_handlers.py:57` + `_cleanup_stale_preview_url`).
- **Set-aware root labels** (`_root_display_labels(roots)`), shared by the progress broadcasts and
`/roots`: last path segment, Windows drive prefix (`G: loras`), deduped by prepending real parent
segments (`usb/loras`, `a/models/loras`), `(2)` fallback by sorted path, 40-char cap, full path in
the tooltip.
- i18n keys in `locales/en.json` + `python scripts/sync_translation_keys.py`.
- pytest + vitest coverage for every item above.
### Must have (P2, implemented)
- `folder=<rel>` parameter: walk `<root>/<rel>` for every reachable root that contains it (the
sidebar's unified tree has no root identity, so "this folder" means "this relative path in all
roots"), prune only inside that prefix. Validation reuses `normalize_relative_folder()` (extracted
from `ModelMoveService` so the folder operations and the scan endpoint reject the same input:
absolute paths, drive letters, `..` climbing). The summary carries `scope_label` = the folder, so
the toast names the folder the user clicked rather than the roots it happens to live under.
- Sidebar folder context menu entry "Scan this folder" (`templates/components/context_menu.html`
`#sidebarFolderContextMenu`, above `check-folder-updates`; update
`tests/frontend/regression/sidebarFolderContextMenu.test.js` expectations). The entry is gated
like the other folder operations and resolves the folder through the existing
`_resolveFolderCandidates()` before scanning, so a folder no root holds any more explains itself
instead of scanning nothing. It shares `check-folder-updates`'s divider (both are refresh-ish).
### Must NOT have (guardrails)
- NO per-entry symlink probing during the walk — `DirEntry.is_symlink()` costs a syscall per entry
on Windows, the exact cost the reconcile optimisation removed. Nested symlinks stay uncovered.
- NO persisted `unavailable_paths` table, no DB schema change, no new cache state.
- NO configuration-convention guidance for cross-drive symlinks (dropped by request).
- NO per-card/modal offline badges in this plan (separate change; needs the L1 prefix list which
P1 produces, so it can build on this later).
- NO root enable/disable setting, NO status panel in Settings.
- NO change to `full_rebuild=true` (it still walks everything and replaces the cache);
`roots` + `full_rebuild=true` is rejected with 400.
- NO new dependency, NO change to the extension-facing endpoints.
- NO `os.path.realpath` for scope/prune routing — business paths only (AGENTS.md rule).
## Todos
### Wave 1 — backend semantics and safety net
1. **Scope plumbing in the scanner.**
What to do: add `_ReconcileScope` (dataclass: `roots: Optional[List[str]]`, `folder: Optional[str]`)
and `_reconcile_cache(scope=None)`. Resolve the effective root list once: configured roots ∩
scope.roots, filtered by reachability; missing ones become `skipped_roots` (never pruned).
`_walk_roots_for_reconcile` / `_walk_root_group_sync` / `_walk_root_for_reconcile` take the scope
so the walk can start at `<root>/<folder>` while still computing `folder`/`file_path` relative to
the **root** (`_process_model_file(path, root_path)` must keep receiving the root).
References: `py/services/model_scanner.py:1453` (`_reconcile_cache`), `:1468` (root filter),
`:1832` (`_walk_root_group_sync`), `:365` (`_walk_root_for_reconcile`), `:134`
(`_count_cached_entries_per_root`), `:126` (`_normalized_root_prefix`).
Done when: a scoped reconcile over 2 roots leaves the other root's `raw_data`, hash index and
folder list byte-identical.
2. **Pruning predicate + unreachable collection.**
What to do: `missing = {p for p in cached_paths - found_paths if in_scope(p) and not
under_unreachable(p)}`; collect unreachable prefixes from (a) skipped roots, (b) an `onerror`
callback on `os.walk` (record `oserror.filename` as a business path), (c) `config._path_mappings`
entries whose target fails `os.path.isdir` (mapping is keyed target→link; protect the **link**
prefix). Count the kept entries per prefix for reporting.
References: `py/services/model_scanner.py:1679` (`missing_files = cached_paths - found_paths`),
`:398` (`os.walk(..., followlinks=True)`), `py/config.py:167` (`_path_mappings`),
`:801` (`add_path_mapping`, target→link), `:750-775` (first-level-only symlink scan).
Done when: with a root removed from disk (or a directory replaced by an unreadable junction),
`raw_data` keeps those entries, `removed` is 0, and the counts land in the result payload.
3. **Folder-tree merge under scope.**
What to do: replace the unconditional `sorted_discovered` assignment with the union rule from
"Must have"; keep the `folders_changed` comparison and the persist path unchanged. Unscoped
scans with nothing unreachable must produce exactly today's list.
References: `py/services/model_scanner.py` (walk loop records `discovered_folders`; `model_scanner.py:1738`
(`sorted_discovered`) and `:1739` (`folders_changed`) after the dedup pass).
Done when: a scoped scan keeps folders outside the scope, an offline root keeps its subtree, and
a full scan still drops folders deleted on disk.
4. **Result payload + progress/cancel/complete messages.**
What to do: extend the `completed` broadcast with `scanned_roots`, `skipped_roots`,
`unavailable_paths`, `kept_unreachable`, `repaired`; the walk tracker already receives only the
scoped roots, so the progress line shows that root's label with `roots_total=1`.
References: `py/services/model_scanner.py` `_broadcast_scan_progress` (`:576`),
`_ReconcileWalkTracker` (`:266`), the completed broadcast at the end of `_reconcile_cache`.
Done when: a scoped scan emits `stage=reconcile_scan` with the single scoped label and a
`completed` payload carrying the new fields.
5. **API surface.**
What to do: `scan_models` accepts repeated `roots` query params (400 on unknown root, 400 when
combined with `full_rebuild=true`); `get_model_roots` adds `root_details` (label, reachable,
model count via the shared prefix attribution) without touching `roots`.
References: `py/routes/handlers/model_handlers.py:1139` (`scan_models`), `:1165`
(`get_model_roots`), `py/routes/model_route_registrar.py:61-62`, `static/js/api/apiConfig.js:114`.
Done when: `tests/routes/test_lora_routes.py`-style coverage passes for the new params and the
backward-compatible `/roots` payload.
6. **Preview 404 must not prune when the parent is unreachable.**
What to do: in `serve_preview`, before `_cleanup_stale_preview_url`, check
`os.path.isdir(os.path.dirname(resolved))`; if the directory itself is missing/unreachable,
return 404 **without** clearing caches. (Centralized sidecar mode puts the preview in the mirror
tree, so the check must use the preview file's own parent.)
References: `py/routes/handlers/preview_handlers.py:57` (404 branch), `:74-110`
(`_cleanup_stale_preview_url` → `clear_preview_by_path` + `_persist_current_cache`),
`tests/routes/test_preview_routes.py`.
Done when: a test asserts the cache keeps `preview_url` when the parent dir is absent and still
clears it when the file was really deleted from a reachable directory.
### Wave 2 — labels
7. **`_root_display_labels(roots)` (set-aware, order-independent).**
What to do: replace the single-root `_root_display_label`; compute over the sorted root list;
prepend real parent segments until unique; `(2)`/`(3)` fallback; 40-char middle-ellipsis; return
`{path: label}`. Use it in `_ReconcileWalkTracker.__init__` (progress) and in the `/roots`
handler so both always agree.
References: `py/services/model_scanner.py:117` (`_root_display_label`, current single-root
version), `:266` (`_ReconcileWalkTracker`).
Done when: unit tests cover `usb/loras` + `ssd/loras`, `a/models/loras` + `b/models/loras`,
Windows `G:\x\loras` + `H:\y\loras` → `G: loras` / `H: loras`, and the identical-path fallback.
### Wave 3 — frontend
8. **Refresh ▾ scope section.**
What to do: add an empty container to `templates/components/controls.html` inside
`.dropdown-menu` (`#refreshScopeMenu`) + a section title; on dropdown open, fetch
`endpoints.roots`, render one row per root (`data-action="scan-root"`, `data-root="<path>"`,
label + model count, `disabled` + "Offline" for unreachable), hide the section on the recipes
page (recipes have no roots); wire clicks by **event delegation** on the menu (the existing
`full-rebuild` item is bound with a direct `querySelector`, dynamic rows cannot be);
invalidate the cached list after a scan finishes; add CSS for the section + `max-height`/
`overflow-y` so 4+ roots stay usable.
References: `templates/components/controls.html:67-76`, `static/js/components/controls/PageControls.js:131`
(`data-action="refresh"`), `:220-260` (`initDropdowns` + `full-rebuild` wiring),
`static/js/api/apiConfig.js:118` (`roots` endpoint), `static/js/api/baseModelApi.js:1318`
(`fetchModelRoots`).
Done when: the menu lists roots with counts, an offline root is visibly disabled, and clicking a
row starts a scoped refresh.
9. **Scoped request + toast.**
What to do: `refreshModels(fullRebuild, {roots})` appends repeated `roots` params; the
`completed` payload drives the toast (`added`/`removed`/`kept_unreachable`/`skipped_roots`);
offline rows toast `… is not reachable right now. Nothing was changed.` without a request.
References: `static/js/api/baseModelApi.js:514-602` (refreshModels, URL build at `:601`, toast
at `:618`), `static/js/components/controls/PageControls.js:485`.
Done when: the frontend test asserts the scoped URL and both toast variants.
10. **i18n.**
What to do: add `loras.controls.refresh.scopeSection`, `.rootOffline`, `.rootModels`,
`toast.api.refreshCompleteScoped`, `toast.api.refreshKeptUnreachable`,
`toast.api.scanRootUnreachable` to `locales/en.json`, run
`python scripts/sync_translation_keys.py`, then stop (placeholders are the expected state until
translations are requested).
Done when: `pytest tests/i18n` passes and every locale has the keys.
### Wave 4 — tests and verification
11. **Backend tests** (`tests/services/test_model_scanner.py`, `tests/routes/test_lora_routes.py`,
`tests/routes/test_preview_routes.py`):
`test_reconcile_scoped_scan_leaves_other_roots_untouched`,
`test_reconcile_scoped_scan_removes_deleted_files_in_scope`,
`test_reconcile_scoped_scan_preserves_folder_tree_outside_scope`,
`test_reconcile_keeps_entries_of_unreachable_root`,
`test_reconcile_keeps_entries_under_unreadable_dir`,
`test_reconcile_keeps_entries_under_offline_first_level_symlink`,
`test_root_display_labels_dedupe_by_parent_segments`,
`test_scan_models_accepts_roots_param`, `test_scan_rejects_unknown_root`,
`test_scan_rejects_roots_with_full_rebuild`, `test_roots_endpoint_reports_details`,
`test_preview_404_keeps_cache_when_parent_dir_missing`.
12. **Frontend tests**: extend `tests/frontend/api/baseModelApi.refresh.test.js` (scoped URL, both
toasts) and add `tests/frontend/components/controls/pageControls.scanRoots.test.js`
(rows rendered from `/roots`, offline row disabled, delegation wiring).
13. **Full suites + sandbox eyeball**: `pytest -q`, `npx vitest run`, `npm run test:vue`; then a
sandboxed standalone instance with two roots and a slowed walk
(`LM_WALK_DELAY_S`-style `sitecustomize` hook) to eyeball the scoped progress line, the offline
row and the toast — the user verifies by eye.
### Wave 5 — P2 (folder scope) — done
14. `folder=<rel>` in the scan endpoint + `ReconcileScope.folder`, the sidebar menu entry, the
context-menu regression test update, and the "folder in several roots" semantics (scan every
reachable root that has the relative path; report per root).
* `normalize_relative_folder()` extracted to module scope in `model_file_service.py` (the
private static method now delegates) and reused by the scan handler.
* `scanFolder()` in `SidebarManager` reuses `_resolveFolderCandidatesSafe()` and delegates to the
host page controls, which pass `{ folder }` through `registerAPI`'s argument-forwarding
`refreshModels`.
* Verified live: `folder=pack000` on the three-root sandbox walks drive-G and drive-Y, reports
`scope_label=pack000`, keeps drive-Z's 6 entries under that folder (`kept_unreachable=6`) and
leaves all 420 models cached.
## Wave 6 — configured vs currently available roots (approved follow-up)
The P1/P2 work exposed an asymmetry: `Config._dedupe_existing_paths()` drops roots whose directory
does not exist **at the moment the root list is built** (startup, or applying a library snapshot),
so a drive that is switched off while LM starts is not "an offline root" — it is not a root at all.
Consequences, all verified on the sandbox:
* `/roots` does not list it, so the Refresh ▾ menu shows no row for it in either state;
* a full refresh reports nothing (`kept_unreachable: 0`, no toast) even though its cached entries
are kept — they survive only because they fall outside every configured root prefix;
* plugging the drive back in does not bring the row back: the in-memory list is not re-validated;
* **plugin mode only:** `Config.save_folder_paths_to_settings()` (called from `Config.__init__`)
persists `target_folder_paths["loras"] = list(self.loras_roots)` through
`upsert_library(folder_paths=...)`, which **replaces** the library's paths. Starting ComfyUI with
a drive switched off therefore *erases that path from `settings.json`* — a configuration loss,
not just a display gap. Extra paths are unaffected (that call reuses the stored
`extra_folder_paths`), and the same pattern applies to checkpoints/unet/embeddings/other-model
primary paths.
### Must have
1. **Never erase an unavailable root from the library config.** `Config` records the *configured*
(existence-unfiltered) primary paths per model type while building the live lists, and
`save_folder_paths_to_settings()` persists those instead of the filtered ones.
`_resolve_valid_default_root()` receives the configured paths in `allowed_paths` too, so a
`default_*_root` that sits on a switched-off drive is not "repaired" away.
2. **Report configured-but-unavailable roots.** `Config.configured_roots_for(model_type)` feeds
`ModelScanner.describe_model_roots()`, which appends them with `available: false`, a live
`reachable` and their cached entry count — so the Refresh ▾ menu has a row in **both** states
(greyed while the directory is missing, normal and clickable once it is back). The reconcile
summary 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.
3. **Admit them again, append-only.** `Config.admit_configured_roots()` re-runs the per-type
prepare helpers against the configured paths and **appends** what exists now
(plus the checkpoint/unet/other side maps) to the live lists, then refreshes the preview
allowlist. Called from `/roots` and `/scan`, so a drive plugged in mid-session can be scanned
without restarting. Appending (never re-sorting) keeps `loras_roots[0]` — which derives the
recipes directory and the usage-stats file location — stable for the whole session.
### Must NOT have
* NO removal of a root from the live lists mid-session (that is what would let a later settings
save persist a reduced configuration, and it would move `*_roots[0]`).
* NO change to the scan/prune scope: a root that is still unavailable stays out of scope, so its
entries remain "kept because out of scope" exactly as today.
* NO persisted unavailable-state, no DB schema change, no per-entry filesystem probe.
### Where "configured" comes from
`Config` records the existence-unfiltered paths while building the live lists, but the *source*
differs per mode and that matters:
* **Plugin mode:** `folder_paths.get_folder_paths()` is ComfyUI's own list, unfiltered — a path the
user removed there must be forgotten, one that is merely missing must be kept. The host list wins.
* **Standalone mode:** `standalone.MockFolderPaths.get_folder_paths()` already filters
`os.path.exists` out of `settings.json`, so the host list cannot answer "what did the user
configure". The active **library snapshot** (`libraries[<active>].folder_paths`, falling back to
the top-level `folder_paths`) is the record, and it wins there.
(`_remember_library_configured_paths()` implements the split.)
### Verification
* Config: a configured path that does not exist is still written back by
`save_folder_paths_to_settings()`; a path the host no longer configures is still dropped.
* Promotion: create the directory after the lists were built → `admit_configured_roots()` adds it
**at the end**, keeps every existing root in place, and adds nothing twice.
* Scanner: `describe_model_roots()` reports the missing root (`available: false`, cached count);
a full refresh lists it in `skipped_roots`/`unavailable_paths` and keeps its entries; `/scan`
with that root works once the directory is back.
* Sandbox, drive-Z gone **before** startup: `/roots` reports `drive-Z` with
`reachable=false available=false models=60`; a full refresh returns
`skipped_roots=[drive-Z root_unavailable]`, `unavailable_paths=[{... kept: 60}]`,
`kept_unreachable=60` and leaves all 432 models cached. *(Both measured; before this wave the
menu had no row and the refresh reported nothing.)*
* Sandbox, drive renamed back **while the server runs**: `/roots` admits it
(`available=true reachable=true`) and `GET /scan?roots=<drive-Z>` walks it
(`scanned_roots=['drive-Z']`, 0 added / 0 removed) — no restart required. *(Measured.)*
## Known limitations (accepted)
- **Nested symlinks** (a symlink *below* a root pointing at another drive) are only covered when
`os.walk` fails to enter the target (Windows junctions, permission errors). A broken symlink that
`os.walk` classifies as a non-directory (typical on POSIX) is not detected: its entries are pruned
as today and come back on the next scan once the target is reachable (sidecars carry the sha256,
so no re-hash). Verify the Windows junction behaviour before writing release notes.
- Only **first-level** symlinks are known to `config` (by design, `config.py:750-775`), so the
symlink reachability check inherits that limit.
- A scoped scan of a folder does not re-read metadata for unchanged files (unchanged behaviour).
- Empty folders removed on disk can linger in the sidebar after a scoped scan (the folder list is
unioned rather than replaced whenever the scan did not verify every root); the next full refresh
drops them.
- `root_details[].reachable` is a live `os.path.exists()` per root, so a root that disappears
mid-session shows as offline; a root that was already gone at startup is filtered out by
`Config` and therefore absent from the list.
## Verification checklist
- [x] `pytest -q` green (3704 passed, 7 skipped), `npx vitest run` green (1495 passed),
`npm run test:vue` green (96 passed).
- [x] `python scripts/sync_translation_keys.py --dry-run` reports no pending changes.
- [x] Sandbox: 3 roots, one offline → scoped scan of one root walks only it; a full refresh keeps
the offline root's models (`kept_unreachable=60`) and the grid still shows all 420.
- [x] Sandbox: folder scope (`folder=pack000`) walks the two reachable roots, labels the scan by the
folder and keeps the offline root's 6 entries under it.
- [ ] Sandbox: preview of an offline root's model returns 404 and the DB keeps `preview_url`
(test-locked; not eyeballed in the sandbox because the demo models have no previews).
- [ ] Release note wording agreed for the behaviour change (decision 1).
- [ ] The 2 new sidebar keys (`sidebar.scanFolder`, `sidebar.scanFolderResult.missing`) are
`[TODO: Translate]` placeholders pending the feature owner's go-ahead.
+363
View File
@@ -0,0 +1,363 @@
# Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc.
**Status:** v2 — **Phase 1 implemented** (2026-09-12, commits `27da7b3c` backend + `fa7ce725` frontend; verified live against a running ComfyUI instance: scan/hash/sub_type-derivation/fetch/previews all green). **Phase 2 implemented** (2026-09-12, per §9 design; full pytest + vitest green). **Phase 3 implemented** (§11: opt-in management toggles; default off). **i18n done** (2026-09-13): all 36 new keys translated in the 9 non-English locales — the `[TODO: Translate]` placeholders left by the sync script during development are gone (see `docs/i18n-translation-guidelines.md` §2, "Other Models feature"). **Default set revised (pre-release):** only `vae` / `upscaler` / `text_encoder` are managed by default — `clip_vision` and `controlnet` are both opt-in (§2, §11.1.1).
**Scope (Phase 1):** scan + manage (list, search, filter, tags, folders, preview, rename, move, delete/exclude, CivitAI metadata fetch) for a new model type `other`, exposed as a new web page. **Phase 2 (§9):** one-click download from CivitAI for these types.
## 1. Goal
Today the manager supports three model types:
| page | model_type | sub_types |
|---|---|---|
| `/loras` | `lora` | `lora`, `locon`, `dora` |
| `/checkpoints` | `checkpoint` | `checkpoint`, `diffusion_model` |
| `/embeddings` | `embedding` | `embedding` |
Add a fourth page that manages "everything else" — VAE, upscalers, text encoders / CLIP, CLIP vision, optionally ControlNet — with a folder→sub_type mapping table so new ComfyUI folder categories can be added later by configuration, not code.
## 2. Locked Decisions
1. **Architecture: one scanner + one service + one page, sub_type derived by location.**
Replicates the checkpoint pattern (`CheckpointScanner` aggregates `checkpoints` + `unet` roots and derives `checkpoint` vs `diffusion_model` from the root containing the file, `py/services/checkpoint_scanner.py:384-415`). One `OtherScanner` aggregates all enabled folder roots; `resolve_sub_type_for_path()` maps each root to a sub_type. No per-category scanners.
2. **Naming: internal `model_type = "other"`, route prefix `/other`, page id `other`.**
- `misc` is rejected: `py/routes/misc_routes.py` already owns that name for system/settings routes (`/api/lm/settings`, `/api/lm/doctor/*`).
- `components` is rejected: `templates/components/` and `static/js/components/` directories would make `components.html` / `components.js` confusing neighbors.
- `other` matches CivitAI's `Other` fallback type semantics. The **display name** is an i18n string (`other.title`, e.g. "Other Models") and can be renamed later without touching code.
3. **sub_type values:** snake_case, aligned with CivitAI `ModelType` semantics:
| sub_type | ComfyUI `folder_paths` key(s) | CivitAI ModelType | enabled by default |
|---|---|---|---|
| `vae` | `vae` | `VAE` | yes |
| `upscaler` | `upscale_models` | `Upscaler` | yes |
| `text_encoder` | `text_encoders`, `clip` (legacy) | `TextEncoder` (CLIP is retired upstream) | yes |
| `clip_vision` | `clip_vision` | `CLIPVision` | no (mapping present, opt-in) |
| `controlnet` | `controlnet` | `Controlnet` | no (mapping present, opt-in) |
New folder categories = one line in the mapping table (see §4.1).
**Why only three are on by default** (revised in Phase 3, before release):
VAE, upscalers and text encoders are dependency-style assets every pipeline
needs, and "which one am I actually using" is the recurring problem they
solve. `clip_vision` and `controlnet` are workflow-driven instead
(IPAdapter/SVD image conditioning; per-workflow ControlNet variants), and
ControlNet libraries routinely run to dozens of files, so both are treated
symmetrically as opt-in. Enumerating all five as "the default set" was not
defensible on demand breadth alone.
4. **Phase 1 = scan/manage only.** Downloads from CivitAI (`download_manager.py` type mapping, default-root settings keys, download routing) are Phase 2 (§9). CivitAI **metadata fetch** for existing files IS in Phase 1 (hash-based lookup is type-agnostic; only the type-validation hook needs new values).
5. **Out of scope (default off, revisit later):** usage statistics buckets, recipe matching (`recipe_scanner.py` only merges lora+checkpoint scanners), statistics page, embeddings re-classification (stays its own page — merging would be a breaking change).
## 3. Why This Works With Minimal Churn
- `ModelScanner` (`py/services/model_scanner.py:93`) is specialized entirely via constructor params (`model_type`, `model_class`, `file_extensions`) + optional hooks (`adjust_metadata`, `adjust_cached_entry`, `resolve_sub_type_for_path`, `model_scanner.py:1429-1443`).
- `BaseModelService` subclasses can be one method (`EmbeddingService` implements only `format_response`, `py/services/embedding_service.py:12`).
- Routes: `ModelServiceFactory.register_model_type()` (`py/services/model_service_factory.py:120-136`) + `COMMON_ROUTE_DEFINITIONS` (`py/routes/model_route_registrar.py:23-149`) generate the full `/api/lm/{prefix}/*` surface (~50 endpoints) plus the `GET /{prefix}` page route.
- `PersistentModelCache` (`py/services/persistent_model_cache.py:526-606`) is a single `models` table keyed `(model_type, file_path)` with `model_type` as free text — **zero schema change**.
- Frontend `apiConfig.js` (`static/js/api/apiConfig.js:51`) generates all endpoints from the model-type string; `ModelCard.js:670-675` renders the sub_type badge from data; the checkpoints page already demonstrates the "one page, multiple sub_types" filter (`header.html:298`).
## 4. Backend Changes
### 4.1 New constants — `py/utils/constants.py`
```python
# folder_paths key -> sub_type; single source of truth for extensibility
OTHER_MODEL_FOLDER_SUBTYPES = {
"vae": "vae",
"upscale_models": "upscaler",
"text_encoders": "text_encoder",
"clip": "text_encoder", # legacy ComfyUI key
"clip_vision": "clip_vision",
"controlnet": "controlnet",
}
DEFAULT_OTHER_MODEL_FOLDERS = ("vae", "upscale_models", "text_encoders", "clip", "clip_vision")
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
# CivitAI model.type values accepted for this page (fetch-metadata validation)
VALID_OTHER_CIVITAI_TYPES = {"vae", "upscaler", "textencoder", "clipvision", "controlnet", "other"}
```
Also extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:90`) if user-model queries should include these types.
### 4.2 New files (mirror the embedding/checkpoint implementations)
1. **`py/utils/models.py`** — add `OtherModelMetadata(BaseModelMetadata)`: default `sub_type="vae"` placeholder overridden by scanner hook; `from_civitai_info` mapping CivitAI types → our sub_types (`TextEncoder`→`text_encoder`, `CLIPVision`→`clip_vision`, `Upscaler`→`upscaler`, `VAE`→`vae`, `Controlnet`→`controlnet`, else `other`-ish fallback to folder-derived sub_type).
2. **`py/services/other_scanner.py`** — `OtherScanner(ModelScanner)`:
- `model_type="other"`, extensions: reuse the checkpoint set (`safetensors/pt/pt2/bin/pth/pkl/sft/gguf`).
- `get_model_roots()`: iterate `OTHER_MODEL_FOLDER_SUBTYPES` ∩ enabled keys, pull each from `config` (§4.3); dedupe; build `root → sub_type` map (normalized abspaths; multiple keys may share a sub_type).
- Implement all three hooks like `CheckpointScanner` (`checkpoint_scanner.py:384-415`): `resolve_sub_type_for_path` by longest-prefix root match, `adjust_metadata`, `adjust_cached_entry` (sub_type is re-derived on cache load, never persisted).
- **Lazy hashing, checkpoint-style**: text encoders (T5-XXL ≈ 10 GB) make eager sha256 painful. Copy the `hash_status="pending"` + singleflight `calculate_hash_for_model` pattern from `CheckpointScanner`.
3. **`py/services/other_model_service.py`** — `OtherModelService(BaseModelService)`, `format_response` only (no usage_count, like `EmbeddingService`).
4. **`py/routes/other_routes.py`** — `OtherRoutes(BaseModelRoutes)`, `template_name="other.html"`, hooks:
- `_validate_civitai_model_type` → `VALID_OTHER_CIVITAI_TYPES`
- `_get_expected_model_types`, `_parse_specific_params` (no type-specific download params in Phase 1)
- `initialize_services()` on `app.on_startup` pulling `ServiceRegistry.get_other_scanner()`.
### 4.3 `py/config.py`
- New `other_roots` property: for each enabled key in `OTHER_MODEL_FOLDER_SUBTYPES`, `folder_paths.get_folder_paths(key)` (plugin mode) — standalone mode needs nothing new: `MockFolderPaths` (`standalone.py:66-105`) already serves arbitrary keys from `settings.json.folder_paths`.
- Follow the existing per-type recipe: an `_prepare_other_paths()` (dedupe + symlink registration; also **cross-scanner overlap detection** — warn if an `other` root is already covered by checkpoints/unet/embedding roots, mirroring the checkpoint/unet overlap check).
- Wire into: `_apply_library_paths`, `_symlink_roots()`, `_rebuild_preview_roots()` (hard requirement — preview images are served per registered root), `save_folder_paths_to_settings()`.
### 4.4 Existing-file edits (the "type string scatter" — each is a small branch/entry)
| file | change |
|---|---|
| `py/services/model_service_factory.py:120` | register `("other", OtherModelService, OtherRoutes)` in `register_default_model_types()` |
| `py/services/service_registry.py` | add `get_other_scanner()` (mirror `:297` `get_embedding_scanner`) |
| `py/services/model_scanner.py:67` | `PAGE_TYPE_MAP['other'] = 'other'` (WebSocket progress) |
| `py/services/base_model_service.py:896-906` | `get_model_types()` branch → `VALID_OTHER_SUB_TYPES` |
| `py/lora_manager.py` | `_initialize_services` scanner task list (`:219-242`), `_cleanup` cancel list (`:463`), `_cleanup_backup_files` roots (`:327-330`) |
| `py/routes/handlers/misc_handlers.py` | `scanner_getters` (`:657-661`) + `scanner_factories` (`:757-759`) so Doctor / init-status / refresh-all see the new scanner |
| `py/services/pending_delete_service.py` | `_PAGE_TYPE` map (`:57-61`) + scanner getter list (`:983-985`) |
| `py/metadata_ops/__init__.py:36-38` | `SCANNER_TYPE_MAP['other']` |
| `settings.json.example` | document optional `folder_paths` keys: `vae`, `upscale_models`, `text_encoders`, `clip_vision` |
**Explicitly NOT touched in Phase 1:** `py/services/download_manager.py`, `py/services/download_routing.py`, `py/services/settings_manager.py` default-root keys, `py/routes/stats_routes.py`, `py/utils/usage_stats.py`, `py/services/recipe_scanner.py`, `py/metadata_collector/`, `py/nodes/`.
**Zero-change confirmations (verified):** `PersistentModelCache`, `ModelUpdateService`, `DownloadedVersionHistoryService`, `MetadataSyncService` + provider chain (type-agnostic hash lookups), `ModelFileService` / `ModelMoveService` / `ModelLifecycleService` (scanner + model_type injected), `ModelCache` / `ModelHashIndex`, `AutoV3BackfillService`.
## 5. Frontend Changes
1. **`static/js/api/apiConfig.js`** — `MODEL_TYPES.OTHER = 'other'`; `MODEL_CONFIG.other` entry (displayName, singularName, `supportsMove`, `supportsBulkOperations`; no letter filter); endpoints come free from `getApiEndpoints()` (`:51`).
2. **`static/js/api/otherApi.js`** — thin `OtherApiClient extends BaseModelApiClient` (mirror `embeddingApi.js`); register in `modelApiFactory.js`.
3. **`static/js/other.js`** — page entry (mirror `embeddings.js`): `appCore.initialize()` + `createPageControls('other')` + `initializePageFeatures()` + `ModelDuplicatesManager` + `initActiveFiltersSync('other')`.
4. **Controls & context menu** — `OtherControls extends PageControls` and `OtherContextMenu` (start from the embedding variants — the smallest); add branches in the two factories (`components/controls/index.js:15`, `components/ContextMenu/index.js:15`). Context-menu template block lives in `templates/other.html` (`{% block additional_components %}`, the checkpoints/embeddings pattern — do NOT touch the shared `context_menu.html`).
5. **`templates/other.html`** — copy `embeddings.html`: same content blocks (controls + breadcrumb + duplicates banner + folder sidebar + `#modelGrid`), `data-page="other"`, main script `/loras_static/js/other.js`.
6. **`templates/components/header.html`** — nav entry (`:23-43`, active when `request.path.startswith('/other')`); enable the `modelTypes` sub_type filter panel for `other` (`:298-305` pattern from checkpoints); check search-options panel conditions (`:199-224`).
7. **`static/js/utils/constants.js`** — `MODEL_SUBTYPE_ABBREVIATIONS` (`:115`): `vae→VAE`, `upscaler→UPS`, `text_encoder→TE`, `clip_vision→CV`, `controlnet→CN`; matching `MODEL_SUBTYPE_DISPLAY_NAMES` (`:99`). (Unknown fallback already uppercases 4 chars, but explicit mappings read better.)
8. **`static/js/core.js:110` `getPageType()`** — verify `data-page="other"` flows through `state.pages` generically; add only if the page list is enumerated anywhere.
9. No change to `web/comfyui/top_menu_extension.js` (it opens `/loras`; page-to-page nav is the header bar).
## 6. i18n
- `locales/en.json`: add `other.title` (e.g. "Other Models") + minimal `other.contextMenu.*` / `other.modelTypes.*` keys; reuse `modelCard.*`, `loras.contextMenu.*`, `common.*` wherever possible (the established pattern — checkpoints/embeddings already reuse lora keys).
- Run `python scripts/sync_translation_keys.py`; leave `[TODO: Translate]` placeholders in other locales (per `docs/i18n-translation-guidelines.md` §7 — do not translate proactively).
## 7. Testing
Follow existing conventions (`pytest.ini`, `tests/frontend/` vitest):
1. **Backend (pytest, async where needed):**
- `OtherScanner` root aggregation + `resolve_sub_type_for_path` (file under `vae/` root → `vae`; `text_encoders` and legacy `clip` both → `text_encoder`; disabled `controlnet` root not scanned).
- Cache round-trip: sub_type re-derived via `adjust_cached_entry` (not persisted).
- Lazy hash: `hash_status="pending"` default; `calculate_hash_for_model` singleflight.
- `OtherRoutes` registration smoke test: `/api/lm/other/...` endpoints exist; `_validate_civitai_model_type` accepts `vae`/`upscaler`/`textencoder`, rejects `lora`.
- Config: `other_roots` in both modes (mock `folder_paths`, and standalone `settings.json.folder_paths`).
2. **Frontend (vitest + jsdom, `tests/frontend/`):**
- `apiConfig`: `getApiEndpoints('other')` URL shapes; `modelApiFactory` returns the Other client.
- `ModelCard` badge rendering for new sub_types.
- `createPageControls('other')` / `createPageContextMenu('other')` factories.
3. **Manual UI verification by the user** (per AGENTS.md — no sandbox/browser automation): page loads, scans a real library, sub_type filter + badges, context menu actions.
## 8. Execution Order
1. `constants.py` + `OtherModelMetadata` + `config.py` roots
2. `OtherScanner` (+ registry, factory, `PAGE_TYPE_MAP`) → scanner unit tests green
3. `OtherModelService` + `OtherRoutes` + handler/registrar wiring + `lora_manager.py` lifecycle → route tests green
4. Doctor/pending-delete/metadata-ops scatter entries
5. Template + header nav + frontend API/controls/context-menu/card badges → vitest green
6. i18n keys + sync script
7. `pytest` + `npm test` full runs; hand to user for manual UI check
## 9. Phase 2 Detailed Design — CivitAI Downloads for `other`
Designed 2026-09-12 against the Phase-1 code on this branch; decisions marked **[locked]** follow the same recommendations the feature owner approved for Phase 1.
### 9.1 Download pipeline touch points
Flow: `POST /api/lm/download-model` (`py/routes/model_route_registrar.py:104`; GET variant `:105` for the browser extension) → `ModelDownloadHandler.download_model` (`model_handlers.py:1740`) → `DownloadModelUseCase.execute` → `DownloadCoordinator.schedule_download` → `DownloadManager.download_from_civitai` (`download_manager.py:386`) → `_execute_original_download` (`:1415`). Inside, seven scatter points need an `other` branch:
1. **Type map** (`:1496-1507`): accept `model.type.lower() in VALID_OTHER_CIVITAI_TYPES` → `model_type = "other"` (reuses the Phase-1 set, incl. `"other"` itself).
2. **Early version-exists gate** (`:1436-1463`): add `other_scanner.check_model_version_exists`.
3. **File-level exists gate** (`:1640-1655` → `_find_local_file_entry` `:320-346` → `_get_scanner_for_model_type` `:230-236`): add explicit `other` branch. **Trap**: the function currently falls through to the lora scanner for unknown types — `"other"` would silently dedupe against loras. Also narrow the fall-through to `"lora"` only / raise on unknown.
4. **Version-level fallback gate** (`:1656-1688`): add `elif model_type == "other"`.
5. **Default-root selection** (`:1690-1727`): for `other`, first resolve sub_type (§9.2), then read `default_other_roots[sub_type]` (§9.3); if sub_type is undecidable or no default root configured → error guiding the user to pick a folder explicitly.
6. **Metadata class selection** (`:1909-1928`) + `_build_metadata_for_resume` (`:969-981`): add `OtherModelMetadata.from_civitai_info` branches.
7. **Post-download cache write** (`_execute_download_pipeline` `:2622-2679`): add `other` scanner branch; `adjust_metadata` re-derives sub_type from the on-disk root automatically. `_get_supported_extensions_for_type` (`:2720-2744`): `other` reuses the checkpoint extension set.
Hooks: `_record_downloaded_version_history` (model_type is free text — zero change); `_sync_downloaded_version` (`:1984` → scanner dispatch `:2130-2135`) add `other`; `py/utils/example_images_download_manager.py` scanner dispatch at `:411-421`, `:591-601`, `:1089+` — add `other` at all three (silent no-scanner otherwise).
Path templates: `get_download_path_template("other")` is unset, so `other` resolves to a **flat** layout (empty template) — downloads land directly under the resolved sub_type root. This is deliberate: other-model roots are already split per sub_type (`default_other_roots`), and `priority_tags` has no `other` entry, so `{first_tag}` would fall back to an arbitrary CivitAI tag and scatter files into unstable folders. Users who want nesting can still set `download_path_templates["other"]` in `settings.json`. See `DEFAULT_DOWNLOAD_PATH_TEMPLATES` (`py/utils/constants.py`) and `DEFAULT_PATH_TEMPLATES` (`static/js/utils/constants.js`).
### 9.2 File-level routing (model.type / file.type → sub_type) **[locked]**
Table-driven, mirroring Phase 1. New in `py/utils/constants.py`:
```python
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
"VAE": "vae", "Upscaler": "upscaler", "Text Encoder": "text_encoder",
"Vision Encoder": "clip_vision", "CLIPVision": "clip_vision",
"ControlNet": "controlnet",
}
```
`download_routing.py` gains `resolve_other_download_sub_type(civitai_model_type, file_types, selected_file_type=None)` with fixed priority:
1. **Explicit user file pick** (`file_params` from #1058's `_resolve_target_file`) — if the picked file's type maps, it wins even when model.type is `Checkpoint`.
2. **model.type** via the existing `CIVITAI_TYPE_TO_OTHER_SUB_TYPE` (`constants.py:120-127`).
3. **file.type fallback** — only when model.type maps to nothing (e.g. model.type `Other` or retired `CLIP`). MUST NOT override a mapped model.type: checkpoint models routinely bundle VAE/Text Encoder component files, and unconditional file-type routing would misroute them.
4. Still undecidable → `None`; `use_default_paths` errors and the UI offers all other roots for manual selection.
HTTP: extend `DownloadRoutingHandler.get_download_routing` (`download_routing_handlers.py:23`) with an `other` branch returning `{root_kind: "other", sub_type: ...}`; add `GET /api/lm/other/roots_by_subtype` in `OtherRoutes.setup_specific_routes` (data from `config._prepare_other_paths`'s per-key roots, aggregating `text_encoders` + legacy `clip` under `text_encoder`).
### 9.3 Settings: single dict key `default_other_roots` **[locked]**
Rejected: four flat keys (`default_vae_root`…) — each flat key costs ~13 touch points in `settings_manager.py` (defaults `:82-85`, `_check_and_auto_set` `:890-895`, `set()` `:1621-1628`, `_update_active_library_entry` `:738-805`, upsert/create signatures `:1953-2132`, `_build_library_payload` `:552-612`, `_sync_active_library_to_root` `:519-547`, three library constructors, frontend `DEFAULT_SETTINGS_BASE`), repeated per future sub_type.
Chosen: one mapping key `default_other_roots: {sub_type: path}`, copying the `extra_folder_paths` precedent (generic Mapping handling at `:533-535`, `:573-578`, `:763-767`). `_check_and_auto_set` generalizes to per-sub_type candidates (union over that sub_type's folder keys — `text_encoder` → `text_encoders` + `clip`). `set()` validates keys against `VALID_OTHER_SUB_TYPES`.
Also fix the Phase-1 omission: add `"other_scanner"` to `_notify_library_change` (`:2150-2156`) and `_notify_model_name_display_change` (`:1795-1800`) — otherwise switching libraries leaves the other page stale.
### 9.4 Settings UI
- `templates/components/modals/settings/library.html:34-40`: sub_type selectors after the existing four `setting_select`s (Jinja loop; controlnet selector only when `enabled_other_folders` includes it). Dict-subkey save helper `saveOtherRootSetting(subType, value)` alongside the flat `saveSelectSetting`.
- `static/js/managers/SettingsManager.js:1547-1697`: `loadOtherRoots()` mirroring `loadUnetRoots()`, fed by `/api/lm/other/roots_by_subtype`; current values from `state.global.settings.default_other_roots`. `state/index.js:24` `DEFAULT_SETTINGS_BASE` += `default_other_roots: {}`.
- Optional: one `other` row in the download-path-template block (`library.html:153-211`).
- i18n: `settings.folderSettings.*` keys into `locales/en.json` + sync script; other locales keep `[TODO: Translate]`.
- Settings GET (`misc_handlers.py:1528-1536`) already returns all non-sensitive keys — new key reaches the frontend for free.
### 9.5 Frontend download entry
- `templates/components/controls.html:83`: drop the `page_id != 'other'` exclusion on the download button (keyboard shortcut D self-enables via `PageControls.js:196-198`).
- `OtherControls.js:22-55`: add `showDownloadModal: () => downloadManager.showDownloadModal()` (mirror `EmbeddingsControls.js:43-45`).
- `DownloadManager.js` `proceedToLocationContent` (`:955-1017`): add `_resolveOtherSubType()` (mirror `_resolveIsDiffusionModel` `:1026`): selected file type → `/api/lm/download/routing` → `otherApiClient.fetchModelRoots(subType)` (new); default-root preselect reads `default_other_roots[subType]` instead of `` `default_${singularType}_root` `` (`:974`). Undecidable → list all other roots (`/api/lm/other/roots`) for manual pick; an explicit save_dir skips backend default-root logic, so the two paths cannot disagree.
- `ModelVersionsTab` download buttons are modelType-generic and already work via `getModelApiClient('other')`; context menu has no CivitAI download entry — no change.
- Version-list type validation (`get_civitai_versions` → `_validate_civitai_model_type`) already accepts `VALID_OTHER_CIVITAI_TYPES` from Phase 1.
### 9.6 CivitAI type mapping decisions **[locked]**
- Download accepts exactly `VALID_OTHER_CIVITAI_TYPES` (`VAE, Upscaler, TextEncoder, CLIP, CLIPVision, Controlnet, Other`) — reuse the Phase-1 tables; do NOT create new ones.
- Extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:133-137`) with the 7 aliases, and point them at the other scanner / `"other"` history bucket in `misc_handlers.py` (`type_scanner_map` `:2793-2797`, `downloaded_version_map` `:2821-2827`) — otherwise creator pages silently filter these models while downloads claim support.
- Fix (small Phase-1 bug): `OtherModelMetadata.from_civitai_info` (`py/utils/models.py:343`) reads `version_info.get("type")`, but the type lives at `version["model"]["type"]` — the mapping never fires and always degrades to the placeholder. Read `version_info.get("model", {}).get("type")` instead. (`CheckpointMetadata:290` has the same shape; leave it alone here.)
### 9.7 Tests
Existing base: `tests/services/test_download_manager_basic.py` (incl. `test_download_rejects_unsupported_model_type` `:1336`), `test_download_manager_error.py`, `test_download_manager_concurrent.py`, `tests/integration/test_download_flow.py`, `tests/services/test_settings_manager.py`; frontend `tests/frontend/managers/downloadManager.routing.test.js`, `settingsManager.library.test.js`.
Add: (1) `resolve_other_download_sub_type` unit tests — every priority tier, bundled-component anti-misrouting, undecidable → None, civarchive-shaped payload; (2) download_manager — six model.types accepted → other scanner (mock), unknown still rejected, no lora-scanner fall-through, per-sub_type default roots + unconfigured error, resume metadata, extension set; (3) settings_manager — `default_other_roots` defaults/auto-set (incl. text_encoder dual-key union)/library sync/upsert passthrough/illegal sub_type rejection; (4) routes — `/api/lm/download/routing` other branch, `roots_by_subtype` shape; (5) example-images dispatch accepts `other` (3 sites); (6) vitest — `_resolveOtherSubType` + root select + default preselect, `loadOtherRoots`; (7) user-models existsLocally for VAE.
### 9.8 Phase 2 file list
Backend: `py/utils/constants.py`, `py/services/download_routing.py`, `py/routes/handlers/download_routing_handlers.py`, `py/services/download_manager.py`, `py/utils/example_images_download_manager.py`, `py/services/settings_manager.py`, `py/utils/models.py`, `py/routes/other_routes.py`, `py/routes/handlers/misc_handlers.py`, `settings.json.example`.
Frontend/templates: `templates/components/controls.html`, `static/js/components/controls/OtherControls.js`, `static/js/managers/DownloadManager.js`, `static/js/api/otherApi.js`, `templates/components/modals/settings/library.html`, `static/js/managers/SettingsManager.js`, `static/js/state/index.js`, `locales/en.json` + sync.
## 10. Risks / Open Questions
- **Root overlap**: a user may point `text_encoders` at a directory already scanned as checkpoints/unet. Realpath dedup inside one scanner won't catch cross-scanner overlap → the `_prepare_other_paths` overlap warning (§4.3) is the mitigation; duplicate cards across pages are cosmetic, not corrupting (cache keyed by `(model_type, file_path)`).
- **Huge text encoders + lazy hash**: CivitAI fetch for a pending-hash model must trigger on-demand hash like checkpoints do — verify that flow (`calculate_hash_for_model`) is reachable from the `other` routes' fetch-metadata handler.
- **Retired CivitAI types**: `CLIP`/`CLIPVision` are retired upstream (grandfathered for existing models); metadata fetch must tolerate both retired and current types — `VALID_OTHER_CIVITAI_TYPES` includes them deliberately.
- **Standalone users** must add the new `folder_paths` keys to `settings.json` themselves; document in `settings.json.example` and the feature doc.
- **Page display name** is i18n-only; if "Other Models" tests poorly, rename `other.title` without code changes.
### Phase 2 risks
- **Bundled component files**: checkpoint models routinely ship VAE/Text Encoder component files — file.type routing must stay a fallback (or explicit user pick), never an override (§9.2 priority is load-bearing; test it).
- **`_get_scanner_for_model_type` lora fall-through** (`download_manager.py:236`): without an explicit `other` branch, dedupe checks run against the lora scanner — the most insidious trap in Phase 2.
- **text_encoder dual folder keys** (`text_encoders` + legacy `clip`): default-root candidates, `roots_by_subtype`, and auto-set must all merge both keys; miss one and the default-root dropdown comes up empty.
- **Undecidable sub_type** (model.type `Other` + unknown file types): must error and ask, never silently default to the vae folder.
- **Lazy hash after download**: downloads carry CivitAI SHA256 (no recompute needed) — ensure the post-download cache write doesn't leave `hash_status="pending"`, or the next metadata fetch re-hashes a 10 GB file.
- **CivArchive source**: same `_execute_original_download` path, same payload shape — cover it once in tests.
## 11. Phase 3 — Opt-in Management Toggles (implemented)
Designed 2026-09-13 against the Phase-1/2 code. Other Models is **opt-in**: after
Phase 3 the feature ships disabled, so no other-model folder is scanned and the
page shows an "enable" empty state until the user turns it on.
### 11.1 Settings (global, not per-library)
| key | type | default | meaning |
|---|---|---|---|
| `enable_other_models` | bool | `false` | master switch |
| `enabled_other_sub_types` | list[str] | `["vae","upscaler","text_encoder"]` | allow-list; `clip_vision` and `controlnet` are opt-in (see §2) |
`enabled_other_folders` (the unreleased, additive, no-UI backend key) was removed
and replaced by the sub_type-level allow-list; there is no migration because the
feature never shipped. `text_encoder` expands to `text_encoders` + legacy `clip`
via `OTHER_SUB_TYPE_FOLDER_KEYS`.
The default allow-list lives on five surfaces that must stay in sync:
`DEFAULT_ENABLED_OTHER_SUB_TYPES` (`py/utils/constants.py`), `DEFAULT_SETTINGS`
(`py/services/settings_manager.py`), the two `DEFAULT_SETTINGS_BASE` /
`createDefaultSettings` lists (`static/js/state/index.js`), the
`updateOtherModelsControls()` fallback (`static/js/managers/SettingsManager.js`)
and the server-rendered Jinja fallback
(`templates/components/modals/settings/library.html`).
### 11.1.1 Legacy key handling in `Config._init_other_paths`
ComfyUI's `folder_paths` rewrites legacy names before every access (`clip` →
`text_encoders`, `unet` → `diffusion_models`) and registers both legacy
directories under the canonical key, so `get_folder_paths("clip")` returns
exactly the same list as `get_folder_paths("text_encoders")`. Querying both keys
made the overlap guard fire twice with `please fix your path configuration` for a
configuration the user cannot fix. `Config._collapse_legacy_folder_keys()` now
drops a key when the host exposes `map_legacy` and resolves it to another queried
key, and `_prepare_other_paths()` downgrades a same-`sub_type` duplicate to
`debug` (a cross-`sub_type` collision still warns). In standalone mode
`MockFolderPaths` has no `map_legacy` and its keys are independent
`settings.json` entries, so every key is still queried there.
`settings.json.example` intentionally stays minimal (only `use_portable_settings`,
`civitai_api_key`, and the four core `folder_paths` keys: `loras`, `checkpoints`,
`unet`, `embeddings`). Optional keys — including the other-model folder paths and
`enable_other_models` — are NOT documented there; they live in `DEFAULT_SETTINGS`
and reach the user's `settings.json` on demand. This supersedes the Phase-1/Phase-2
notes that proposed adding the other-model folder keys to the example.
### 11.2 Behaviour matrix
| state | scan | nav / `/other` | other downloads | `default_other_roots` | Doctor / refresh-all |
|---|---|---|---|---|---|
| master off | nothing (`other_roots == []`) | nav entry hidden (`nav-item--hidden`); `/other` still renders the disabled empty state + Enable button; one-time dismissible announcement banner on first visit | rejected | preserved, never auto-set | scanner skipped |
| sub_type off | that sub_type's folder keys excluded | page keeps working, type disappears from data | auto-routing refused (manual folder still allowed) | preserved, not preselected | normal |
| all on (after enabling) | Phase-1/2 behaviour | normal | normal | normal | normal |
### 11.3 Backend touch points
- `py/utils/constants.py` — `DEFAULT_ENABLED_OTHER_SUB_TYPES`, `OTHER_SUB_TYPE_FOLDER_KEYS`, `normalize_other_sub_types`.
- `py/config.py` — `_get_enabled_other_folder_keys()` is the single scan gate (master switch + allow-list); new `refresh_other_roots()` rebuilds roots + preview roots on toggle.
- `py/services/settings_manager.py` — new defaults, `set()` normalization, `is_other_models_enabled()` / `get_enabled_other_sub_types()` / `is_other_sub_type_enabled()`, and `_apply_other_model_settings_change()` which reapplies config and calls `other_scanner.on_library_changed(reconcile=True)`.
- `py/services/model_scanner.py` — `_should_keep_cached_entry()` hydration hook (default keep) plus `on_library_changed(reconcile=...)` / `initialize_in_background(reconcile=...)`; the hook filters `raw_data` and the hash/autov3 index rows.
- `py/services/other_scanner.py` — drops persisted entries whose folder is no longer a managed root (sub_type is location-derived, so config is the source of truth).
- `py/routes/other_routes.py` — `_validate_civitai_model_type` rejects everything while off / mapped-but-disabled sub_types; `_get_page_context_provider()` injects `other_disabled` into the template.
- `py/routes/handlers/model_handlers.py` + `base_model_routes.py` — optional `page_context_provider` hook on `ModelPageView`.
- `py/routes/handlers/download_routing_handlers.py` — returns `{sub_type: None, disabled: true, reason}` instead of guessing.
- `py/services/download_manager.py` — rejects other-type downloads while off; disabled sub_type refuses default-path routing with a "pick a folder" error.
- `py/routes/handlers/misc_handlers.py` — Doctor / init-status / refresh-all skip the other scanner while off (`_active_scanner_factories` / `_active_scanner_getters`).
- `py/services/pending_delete_service.py` — deliberately untouched: the scanner stays registered so staged deletes still merge.
### 11.4 Frontend
Discoverability: the nav entry is hidden while the feature is off, and three
lightweight surfaces replace it — a one-time announcement banner, the download
toast, and the settings toggle itself.
- `templates/components/header.html` + `static/css/components/header.css` — `nav-item--hidden` class (server-rendered when off, client-toggled after enabling) and the `fa-shapes` icon.
- `templates/other.html` — `other_disabled` branch in `content` + `main_script`; page-scoped CSS for the empty state.
- `static/js/other_disabled.js` — boots `appCore` (shared header) and delegates to the shared enable helper.
- `static/js/utils/otherModels.js` — shared `enableOtherModels()` (POST settings + reload) and `openOtherModelsSettings()` (settings modal on the Library section); used by the disabled page, the banner and the download modal.
- `static/js/managers/BannerService.js` — `other-models-announcement` banner (only when off and not dismissed; `priority: 0`, dismissal persisted via `dismissed_banners`) with Enable / Open Settings actions; `removeOtherModelsAnnouncement()` drops it without persisting a dismissal.
- `templates/components/modals/settings/library.html` + `SettingsManager.updateOtherModelsControls()` / `saveEnabledOtherSubTypes()` / `updateOtherModelsNavVisibility()` — master toggle + five sub_type checkboxes; unchecked/disabled sub_types have their default-root select disabled.
- `static/js/managers/DownloadManager.js` — a disabled routing answer surfaces a `showActionToast` with an "Enable Other Models" action (opening settings) and falls back to manual selection.
- i18n: `settings.folderSettings.*`, `other.disabled.*` and `banners.otherModels.*` keys in `locales/en.json` + `scripts/sync_translation_keys.py` (other locales keep `[TODO: Translate]`).
### 11.5 Cache consistency
- Disabling purges rows from the in-memory view at hydration time (the
`_should_keep_cached_entry` hook) and from SQLite on the reconcile triggered by
the toggle; the `.metadata.json` sidecars survive, so re-enabling rescans
without recomputing hashes (critical for multi-GB text encoders).
- Enabling triggers a reconcile so newly managed roots are scanned immediately.
- Editing `settings.json` while the server is stopped is still covered by the
hydration hook, so disabled types never appear after a restart.
### 11.6 Tests
Backend: opt-in fixtures added to the other-related suites; new coverage for
"default off scans nothing", per-sub_type gating, routing/download rejection,
`_should_keep_cached_entry`, settings normalization and `other_disabled` page
context. Frontend: `updateOtherModelsControls` / `saveEnabledOtherSubTypes` and
the disabled-page enable flow.
+914
View File
@@ -0,0 +1,914 @@
# Plan: Buzz Price Tracking and Threshold Alerts for Paid / Early Access Versions
**Origin:** FR "Price tracker for models/loras" (Geekier, Discord) — track the buzz price of paid
model versions and flag when it drops below a threshold or becomes free.
**Related:** [#1060 — Some "Early Access" models are not identified correctly](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1060)
(closed) established `is_paid`, the Paid badge and `hide_paid_updates`; this FR is the next step
after gate *state* — gate *price*.
**Status:** v3 — **P0–P3 implemented** (§10 records what shipped and the deviations); **P5 (alerts
panel) planned** in §11, not implemented. The grid-level "price alert only" filter was dropped by
owner decision, so §11 defines the panel as the only new browsing surface.
Feasibility was verified against this repo and upstream CivitAI `main` (`6d29ed1368`), including live
probes against `civitai.com` / `civitai.red`.
**Scope:** every model type that goes through `ModelUpdateService` (lora / checkpoint / embedding /
other). Out of scope: purchasing, downloading gated content, and any change that depends on a future
CivitAI API addition.
---
## 1. Problem statement
Creators increasingly gate model versions behind buzz. LoRA Manager already knows **whether** a
version is gated (`is_paid`, `is_early_access`, `paid_access`) but not **how much it costs**, so it
cannot answer the two questions the FR asks:
1. "Tell me when this model's price drops below X buzz."
2. "Tell me when it becomes free."
Additionally, the current code only compares version ids when deciding `has_update`, so a version
that a user already tracks can go from paid to free (or gain a gate) without producing any signal at
all.
## 2. Verified current state
### 2.1 LoRA Manager (all references confirmed in this checkout)
| Fact | Reference |
| --- | --- |
| Gate fields persisted per version: `is_paid`, `is_early_access`, `paid_access` (raw DTO JSON), `early_access_ends_at` | `py/services/model_update_service.py:65-84` |
| Gate data comes from the bulk `GET /api/v1/models?ids=…` response (`availability`, `paidAccess`, `earlyAccessEndsAt`) | `py/services/civitai_client.py:329-380`, parsed at `py/services/model_update_service.py:1756-1793` |
| **No numeric price anywhere in `py/`** | `grep -rn "price" py/` → only license/`allowCommercialUse` hits |
| Update refresh is user-triggered, not scheduled; TTL 24 h | route `py/routes/handlers/model_handlers.py:2899`; `ModelUpdateService.__init__` default `ttl_seconds=24*60*60` at `py/services/model_update_service.py:338` |
| `has_update` compares version ids + gate filters only; no state-transition detection | `py/services/model_update_service.py:117-160` |
| `{permanent:false, endsAt:null}` is deliberately treated as **not** a gate on the download path | `py/services/download_manager.py:2000-2003` |
| Version payload sent to the frontend already carries `isPaid` / `paidAccess` | `py/routes/handlers/model_handlers.py:3444-3455` |
| UI: Paid / Early Access badges, `hide_paid_updates` filter | `static/js/components/shared/ModelVersionsTab.js:169-196, 474-545`; `static/js/state/index.js:58-59`; `templates/components/modals/settings/library.html:191-192` |
### 2.2 Upstream CivitAI — what is and is not public
| Fact | Evidence |
| --- | --- |
| Public v1 API intentionally strips pricing: `paidAccess` is reduced to `{permanent, endsAt}`. Source comment: *"Omits terms (pricing belongs to the purchase flow)"* | `~/code/civitai/src/server/services/paid-access.service.ts:640-652`; verified live: `/api/v1/models` and `/api/v1/model-versions/{id}` return exactly that shape |
| The internal tRPC route is **not** usable anonymously. `isAcceptableOrigin` rejects non-site origins with `401 "Please use the public API instead"` | `~/code/civitai/src/server/trpc.ts:120-128`; `acceptableOrigin` = `!isProd \|\| isBearerAuth \|\| isAllowedOriginRequest(req)` (`createContext.ts:43`), and the origin check is header-based (`src/server/utils/origin-helpers.ts:27-32`). **Live probe warning:** an anonymous tRPC GET can return `200` when Cloudflare serves a cached copy (`cf-cache-status: HIT`); uncached it is `401`. Do not build on this path. |
| **The price *is* publicly reachable inside the public model page.** The SSR payload embeds the site's own `model.getById` result, including full `terms` | `https://civitai.com/models/<id>` and `https://civitai.red/models/<id>` → `<script id="__NEXT_DATA__" type="application/json">` → `props.pageProps.trpcState.json.queries[*]` where `queryKey[0] == ["model","getById"]` → `state.data.modelVersions[*].paidAccess` |
| Observed payloads (anonymous, no cookies) | version 3379626 → `{terms:{download:{price:5000},generation:{price:100,trialLimit:5}}, endsAt:null, sale:null}`; version 3380114 → `download.price 125` `acceptsBlueBuzz:true` `endsAt:2026-10-10`; another → `200` |
| Per-generation licensing fees are already public and official | `GET /api/v1/model-versions/mini/{id}` (`MixedAuthEndpoint`, anonymous 200) exposes `fees` / `freeTrialLimit` — `~/code/civitai/src/pages/api/v1/model-versions/mini/[id].ts:455` |
| Webhooks cannot carry price changes | `updated-model` is driven by `Model.lastVersionAt` (fires only on new versions) and its select has no `paidAccess`: `~/code/civitai/src/server/webhooks/model.webooks.ts:88-96`, `~/code/civitai/src/server/selectors/model.selector.ts:15-36` |
| Prevalence (sampled newest 600 models / 4634 versions via public API cursor): **90 gated (1.9%), 87 permanent vs 3 timed** | live scan, 2026-xx; dominated by permanent gates, so "EA expiry" alone covers ~3% of gated versions |
### 2.3 Consequences for the design
* Threshold alerts on download price require **one new data source**: the model page payload. It is
public, but it is page data — so it must be isolated, opt-in, cheap, and fail-open.
* Request budget is small because only gated models need it: ~2% of models, one extra ~200 KB
request per gated model per price TTL.
* "Becomes free" and "gained a gate" need no new data source at all and should ship first.
* One page fetch returns **all** versions of a model, so prices cost one request per model, not per
version.
## 3. Data source decision
**Chosen: `__NEXT_DATA__` of the public model page**, fetched with the existing HTTP stack.
Rejected alternatives:
* *Internal tRPC* — origin-gated; passing it means forging `Origin` or relying on bearer auth against
an undocumented endpoint whose own error message says to use the public API. Not worth the
stability/compliance risk for a convenience feature.
* *Official v1 API as-is* — no price field, by design. Track it as an upstream request (§8), not as a
dependency.
* *`mini/{id}` `fees`* — useful extra signal for per-generation fees, but it is not the download
price and does not cover the gate.
## 4. Design
### 4.1 Schema (all additive, in the existing update DB)
`model_update_versions` gains:
```sql
ALTER TABLE model_update_versions ADD COLUMN price_buzz INTEGER; -- effective (sale-adjusted) download price
ALTER TABLE model_update_versions ADD COLUMN list_price_buzz INTEGER; -- undiscounted download price
ALTER TABLE model_update_versions ADD COLUMN generation_price_buzz INTEGER;
ALTER TABLE model_update_versions ADD COLUMN accepts_blue_buzz INTEGER NOT NULL DEFAULT 0;
ALTER TABLE model_update_versions ADD COLUMN price_sale_ends_at TEXT;
ALTER TABLE model_update_versions ADD COLUMN price_checked_at REAL; -- last *successful* price fetch
ALTER TABLE model_update_versions ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0; -- last computed alert hit, for edge detection
```
Adding a column to this table requires touching **all eight** enumerations (missing one silently
drops data or breaks reads):
1. dataclass `ModelVersionRecord` — `py/services/model_update_service.py:65-84`
2. `_SCHEMA` `CREATE TABLE` — `:300-330`
3. `_apply_migrations` column dict — `:554-580`
4. `_migrate_model_update_versions_primary_key` `target_columns` + `defaults` — `:680-745` (**easy to
miss; omitting it loses the new columns whenever the PK migration runs**)
5. `_build_record_from_remote` — `:1630-1665`
6. `_extract_single_version` — `:1740-1795`
7. `_get_records_bulk` SELECT + row→record mapping — `:1945-1990`
8. `_upsert_record` INSERT — `:2028-2066`
`price_checked_at` lives on the version row (not the model status row) so a stale price on one
version does not force a re-fetch for its siblings; the fetch itself is per model.
### 4.2 Parser contract
New pure function, unit-testable without network:
```
parse_model_page_prices(html: str) -> dict[int, dict] | None
```
* Locate `<script id="__NEXT_DATA__" type="application/json">…</script>`; `json.loads` it.
* Walk `props.pageProps.trpcState.json.queries[*]`; pick the entry whose `queryKey[0] == ["model","getById"]`
(the query order is not stable — 7 queries were observed, `getById` first only incidentally).
* From `state.data.modelVersions[*]`, per version emit:
* `price_buzz` = `paidAccess.sale.buyerTerms.download.price` if `sale` present, else
`paidAccess.terms.download.price`
* `list_price_buzz` = `paidAccess.terms.download.price`
* `generation_price_buzz` = `terms.generation.price` (undefined for `{free:true}` / bundled)
* `accepts_blue_buzz`, `price_sale_ends_at`
* no `paidAccess` → the version is ungated: emit an explicit "no price" marker so the caller can
clear stale prices and raise a *became free* event.
* Return `None` (never raise) for: missing script tag, JSON error, shape mismatch, empty
`modelVersions`, or a Cloudflare/challenge page.
### 4.3 Refresh flow and request budget
Hook: `_refresh_single_model` (`py/services/model_update_service.py:1099-1215`), inside the existing
"lock released during network I/O" window, after `fetched_versions` is built:
```
prices_needed = feature_enabled and (any gated version or any version with a stored price)
if prices_needed:
prices = await provider.get_model_prices(model_id) # never raises; None on failure
if prices is not None:
fetched_versions = [apply_price(v, prices.get(v.version_id)) for v in fetched_versions]
# price_checked_at advances only for versions actually present in the payload
```
* Candidate selection uses the gate data already in hand from the bulk API, so no extra call is made
for the ~98% of models with no gate (except models that previously had a price and must confirm
"now free").
* TTL: `price_check_ttl_hours` (default 24 h), independent of the update TTL — a price refresh can be
skipped while the version list is fresh, and vice versa. Force refresh (`force_refresh=True`) also
forces prices.
* Failure behaviour: keep the previous price, do **not** advance `price_checked_at` (so the next
refresh retries), log at debug/info. A `RateLimitError` from the shared gate must remain a skip,
not a hard failure of the update check. After N consecutive parse failures within one refresh,
stop attempting price fetches for the remainder of that run.
* Offline/cooldown: reuse the existing `ConnectivityGuard` path in `downloader.make_request` — no new
connectivity handling.
Client/provider seam:
* `ModelMetadataProvider.get_model_prices(model_id)` — default returns `None` (not abstract, so
CivArchive / SQLite providers are untouched).
* `CivitaiModelMetadataProvider` → `CivitaiClient.get_model_prices(model_id)`.
* Add the pass-through to the composite provider wrapper
(`py/services/model_metadata_provider.py:855-955`) so the rate-limit helper applies.
* `CivitaiClient.get_model_prices`: build the URL with the existing helper
`build_civitai_model_page_url(model_id, host=self._settings.get("civitai_host"))`
(`py/utils/civitai_utils.py:44-66`; `civitai.red` is already a supported page host,
`:10`), fetch with `downloader.make_request("GET", url, use_auth=False, custom_headers={"Accept": "text/html"})`
(returns `str` because the body is not JSON), then call the parser. Cap the body size before
parsing.
### 4.4 Alert semantics
* **Threshold hit**: `price_buzz is not None and price_buzz <= threshold_buzz`. `threshold_buzz = 0`
means "free only, plus any gate removal".
* **Became free**: a version with a stored gate/price now has no `paidAccess` (from the bulk API
and/or the page payload).
* **Newly gated**: a version that had none now has `paidAccess`.
* Edge detection uses `price_alert_state` plus the previous `paid_access` JSON, compared in
`_build_record_from_remote` where `existing_map` is already built
(`py/services/model_update_service.py:1636-1665`). Do not put this in `has_update`: it is a
different question, and `has_update` is filtered by the hide-* settings.
* `ModelUpdateRecord` gains a **non-persisted** field (e.g. `events: list | None = None`) carrying
`{version_id, kind: "price_drop"|"became_free"|"new_gate", price_buzz, previous_price_buzz}`.
`_upsert_record` ignores it; `_get_record` leaves it `None`.
### 4.5 Settings and routes
New settings (defaults in `DEFAULT_SETTINGS`, `py/services/settings_manager.py:68`;
`settings.json.example` stays minimal per repo policy; frontend defaults in
`static/js/state/index.js:58`):
| Key | Default | Meaning |
| --- | --- | --- |
| `price_tracking_enabled` | `False` | master switch; off means no model-page fetches at all |
| `price_alert_threshold_buzz` | `0` | alert when effective price ≤ this; 0 = free only |
| `price_check_ttl_hours` | `24` | price freshness window |
Routes (all under the existing model route registrars, `POST` + `GET` because the companion
extension is GET-only per `AGENTS.md`):
* `GET /api/lm/models/price-alerts` — aggregate over the update DB:
`{modelId, modelType, versionId, versionName, priceBuzz, listPriceBuzz, thresholdBuzz, isFree}`.
* Prices themselves need no new read route: they ride along in the existing version payload
(`py/routes/handlers/model_handlers.py:3438-3455` gains `priceBuzz`, `listPriceBuzz`,
`generationPriceBuzz`, `acceptsBlueBuzz`, `priceCheckedAt`, `priceAlert`).
* The existing update-refresh response gains `events` (from §4.4) so the frontend can toast.
### 4.6 UI
* `static/js/components/shared/ModelVersionsTab.js`
* Extend the existing Paid / Early Access badge tooltips with the price when known
("Paid · 5,000 Buzz") — helpers at `:169-196`, badges at `:474-545`.
* New badges: price chip (`paid`/`info` styling) and "Free now" for a became-free transition.
* Filter toggle reusing the `hide_paid_updates` pattern (`:349-400`, plus the settings modal and
`SettingsManager.js:1157-1163`) — e.g. "Show price drops only".
* Optional, cheap win: include the price in the paid-download error text
(`py/services/download_manager.py:1987-2031`, `py/services/use_cases/download_model_use_case.py:35`).
* Strings: add to `locales/en.json`, then `python scripts/sync_translation_keys.py`, then **stop** —
do not translate the other locales (see `docs/i18n-translation-guidelines.md` §7).
## 5. Phased tasks
### P0 — settle gate semantics (prerequisite, small)
- [ ] Decide the meaning of `{permanent:false, endsAt:null}`. The repo currently treats it as *no
gate* (`py/services/download_manager.py:2000-2003`), while upstream `isPaidAccessActive`
(`endsAt == null || endsAt > now`) treats it as *active*. Live sample: version 3372655
currently returns exactly this DTO from `/api/v1/models`.
- [ ] Introduce one shared helper (e.g. `_has_active_gate(paid_access, early_access_ends_at)` in
`py/services/model_update_service.py`) and use it from both the update service and the download
gate, so alerts and download blocking cannot disagree.
- [ ] Unit test for the chosen rule.
- **Why first:** every alert kind inherits the gate-state decision; getting it wrong produces false
"free" alerts.
### P1 — gate-state change detection (no new data source)
- [ ] Emit `new_gate` / `became_free` events from `_build_record_from_remote`; add the non-persisted
`events` field; surface in the refresh response.
- [ ] Keep `has_update` semantics unchanged (regression risk to `hide_paid_updates`,
`hide_early_access_updates`).
- [ ] UI: "Free now" badge + one batched toast per refresh; extend paid/EA tooltips.
- [ ] Tests in `tests/services/test_model_update_service.py` for each transition and for the
no-change case.
- **Acceptance:** a version whose gate lapses is visibly marked free after a refresh, with no new
network calls; existing hide-* filters behave identically.
### P2 — price capture (opt-in)
- [ ] Schema + all eight column touchpoints from §4.1, with a migration test.
- [ ] `parse_model_page_prices` + fixture-based tests (`tests/services/fixtures/…`, one trimmed page
with a timed gate, one with a permanent gate, one malformed/missing payload).
- [ ] `CivitaiClient.get_model_prices` + provider methods + composite pass-through; assert redirect
following and that a non-JSON body is returned as text.
- [ ] Wire into `_refresh_single_model` behind `price_tracking_enabled`, with the failure matrix from
§4.3.
- [ ] Tests: feature off ⇒ zero extra calls; fetch failure ⇒ previous price retained and
`price_checked_at` unchanged; gated-only candidate selection.
- **Acceptance:** with tracking enabled, a refresh on a gated model stores `price_buzz` for all of
its versions from one request; with tracking disabled or offline, refresh behaves exactly as today.
### P3 — threshold alerts and surface
- [ ] Settings + defaults (§4.5) and the settings-modal toggles/inputs.
- [ ] Threshold evaluation + `price_alert_state` edges → `price_drop` events.
- [ ] `GET /api/lm/models/price-alerts` aggregate endpoint.
- [ ] Version payload gains the price fields; UI price chip, "Free now" badge, "price drops only"
filter; optional price in the paid-download error.
- [ ] Locale keys + `scripts/sync_translation_keys.py`.
- [ ] Tests: threshold boundary (`<=` vs `<`), Blue Buzz labelling, sale-adjusted vs list price,
alerts for a version already in the library.
- **Acceptance:** a user can set "alert me under 500 buzz", refresh, and see the models that
qualify; nothing alerts when the price rises above the threshold again (state resets).
### P4 — docs and upstream
- [ ] Short feature note in the repo docs; keep this plan's data-source rationale in a code comment
(public page read, no auth or origin spoofing, no internal API).
- [x] Draft the upstream request: add a price field to the public `paidAccess` DTO (§13). Point out the
asymmetry — writes already go through the official v1 endpoint
(`~/code/civitai/src/pages/api/v1/model-versions/early-access.ts`, sharing
`updateModelVersionPaidAccessSchema`) while reads withhold it deliberately. If it lands, the
page scraper becomes an optional fallback rather than the only path.
## 6. Test plan summary
* `pytest tests/services/test_model_update_service.py` — migrations, extraction, transitions,
threshold logic, fetch-failure matrix, feature-off path.
* New parser tests with HTML fixtures (no network in tests).
* `npm run test:js` for badge/helper changes in `ModelVersionsTab`.
* Manual UI verification by the user (per repo policy — no browser automation for layout).
* No live CivitAI calls in the test suite.
## 7. Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Page payload format changes (Next.js is migrating away from `__NEXT_DATA__` to the flight payload) | Parser is a single pure function behind a provider method; fixture test fails loudly at build time; parse failure is non-fatal and keeps the last known price |
| Cloudflare / UA sensitivity (verified: `civitai.com`, `civitai.red` and even no-UA all returned the payload for the sampled pages) | Reuse `downloader` (proxy, rate-limit coordinator, connectivity guard); detect challenge/absence and back off; never retry in a tight loop |
| Traffic/size (~200-270 KB per model) | Gated models only (~2%), one request per model, `price_check_ttl_hours`, force-refresh only on user action |
| False "free" alerts from gate-semantics ambiguity | P0 helper + explicit tests before any alert ships |
| Misleading price (list vs sale-adjusted; Blue Buzz is the same number but non-withdrawable) | Store both list and effective price; tooltip states the unit and Blue Buzz acceptance |
| Alerts are pull-only (no scheduler, no push channel today) | State it in the UI copy; treat background push as a separate follow-up, not a silent gap |
## 8. Out of scope / follow-ups
* Purchasing or downloading gated content.
* Generation-fee tracking beyond displaying `generation_price_buzz` (the public `mini/{id}` `fees`
field could feed a richer view later).
* A background/notification channel; today alerts appear when the user refreshes or opens the panel.
* Waiting on CivitAI to expose prices; P1–P3 stand alone.
## 9. Open questions
1. `{permanent:false, endsAt:null}` — gate or not? (P0; affects download blocking too.)
2. Should the threshold apply to versions already in the library (watching for a future re-buy) or
only to versions the user does not own?
3. Is a per-model threshold override needed in v1, or is one global threshold enough?
---
## 10. What shipped (P0–P3)
### 10.1 Answers to the open questions
1. **`{permanent:false, endsAt:null}` is a gate.** Decided by evidence, not preference: the public v1
API returns `null` for lapsed gates (`toPublicPaidAccessDto` filters on `isPaidAccessActive`), so a
non-null DTO is always an *active* gate; on the live model 1802980 that version reports
`canDownload: false` while its lapsed-tombstone siblings report `true`. The previous rule dropped
it, which is exactly the #1060 class of bug.
2. The global threshold applies to **every** gated version the update service tracks, in library or
not — the user may be watching a version they intend to buy later.
3. One global threshold in v1; a per-model override is still open.
### 10.2 Files and seams
| Area | Where |
| --- | --- |
| Shared gate semantics (`normalize_paid_access`, `is_gate_active`, `is_early_access_deadline_active`) | `py/utils/paid_access.py` (new) |
| Model page price parser (`parse_model_page_prices`) | `py/utils/civitai_page_prices.py` (new) |
| Price fetch | `CivitaiClient.get_model_prices` (`py/services/civitai_client.py`) |
| Provider seam | `ModelMetadataProvider.get_model_prices` default None; CivitAI impl; fallback + rate-limit wrappers (`py/services/model_metadata_provider.py`) |
| Columns, migration, transition + threshold logic, alerts query | `py/services/model_update_service.py` |
| Version payload, refresh `events`, `GET /api/lm/{type}/updates/price-alerts` | `py/routes/handlers/model_handlers.py`, `py/routes/model_route_registrar.py` |
| Badges, price chip, gate/price toast | `static/js/components/shared/ModelVersionsTab.js`, `static/js/utils/updateCheckHelpers.js`, `static/css/components/lora-modal/versions.css` |
| Settings | defaults in `py/services/settings_manager.py`; UI in `templates/components/modals/settings/library.html` + `static/js/managers/SettingsManager.js`; client defaults in `static/js/state/index.js` |
| API client | `getPriceAlerts` in `static/js/api/baseModelApi.js` + `priceAlerts` endpoint in `static/js/api/apiConfig.js` |
### 10.3 Deviations from the plan
* **`gate_lapsed_at` column added** (8th column): a transient event would leave a "became free"
marker invisible minutes later, so the lapse timestamp is persisted and the UI shows a `Free Now`
badge with it.
* **Alerts are edge-triggered, and first sight is silent.** When a price first appears under the
threshold, `price_alert_state` is stored and the version shows up in `price-alerts` immediately,
but no toast fires — otherwise switching the feature on would announce every cheap version in the
library at once. The toast fires when a price *crosses* down through the threshold.
* **No dedicated alerts panel.** The surfaces are: price chip + `Free Now` badge in the versions tab,
one combined toast per update check, and the `price-alerts` endpoint (GET, so the companion
extension can use it). A browse surface over the endpoint is a follow-up.
* **Prices are only fetched for models with a gate** (~2% of a typical library), one page request per
model, on `price_check_ttl_hours`, and immediately when the gate DTO changes.
* **The paid-download warning message does not include the price**: that path reads the model's
CivitAI metadata, which has no price, and looking one up would add a request to a failure path.
Left as a follow-up.
### 10.4 Test coverage added
* `tests/utils/test_paid_access.py` — gate shapes, activity, timestamps.
* `tests/utils/test_civitai_page_prices.py` + `tests/utils/fixtures/civitai_model_page_paid.html` —
parser, including sale-adjusted vs list price and lapsed tombstones.
* `tests/services/test_model_update_service.py` — migration from a pre-feature DB, column round-trip,
transitions, price capture on/off, failure retention, TTL, threshold edges, alerts query.
* `tests/services/test_civitai_client.py` — page fetch happy path, unusable payloads, rate limits.
* `tests/routes/test_model_update_handler.py` — alerts endpoint, gate events for non-updating records.
---
## 11. P5 — Price alerts panel
**Goal:** a single surface that answers "what got cheaper / became free, and can I act on it".
**Status:** **P5a implemented** (see §11.9); P5b (inline threshold editing, *Refresh prices*, type
chips, ignore action) and P5c (polish) are not.
**Naming:** called P4a–c in earlier discussion; renumbered to **P5** because §5 already uses P4 for
"docs and upstream".
**Owner decision:** the grid-level "price alert only" filter is **out of scope**. The panel is the
only new browsing surface; the existing per-version badges and the update-check toast stay as they
are. Consequence: the panel carries the "act on it" affordances itself (§11.2), and discoverability
rests on the bell badge plus the controls-dropdown entry (§11.1 D8).
### 11.1 Locked decisions
| # | Decision | Why / evidence |
| --- | --- | --- |
| D1 | Host it as a **third tab in the existing notification bell modal**, driven by `UpdateService` | `templates/components/modals.html` is included by `templates/base.html:75`, so the bell exists on every page; `UpdateService` already has `toggleUpdateModal()`, `switchNotificationTab()`, tab badges, arrow-key tab navigation and a list/empty-state pattern (`renderRecentBanners`) |
| D2 | One **global** endpoint `GET /api/lm/price-alerts`, registered **once** in `MiscRoutes` | The update DB is one file per library shared by all model types (`cache/model_update/<library>.sqlite`), and `ServiceRegistry.get_model_update_service()` returns one shared instance (`py/services/service_registry.py:153`). `get_price_alerts` only filters on `s.model_type`, so `model_type=None` is all types in one query. Adding it to `COMMON_ROUTE_DEFINITIONS` would bind the same path once per model type (4×) |
| D3 | **Compare the threshold at read time** (`WHERE v.price_buzz <= ?`); keep `price_alert_state` for the toast edge only | As shipped, panel membership only changes after a refresh, so editing the threshold in the panel would look broken |
| D4 | New column `price_alert_since REAL` | `price_alert_state` is a boolean; "dropped 3 days ago" and an unread count both need the moment the state flipped, which the existing edge detection already knows |
| D5 | Unread state is **client-side** (`localStorage` watermark compared against `price_alert_since`) | No per-user read-state table and no sync logic; the worst case is a conservative badge on a second browser |
| D6 | Row actions: **CivitAI** always; **Open** only when the payload resolved a local file path | `showModelModal(model, modelType)` needs a local metadata object and re-fetches by `file_path` (`static/js/components/shared/ModelModal.js:343-361`); a gated version the user does not own has no local file |
| D7 | **Refresh prices** must bypass the price TTL | Prices stay fresh for `price_check_ttl_hours` (24 h by default), so without a force flag the button would appear to do nothing |
| D8 | **Two non-permanent entry points**, both opening the bell on the Price alerts tab: the **updates dropdown** in the controls bar, and the **global context menu** (right-click on empty space) | The controls dropdown already hosts `checkUpdatesMenuItem` (`templates/components/controls.html:114-126`) and costs no layout space. The global menu is the established home for library-wide occasional actions and already holds the sibling `check-model-updates` (`templates/components/context_menu.html:185`); it has only 7 items and existing hide/separator machinery (`GlobalContextMenu.showMenu` / `_updateSeparatorVisibility`). The **model-card menu is deliberately left alone** — it already has 20 items, and P5b's *Refresh prices* covers the per-model case |
| D9 | Bell badge shows the **unread alert count**; when there are none it keeps today's behaviour (dot for app updates). The same cached count is appended to the global context menu item (`Price alerts (3)`) | Otherwise a price drop is invisible until the user opens the bell. The count is fetched once on init **only when `price_tracking_enabled`**, so users who never enable the feature pay nothing |
| D10 | The global context menu item is **always visible** on model pages (hidden on recipes, like its siblings) | The panel's disabled state is the explanation plus a deep link into Settings; hiding the entry would make the feature undiscoverable for exactly the users who have not enabled it yet |
### 11.2 Information architecture
```
┌ Price alerts [Refresh prices] ┐
│ Threshold: 500 Buzz (click to edit) │
│ [ Under threshold ] [ Became free ] Type: All · LoRA · … │
├──────────────────────────────────────────────────────────────────────┤
│ ▸ Glorious Art · Checkpoint · 2 versions │
│ Alpha · 250 Buzz (was 500) · Blue Buzz OK │
│ EA until Oct 10 · Not in library · dropped 3 days ago │
│ [CivitAI] [Open] [Ignore] │
│ ▸ Eira Kishida · LoRA · 1 version │
│ 125 Buzz · In library · dropped today [CivitAI] [Open] │
└──────────────────────────────────────────────────────────────────────┘
```
* Segments cover both halves of the FR: `Under threshold` (price at or below the threshold) and
`Became free` (versions with `gate_lapsed_at`, no price).
* Grouped by model (a model often has several versions at the same price), sorted cheapest first
then most recently crossed; the type chips filter client-side over the loaded list.
* Row: effective price with the list price struck through when on sale, Blue Buzz note, EA end date,
in-library marker, and "dropped X ago" from `price_alert_since`.
* Actions: open on CivitAI, open the local model modal (only when a file path was resolved), ignore
this version (reuses `setVersionUpdateIgnore`).
### 11.3 Data contract
```
GET /api/lm/price-alerts?limit=200
{
"success": true,
"enabled": true, // price_tracking_enabled
"thresholdBuzz": 500,
"newestCheckedAt": 1791039694.5, // staleness copy ("prices last checked X ago")
"alerts": [
{
"modelId": 2981320, "modelType": "checkpoint",
"modelName": "Glorious Art", // best-effort, from the scanner cache
"versionId": 3379626, "versionName": "Alpha",
"kind": "below_threshold", // or "became_free"
"priceBuzz": 250, "listPriceBuzz": 500,
"acceptsBlueBuzz": true, "priceSaleEndsAt": null,
"priceAlertSince": 1791000000.0,
"gateLapsedAt": null,
"earlyAccessEndsAt": null, "isPaid": true, "isEarlyAccess": false,
"isInLibrary": false,
"filePath": null, // set only when resolvable
"civitaiUrl": "https://civitai.com/models/2981320?modelVersionId=3379626"
}
]
}
```
One list with a `kind` discriminator (not two lists) so "All" needs no second request. `civitaiUrl`
is built server-side with the existing `build_civitai_model_page_url` so `civitai_host` stays the
single source of truth.
### 11.4 States (all three must be designed, not just the happy path)
1. **Tracking off** — explain that CivitAI publishes no price in its public API and that the feature
reads the model page, plus a button that opens Settings → Library.
2. **On, nothing matching** — "nothing under N Buzz right now", with the threshold editable inline.
3. **Stale / offline / rate-limited** — keep the last known list, add a "prices last checked X ago"
line and a retry; never blank the panel.
### 11.5 Tasks
**P5a — panel usable end to end**
1. `py/services/model_update_service.py`
* `get_price_alerts(model_type=None, *, threshold_buzz, limit=200)` — optional type filter, live
threshold comparison, `kind`, `price_alert_since`; keep the shipped per-type call site working.
* `price_alert_since` column through all eight enumerations (§4.1), set on the `0 → 1` edge and
cleared on `1 → 0` inside `_build_record_from_remote`.
2. `py/routes/misc_route_registrar.py` + `py/routes/handlers/misc_handlers.py` — register
`GET /api/lm/price-alerts` **once**, using `ServiceRegistry.get_model_update_service()` and the
settings service; resolve `modelName` / `filePath` best-effort per model type from the scanner
caches (`version_index[version_id]` → `file_path`, `file_name`; item `model_name` with a
`file_name` fallback), and omit them when resolution fails.
3. `templates/components/modals/update_modal.html` — third tab + panel skeleton (segments, threshold
row, list container, empty/disabled blocks), reusing `data-notification-tab` /
`data-notification-panel`.
4. `static/js/managers/UpdateService.js` — `renderPriceAlerts()`, panel fetch on open, tab badge
count, threshold display, row rendering and actions, `localStorage` watermark; extend
`updateTabBadges()` and `switchNotificationTab()`.
5. `static/js/api/apiConfig.js` + a small fetch helper — the endpoint is global, so it does not
belong in the per-model-type `endpoints` map.
6. `templates/components/controls.html` + `static/js/components/controls/PageControls.js` — the
controls-dropdown item (D8).
7. `templates/components/context_menu.html` + `static/js/components/ContextMenu/GlobalContextMenu.js`
— the global-context-menu item (D8): one template entry, the recipes-page hide list in
`showMenu()`, one `case` in `handleMenuAction`, and the optional `(N)` count from the cached
alert count. Both entries call one shared `openPriceAlertsPanel()` helper.
8. `locales/en.json` + `python scripts/sync_translation_keys.py`.
*Acceptance:* with tracking on and one refresh done, the bell shows a count, the tab lists the
versions with prices, CivitAI opens the right page, Open appears only for in-library models and opens
the modal, all three states render, and it works from every page type.
**P5b — actions and polish**
* Inline threshold editing (read-time comparison makes it instant) and **Refresh prices** with a
`force_price_refresh` flag on the existing refresh endpoint (or a dedicated
`POST /api/lm/price-alerts/refresh`).
* Type chips and a "only versions I do not own" toggle (this is open question 2 from §9).
* Ignore-this-version action.
**P5c — optional**
* Thumbnails from the scanner cache, "unread only", focus-management pass on the new tab, and a
deep link from the panel into the settings section.
### 11.6 Tests
* `tests/services/test_model_update_service.py` — `model_type=None` spans types; live threshold
parameter; `price_alert_since` set/cleared on the edge.
* `tests/routes/` — the global route is registered exactly once and returns the documented shape;
`filePath` resolution is best-effort (mock scanner; omit on failure).
* `tests/frontend/` — vitest for `renderPriceAlerts` covering rows, both segments, the three states
and the badge count with a mocked fetch.
* Manual eyeball for layout, per the repo's UI verification policy.
### 11.7 Risks
| Risk | Mitigation |
| --- | --- |
| The bell modal is nominally about app updates; a third tab changes its character | Clear labelling, and only count/badge when the feature is on; the app-update dot behaviour is unchanged when there are no alerts |
| Resolving local file paths pulls scanner caches into an app-wide route | Best-effort with `try/except`, one `get_cached_data()` per type (in-memory), omit the field and hide the Open button when it fails |
| `price_alert_since` is another migration | Same eight-touchpoint discipline as §4.1, covered by the existing migration test |
| A global route in `COMMON_ROUTE_DEFINITIONS` would bind 4× | Register it in `MiscRoutes` instead (D2) |
| Menu bloat / entry sprawl | Two entries maximum (D8), both calling one helper; the 20-item model-card menu is explicitly untouched |
| Panel data goes stale between refreshes | Show `newestCheckedAt`; the refresh action (P5b) makes it explicit rather than silent |
### 11.8 Open questions for the owner
1. **Badge policy** — count unread alerts on the bell (recommended, D9), or leave the bell alone and
put the number only on the tab?
2. **Unread at all** — the `localStorage` watermark (recommended, D5), or simply "current matches"
with no read state?
3. **Threshold editing inside the panel** — convenient, but it silently changes a global setting from
a surface the user opened just to look.
4. **"Open in library"** — worth the scanner-cache coupling (D6), or should the row offer only
"CivitAI" plus a copy-link action?
5. **Thumbnails** — skip for weight (recommended) or show them?
6. **Entry visibility when tracking is off** — always show the global-context-menu item
(recommended, D10: the panel's disabled state educates and deep-links to Settings), or hide it
until the feature is enabled for a cleaner menu?
7. **Count in the menu label** — `Price alerts (3)` using the cached count (recommended, D9), or a
plain label with the number only on the bell/tab?
### 11.9 What shipped (P5a)
Answers to §11.8: badge counts unread alerts (D9), unread uses the `localStorage` watermark (D5),
threshold editing is **display-only in P5a** (editing is P5b), "Open in library" ships with the
best-effort path resolution (D6), no thumbnails, the context-menu item is always visible (D10), and
the menu label carries the count (D9).
| Area | Where |
| --- | --- |
| `price_alert_since` column through all eight enumerations, set on the `0 → 1` edge, preserved while the alert stands, cleared when the price rises | `py/services/model_update_service.py` |
| `get_price_alerts(model_type=None, *, threshold_buzz, limit)` — read-time threshold, `kind`, one list across types; `newest_price_checked_at()` | `py/services/model_update_service.py` |
| Global endpoint `GET /api/lm/price-alerts`, registered once | `py/routes/misc_route_registrar.py`, `py/routes/misc_routes.py`, `PriceAlertsHandler` in `py/routes/handlers/misc_handlers.py` |
| Third bell tab, panel skeleton, segments, three states | `templates/components/modals/update_modal.html`, `static/css/components/modal/update-modal.css` |
| Loader, renderer, unread watermark, badge, `openPriceAlertsPanel()` | `static/js/managers/UpdateService.js` |
| Entry points (controls dropdown + global context menu, count in the label) | `templates/components/controls.html`, `static/js/components/controls/PageControls.js`, `templates/components/context_menu.html`, `static/js/components/ContextMenu/GlobalContextMenu.js` |
Deviations and decisions made while implementing:
* **`became_free` is reported even while price tracking is off.** It needs no price data, so the
panel shows the "tracking is off" explanation *and* whatever became free instead of hiding the
half of the feature that already works.
* **`price_alert_since` is also set on first sight** of an already-cheap version. The toast stays
silent (edge-triggered, §10.3), but the count is non-zero, which is what invites the user into the
panel after enabling the feature.
* **The per-type frontend client method was removed** (`baseModelApi.getPriceAlerts` and the
`priceAlerts` entry in `apiConfig.js`): with the global endpoint it was dead code. The per-type
**backend** route stays for the companion extension and a possible future grid filter.
* **`openPriceAlertsTab()` exists because `toggleUpdateModal()` closes an open bell** — an entry
point calling it unconditionally would dismiss the modal instead of switching tabs.
* **Local context uses the existing indexes** (`cache.model_id_index` for `model_name`,
`cache.version_index` for `file_path`/`file_name`), so it is O(1) per row; every failure just
omits the fields and the row loses its "Open" button.
Verification: `pytest` 3652 passed / 7 skipped, `npm run test:js` 1444 passed, plus a sandboxed
standalone server run that seeded the update DB and confirmed the payload shape, the `kind`
split, the `civitaiUrl`, and that changing `price_alert_threshold_buzz` through `POST /api/lm/settings`
changes panel membership immediately with no refresh.
### 11.10 Known limitation: mature (NSFW) model prices, and the host fallback
End-to-end verification against the live site found that the page fetch was broken for a whole class
of users: **the hosts are not interchangeable**, and the user's `civitai_host` preference was
silently fatal.
Measured with the app's own HTTP stack (aiohttp) and with httpx, browser User-Agent in both cases:
| Target | civitai.com | civitai.green | civitai.red |
| --- | --- | --- | --- |
| Anonymously visible model page | 200, prices parse | 200, prices parse (same bytes as .com) | **403 Cloudflare challenge** |
| Mature (NSFW) model page | 404 | 404 | **403 Cloudflare challenge** |
* `civitai.red` refuses non-browser clients outright — User-Agent, `Accept-Language`, `Sec-Fetch-*`
and switching HTTP library all make no difference. (An earlier manual check with `curl` passed by
luck of TLS fingerprint, which is why this was missed: it was a false positive.)
* A real user on `civitai_host=civitai.red` therefore captured **zero** prices
(`price_checked_at = 0` in their update DB) even with tracking enabled.
* Mature models are hidden from anonymous visitors on `.com`/`.green` and only served by `.red`, so
**no host can currently read their price.** The public API cannot help: it trims `paidAccess` to
`{permanent, endsAt}` by design, and the public `mini/{id}` endpoint exposes only per-generation
`fees`.
What P5a now does about it:
* **Host fallback** (`CivitaiClient.get_model_prices`): the configured host is tried first, then the
others (`civitai_page_host_candidates`), first parseable payload wins. The host that worked is
remembered, and a host that refuses outright (403) is parked for 15 minutes — a host-wide failure
must not cost three requests per mature model in the library. A 404 is model-specific and does
**not** park the host.
* **Honest "unavailable" state**: `price_check_attempted_at` separates "we tried and could not read
a price" from "we never looked". Gated versions in that state show a muted `Price unavailable`
badge in the versions tab, and the panel reports `unavailableCount`.
* **Diagnostics**: a host refusal is warned once per host per TTL, and a model with no price source
logs the per-host reasons (404 vs challenge) instead of failing silently at debug level.
Recorded options for mature models, deliberately **not** implemented:
1. **Internal tRPC with the user's API key** (`modelVersion.getById` on `.red`): the route is a
`publicProcedure` with `requiredScope: ModelsRead`, and `isBearerAuth` satisfies
`acceptableOrigin`, so the user's own key would work and `.red`'s `/api` paths are not challenged.
Rejected as the default because the endpoint is undocumented and its own 401 message says to use
the public API. If ever wanted, it belongs behind an off-by-default setting.
2. **Extension-assisted fetch**: `lm-civitai-extension` runs inside the user's browser, so it has
both the Cloudflare clearance and the login session needed to read mature pages. This is the only
route that would work without an undocumented API, but it is a cross-component design of its own.
**Strengthened upstream ask:** the public API should expose the price. The argument is no longer
"convenience" — the page route is demonstrably unreliable (one host challenges non-browser clients,
the other two hide mature models from anonymous visitors), so a supported field is the only way for
any third-party tool to show prices for the models where creators monetize most.
### 11.11 Price capture is independent of the metadata TTL
Found in a real instance: after enabling price tracking, a normal "Check updates" captured exactly
**one** price out of 718 models. The price capture only ran when the *version list* was re-fetched
(`refresh_succeeded`), so it inherited the metadata TTL: with 24 h metadata and 24 h price TTLs, only
the 6 models whose metadata happened to be stale were ever priced.
The cached record already carries the gate, so the price pass now runs off whichever version list is
available — freshly fetched or stored — and applies the result without touching `last_checked_at`, so
a price-only pass cannot silently extend the metadata TTL. Two related semantics:
* A **failed** attempt (`price_check_attempted_at`) satisfies the price TTL, so a mature model whose
page no host will serve is not retried on every single update check.
* An explicitly **forced** check re-prices within the TTL (`_should_fetch_prices(..., force=True)`).
Verified by copying a real instance's update DB into a sandbox and running a non-forced check:
`bulk metadata fetches: 0` (version lists came entirely from cache) while priced versions went
**1 → 20** and the panel listed 19 alerts.
### 11.12 The empty state has to explain a 0 Buzz threshold
The default threshold is 0 ("only tell me when a version becomes free"), and the settings copy says
so. The panel did not: a real instance with **52 priced paid versions** and an untouched threshold
showed "Nothing is under your price threshold right now" plus a small "Alert threshold: 0 Buzz" in
the corner, which reads as "the feature is broken".
The empty state is now threshold-aware, using a new `pricedCount` in the payload: when the threshold
is 0 and prices are known, it says how many paid versions have a price and where to set a threshold.
The read-time threshold comparison means changing it takes effect immediately — no re-check needed
(measured on a copy of that instance's DB: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51).
## 12. Redesign (locked): obtainability of versions you do not own
### 12.1 Why the P5a model was wrong
The owner could not tell, from the UI alone, what "Buzz Price Tracking" enables, what the
"Price alert threshold" number means, or what "Price Alerts" is alerting about. That is not a copy
problem: the implementation exposed **our mechanism** (a page scrape) and **our SQL predicates**
(`price_buzz <= ?`, `gate_lapsed_at IS NOT NULL`) as the user's concepts. Two concrete defects came
out of the same root:
* **The alert population included versions the user already owns.** Neither the event generator nor
the panel query filtered on `is_in_library` (the payload even carried `isInLibrary`). In the
owner's library **28 of 52 gated versions were already downloaded** — more than half of the
"alerts" were about things already on disk. A version you own cannot become cheaper *for you*.
* **A filtered list in a notification surface.** "Price Alerts" was a state list whose contents were
entirely decided by a number stored in Settings, so an empty panel had three indistinguishable
causes (tracking off / threshold 0 / genuinely nothing), and it read as a broken feature.
Two measurements shaped the correction:
* **Permanent prices dominate** (87 permanent vs 3 timed gates in a 600-model sample), so "wait for a
price drop" is a low-frequency proposition, while "wait for early access to end" is high-frequency
and deterministic.
* **Prices cluster at the floor** (44 of the owner's 52 priced versions are 100 Buzz), so
"price <= N" carries little information; the real distinction is *free / soon free / paid*.
Also corrected during the discussion: the **generation fee** is the cost of generating *on the
CivitAI site*. A locally downloaded model generated in ComfyUI has no such fee, so for this tool it
is not a price axis at all (42 of the owner's 52 gated versions carry one — displaying it would
mislead on 80% of paid rows). It is not displayed today and must stay that way.
### 12.2 The corrected information model
The unit is the **version**, and the boundary is **ownership**: information exists only where a
decision exists.
| State of a version **not in the library** | The user's decision | What is shown |
| --- | --- | --- |
| Downloadable for free | download or not | nothing special (the download action already says it) |
| Early access, free on D, buyable for N | **wait until D / pay N now** | `N Buzz - free on D` |
| Early access, free on D, not buyable | wait | `free on D` |
| Permanently paid, price known (N) | buy or skip | `N Buzz` |
| Permanently paid, price unknown | buy or skip, price unknown | `Paid` (no number) |
| **In the library** | none | **nothing** |
The numeric threshold has no place in this table: every decision is categorical (wait / pay / skip),
not numeric.
### 12.3 Locked decisions
* **D1 - No independent surface.** Obtainability is an attribute of the existing update surfaces:
the model card's update badge, the grid's "models with updates" filter, the post-check toast, and
the model modal's version list. No new bell tab.
* **D2 - No numeric threshold anywhere.** The setting, the comparison, and the tests around it go.
* **D3 - Only versions that are not in the library** produce events or badges (`is_in_library = 0`),
enforced in both the event generator and any query.
* **D4 - Owned versions show no download price** (and carry no alert state).
* **D5 - The generation fee is never displayed.**
* **D6 - Models outside the library are out of scope.** Their decision moment is the download/browse
flow; that is a recorded follow-up, not a background feed (the update service only knows local
models, and there is no follow mechanism to build on).
* **D7 - The plumbing stays**: page fetch, host fallback, attempt markers, both TTLs, storage,
parser, `gate_lapsed_at`, and the price columns.
### 12.4 What happens to what shipped in P5a
| Piece | Fate |
| --- | --- |
| `GET /api/lm/price-alerts`, `GET /api/lm/{prefix}/updates/price-alerts`, `PriceAlertsHandler` | **removed** - no surface consumes them; events reach the UI through the refresh response |
| `get_price_alerts`, `newest_price_checked_at`, `count_priced_versions`, `count_unavailable_prices` | **removed** (threshold comparison and the panel's counters) |
| `price_alert_threshold_buzz` (settings, `_price_alert_threshold_buzz`) | **removed** |
| `price_alert_since` | **stop writing**; the column stays (SQLite drops need a table rebuild) and is marked deprecated |
| `price_alert_state` | **kept** - it is the one-shot edge for the "became free" toast |
| Bell tab, panel markup, `.price-alerts*` CSS, `UpdateService` panel code, watermark, the two entry points (controls dropdown, global context menu), their locales | **removed** |
| Version badges | **reworked** to the 12.2 table, ownership-scoped |
| "Price unavailable" badge | **replaced** by `Paid` (the gate is known from the public API; only the number is best-effort, and that is our plumbing, not the user's problem) |
| `priceAttemptedAt` in the version payload | **removed** from the payload (kept internally for the price-TTL retry policy) |
| The "became free" toast | **kept**, reworded, ownership-scoped |
### 12.5 Tasks
**P6a - the core correction (no new surface).**
Backend: ownership-scope the event generator; remove the threshold, the alert query, the counters,
the two routes and the handler; stop writing `price_alert_since`. Frontend: rework the version
badges to the 12.2 table; delete the panel, tab, entry points, watermark and their locales; reword
the toast. Tests: replace the panel/alert suites with a badge-state matrix and ownership-scoping
tests; assert the removed routes are gone.
**P6b - obtainability in the update surfaces.**
Per-model obtainability summary in the model list payload (next to `hasUpdate`) so the card badge can
say `Update - 500 Buzz` / `Update - free on Oct 15`; toast copy that counts the free ones
("3 models have updates, 1 is now free").
**P6c - deferred (recorded, not planned).** A price-drop event (rare by measurement); obtainability
in the download/browse flow for models outside the library (D6).
### 12.6 Verification
Unit tests for the state matrix and the ownership filter; a sandbox run against a **copy** of the
owner's real update DB, where the population must shrink from 52 gated versions to the **24 that are
not in the library**, with the owned ones showing nothing.
### 12.7 What shipped (P6a)
* The two alert-state columns are **gone from the schema** (not merely unused): the dataclass,
`_SCHEMA`, the additive migration table, the primary-key rebuild's column list and defaults, the
bulk `SELECT`, the row mapping and the `INSERT`. A database created by an unreleased build still
has them, so `_apply_migrations` drops them with a native `ALTER TABLE ... DROP COLUMN` (SQLite
3.35+, guarded) and logs it; for everyone else it is a no-op.
* `price_alert_threshold_buzz`, `_price_alert_threshold_buzz`, `_evaluate_price_alert`, the
threshold-crossing event, `get_price_alerts`, `newest_price_checked_at`,
`count_priced_versions`, `count_unavailable_prices`, both alert routes, `PriceAlertsHandler`, its
handler-set slot, its route mapping entry and the service-registry adapter field it needed: all
removed.
* Gate events (and only gate events) are emitted, and only for versions the user does not have -
`isInLibrary` is now the filter rather than a field in the payload.
* Version badges follow the 12.2 table: cost information only for versions not in the library, the
price replaces the redundant `Paid` badge when it is known, `Paid` appears without a number when
it is not, and `Price unavailable` is gone (the gate is certain; only the number is best-effort).
* The bell tab, panel markup, panel CSS, the controls-dropdown and global-context-menu entries, the
unread watermark and their locales are removed; the toast keeps its gate transitions and drops the
price-drop line. The setting is now framed as plumbing ("Show download prices for paid versions")
and keeps only the enable flag and the refresh interval.
Verified: `pytest` 3653 passed / 7 skipped, `npm run test:js` 1444 passed. Against a copy of the
owner's real database the population is 52 gated versions -> **28 owned (now silent) + 24 that the
feature is actually about**; the drop migration logged twice and left no `price_alert_*` column, and
both removed endpoints return 404.
### 12.8 Correction to P6b: the model card cannot carry a price
P6b proposed annotating the model card's update badge with `Update - 500 Buzz` /
`Update - free on Oct 15`. The owner objected that the badge represents a **model** already in the
library while the update is a set of versions, each with its own price and its own early access end
date. The objection is correct, and the data is blunt about it:
* Live model `958009` has **37 gated versions across 6 price points** (1111, 2220, 4440, 8880,
88800, 888888); model `153568` has two gated versions at 600 and 4000.
* In the owner's own library, model `647926` has two early access versions with **two different end
dates**.
So a single price or date on the card would be fabricated, not summarised. It looked plausible only
because that library is uniform (44 of its 52 priced versions sit at the 100 Buzz floor) - an
artefact of floor pricing, not a guarantee.
What is well defined at model level, by construction: counts ("3 updates"), the earliest upcoming
free date (a minimum over the set), a minimum price ("from 100 Buzz", well defined but misleading
when the cheap version is an old one), and uniformity statements only when they are true. "The price
is X" is not among them.
Revised scope: the card badge stays a plain update indicator, and **prices stay in the version list**,
where every row has exactly one subject. The only model-level fact worth considering is the deadline
- "1 of 3 updates becomes free on Oct 15" - because a deadline can change what the user does *before*
opening the model; it is rare in practice (4 unexpired early access versions across 3 models in that
library), so it is optional polish rather than part of the correction.
## 13. Upstream API request
### 13.1 The request as filed (send this)
**Filed:** [civitai/civitai#5384](https://github.com/civitai/civitai/issues/5384) (2026-10-05, as
`willmiao`).
**Title:** `[API Feature Request] Expose Buzz prices for paid / early-access model versions`
**Body:**
> **Ask:** include the Buzz price in the public `paidAccess` DTO - the download price, whether a
> sale is currently discounting it, and whether Blue Buzz is accepted.
>
> **Why:** the API already tells a client *that* a version is gated and *when* early access ends
> (`paidAccess: { permanent, endsAt }`), but not what it costs. In the Buzz economy that is the
> other half of the question users are asking: wait for it to become free, or pay now? A live sale
> or Blue Buzz support is often what decides it.
>
> With the price exposed, an integration like ComfyUI LoRA Manager can show "100 Buzz - free on
> Oct 11" on a version and tell users when a paid version becomes free, instead of sending them to
> the website to check.
>
> Public or authenticated-only - whichever you prefer.
Filing notes: a new issue on `civitai/civitai`, using that repo's `[API Feature Request]` title
convention. No existing issue asks for this (searched `paidAccess`, "download price",
"buzz price API", and every open `[API Feature Request]`).
### 13.2 Internal notes (do NOT paste into the issue)
Kept here because it is what makes the request cheap to grant - but telling a maintainer how to
implement their own service reads as presumptuous, and volunteering how we read prices today is
our business, not theirs. Both were in the first draft and were cut.
* `toPublicPaidAccessDto` (`src/server/services/paid-access.service.ts:649`) already receives a
`PaidAccessRow` carrying `terms: PaidAccessTerms` and `sales?: ModelVersionSaleWindow[]`
(`packages/civitai-buzz/src/paid-access.ts:132`) and returns only `{ permanent, endsAt }`.
* `discountedTerms(terms, sales, now)` (line 426) already resolves what a buyer pays during a sale.
* The write path already accepts the same terms: `/api/v1/model-versions/early-access` validates
`updateModelVersionPaidAccessSchema`, whose `paidAccess.terms` is `modelVersionTermsSchema`.
* So the reads withhold exactly what the writes accept, from a row that already holds it.
Evidence gathered for our own records (also not for the issue):
| Check | Result |
| --- | --- |
| `GET /api/v1/models?ids=646411` | version `1249246` -> `paidAccess: {permanent: true, endsAt: null}`, no price |
| `GET /api/v1/model-versions/1249246` | same, plus `usageControl: "Download"`, `licensingFee: null` |
| Same version on the model page | `downloadPrice 100`, `generationPrice 50`, `acceptsBlueBuzz false` |
| `civitai.red` model pages | Cloudflare challenge (403 for any User-Agent; aiohttp and httpx alike) |
| `civitai.com` / `civitai.green`, mature models | 404 to anonymous visitors, so no readable price source |
### 13.3 Why the request is written that way
* **Lead with the ask and the need; stop.** A maintainer triages on the first lines; the rest is
only read if the ask survives.
* **No implementation guidance.** They know their service. A "here is your one-line change"
section invites either irritation or a correction, and it can be wrong: withholding prices may be
a deliberate product decision (the code comment says pricing belongs to the purchase flow), so the
request has to argue for the *need*, not the diff.
* **No mention of how we read prices today.** It is an internal approach, it shifts the thread from
the feature to our behaviour, and it invites a "please don't do that" that has nothing to do with
the ask.
* **One scope concession, one clause** ("public or authenticated-only"): it removes the most likely
objection without prescribing anything.
* **One precedent clause** ("you already expose whether a version is gated and when early access
ends"): it makes the price look like the natural companion of what is already public.
* **One credibility clause** (naming ComfyUI LoRA Manager as a real integration): it separates the
request from a one-off user wish.
* Target length: ~130 words. Longer drafts read as a specification, which is the failure mode this
section exists to prevent.
+7 -1
View File
@@ -30,7 +30,9 @@ Aliases live inside `()` and are separated with `|`. The canonical name is what
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags: When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name. - It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
- If no priority tags are found but the model has tags, the very first model tag is used. - If no priority tags are found but the model has tags, the first tag that can be used as a folder name is chosen.
- Tags that contain a comma, or that are longer than 50 characters, are treated as unusable and skipped: some uploaders pack their whole keyword list into a single tag. If every tag is unusable, the folder falls back to `no tags`.
- Civitai's structural labels, such as `base model`, describe the listing rather than the model, so the automatic fallback skips them too. Add one to your priority list if you really want it as a folder name.
- If the model has no tags at all, the folder falls back to `no tags`. - If the model has no tags at all, the folder falls back to `no tags`.
### Example ### Example
@@ -42,6 +44,8 @@ With a template like `/{model_type}/{first_tag}` and the priority entry list `ch
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. | | `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. | | `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. | | `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
| `["lora, character, rosie, ... face"]` | `no tags` | The only tag is a keyword dump, so it is skipped. |
| `["lora, character, ... face", "base model"]` | `no tags` | A keyword dump plus a Civitai label: nothing usable is left. |
| `[]` | `no tags` | Nothing to match, so the fallback is applied. | | `[]` | `no tags` | Nothing to match, so the fallback is applied. |
## 3. Save the Settings ## 3. Save the Settings
@@ -61,10 +65,12 @@ After editing the entry list, press **Enter** to save. Use **Shift+Enter** whene
- Keep canonical names short and meaningful—they become folder names. - Keep canonical names short and meaningful—they become folder names.
- Place the most important categories first; the first match wins. - Place the most important categories first; the first match wins.
- Avoid duplicate canonical names within the same list; only the first instance is used. - Avoid duplicate canonical names within the same list; only the first instance is used.
- Folder names built from tags are sanitized for filesystem safety and truncated to 50 characters.
## Troubleshooting ## Troubleshooting
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches. - **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
- **Folder named `no tags`?** Every model tag was either missing or unusable (a comma-separated keyword dump, or longer than 50 characters). Add the tags you care about to your priority list so they match by name instead.
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`. - **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`. - **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
+58
View File
@@ -0,0 +1,58 @@
# CivitAI image imports can end up with 0 LoRAs
## Symptom
Importing a CivitAI image URL can produce a recipe with **zero LoRA
entries**, even though the image page lists LoRAs in its resource panel.
Reported example: `https://civitai.red/images/140818889` was imported as a
local recipe with 0 LoRAs, while the page shows 3 LoRAs. Some images (e.g.
NSFW / higher browsing level) additionally require a login to view, so their
data is not publicly reachable at all.
## Root cause
URL imports use only two data sources:
1. **CivitAI REST image API** — `GET /api/v1/images?imageId=<id>&nsfw=X&withMeta=true` → `meta`
2. **Embedded image metadata** — EXIF/XMP read from the downloaded bytes
For the same image both sources can be empty, and the one source that does
contain the data is never queried. Verified for image 140818889:
| Source | What it returned |
|---|---|
| REST image API | `meta` holds only a prompt; `modelVersionIds: []`; no `resources`/`hashes`; `baseModel: null` |
| Downloaded image | PNG with **no EXIF/XMP** (the CDN URL ends in `.jpeg`, the body is PNG) |
| Image page HTML | `__NEXT_DATA__` embeds the trpc `image.getGenerationData` result → full `resources` list: 3 LoRAs, each with `modelId`, `modelVersionId`, `modelName`, `modelType`, `versionName`, `baseModel` |
Key points:
- The page's resource panel is fed by an **internal, non-public trpc
endpoint**, not by the public REST image API.
- That internal endpoint is **login-gated** for some content — the
"requires login" symptom.
- Even with the version IDs in hand, `/model-versions/{id}` for these
(Krea) versions returns **no `sha256`**, so an exact local-file hash match
is impossible; only model/version identity is recoverable.
## Conclusion / status
0-LoRA imports are a data-source gap: public REST meta and image EXIF are
both empty, while the only complete source (page generation data) is
internal, sometimes login-gated, and not used by the importer.
Such imports **cannot be reliably auto-repaired/completed** by the backend
alone. The old "Repair Metadata" feature only re-fetched the same incomplete
REST meta and could not fix them; it was deprecated and has been removed.
**Fixed via the companion browser extension.** When the extension is
installed with a valid license, it scrapes the image page's internal trpc
generation data with the user's session and calls the payload-capable
re-import endpoint (`POST /api/lm/recipe/{recipe_id}/reimport` with
`image_url`/`name`/`resources`/`gen_params`/`base_model`/`tags` query
params), which rebuilds the recipe from the caller-supplied metadata. The
web UI delegates re-import of CivitAI-image-sourced recipes to the extension
automatically (probe + `lm:reimport*` DOM events); without the extension,
re-import silently falls back to the native path, which remains limited by
the data-source gap documented above.
@@ -0,0 +1,78 @@
# Reconcile 的 Windows 大小写回退分支
> **状态**: 已改为 O(1) 索引(2026-10-06),不再需要 Windows 机器验证 | **创建日期**: 2026-09-11
> **相关文件**: `py/services/model_scanner.py`(`_walk_root_for_reconcile` / `_CachedPathLookups`)
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
---
## 背景
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
realpath 全量遍历改成按需触发。清理时留下的唯一可疑点,是 Windows 专属的大小写不敏感回退分支:它排在
精确匹配和 realpath 别名匹配之后,只有**未命中**的文件才会走到,但一旦走到就是 O(文件数 × 缓存条目数)。
---
## 现状(已改造)
分支语义保持不变,查找改为 O(1):
```python
if _CASE_INSENSITIVE_PATHS: # 模块级常量,默认 os.name == "nt"
cached_case_match = lookups.match_casefold_path(file_path)
```
`lookups` 是 `_CachedPathLookups`:小写索引 `{lower(cached_path): cached_path}` 在第一次未命中时构建一次
(与 realpath 别名索引同样懒构建,并用 `threading.Lock` 护住——walk 现在跑在工作线程里),之后每次未命中
只做一次字典查询。
`_CASE_INSENSITIVE_PATHS` 提成模块级常量的原因:这条分支在 Linux 上原本被 `os.name == 'nt'` 短路、没有任何
测试覆盖;现在测试可以 monkeypatch 该常量,在 Linux 上真实执行这条分支。
---
## 待办
- [x] **消除 O(N×M)**:改成懒构建的小写索引,查询降为 O(1)。
- [x] **补回归测试**:`tests/services/test_model_scanner.py::test_reconcile_case_fold_fallback_is_indexed_not_linear`
(缓存路径与磁盘仅大小写不同、realpath 别名不命中 → 断言条目保留、不重新处理、索引只构建一次)。
- [x] 回填本文件。
- [ ] (可选,纯代码瘦身)在 Windows 上确认 realpath 别名匹配是否已覆盖全部情形;若确认该分支不可达,
可以整体删掉这一层。它现在的成本已经可以忽略,删除不再是性能问题。
---
## 验证方法(可选,Windows)
1. 构造"缓存路径与磁盘真实大小写不一致"的场景(改过盘符大小写、迁移过 `settings.json` 与持久化缓存),点 Refresh。
2. 日志判据:`Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,且**没有**
`Found N new files to process` / `Processing <path>`。
3. 跑测试:`python -m pytest tests/services/test_model_scanner.py -k reconcile`。
---
## 已完成(历史,供对照)
2026-09-11 那轮清理里在 Linux(5 万文件库)验证过的部分:
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`)。
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
`re-processed=0`。
---
## 2026-10-06 追加:walk 阶段的并发与进度
同一次改动还做了两件事(与大小写分支无关,但都动到了同一段 walk 循环,故一并记录):
- walk 循环抽成同步函数 `_walk_root_for_reconcile()`,并按设备分组
(`_root_device_key()` / `_group_roots_by_device()`)在工作线程里并行执行:同一设备内的 root 仍按配置顺序
串行(保证目录认领与去重的确定性),不同设备之间才并行。结果回到事件循环后按**配置的 root 顺序**合并,
因此"同一个文件可达多条业务路径时谁胜出"与并发完成顺序无关。
- walk 阶段按 root 广播进度:`_ReconcileWalkTracker` 以「该 root 的缓存条目数」为权重估算进度(真实文件数
只有走完才知道),进度条把 walk 记为 0-50%,新增文件处理阶段顺延为 50-99%,两段之间不会回退。
+550 -65
View File
File diff suppressed because it is too large Load Diff
+551 -66
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+550 -65
View File
File diff suppressed because it is too large Load Diff
+586 -8
View File
@@ -17,6 +17,9 @@ import types as _types
import time import time
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
from .utils.constants import (
OTHER_MODEL_FOLDER_SUBTYPES,
)
from .utils.settings_paths import ( from .utils.settings_paths import (
ensure_settings_file, ensure_settings_file,
get_settings_dir, get_settings_dir,
@@ -41,6 +44,26 @@ def _normalize_root_identity(path: str) -> str:
return normalized return normalized
def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]:
"""Return ``current`` plus any candidate it does not already hold.
Order is preserved and nothing is ever removed: ``*_roots[0]`` derives the
recipes directory and the usage-stats file location, so a mid-session change
to the root *order* would move user data.
"""
merged = list(current)
seen = {_normalize_root_identity(path) for path in merged}
for path in candidates:
if not isinstance(path, str) or not path.strip():
continue
identity = _normalize_root_identity(path)
if identity in seen:
continue
seen.add(identity)
merged.append(path)
return merged
def _resolve_valid_default_root( def _resolve_valid_default_root(
current: str, primary_paths: List[str], allowed_paths: List[str], name: str current: str, primary_paths: List[str], allowed_paths: List[str], name: str
) -> str: ) -> str:
@@ -166,12 +189,25 @@ class Config:
self._preview_root_paths: Set[Path] = set() self._preview_root_paths: Set[Path] = set()
# Fingerprint of the symlink layout from the last successful scan # Fingerprint of the symlink layout from the last successful scan
self._cached_fingerprint: Optional[Dict[str, object]] = None self._cached_fingerprint: Optional[Dict[str, object]] = None
# Configured (existence-unfiltered) primary roots per model type. The live
# lists below drop paths whose directory is missing right now (a drive
# that is switched off), which must not be mistaken for "the user
# removed this path": these are what `save_folder_paths_to_settings()`
# persists and what `/roots` reports as unavailable.
self._configured_root_paths: Dict[str, List[str]] = {}
self.loras_roots = self._init_lora_paths() self.loras_roots = self._init_lora_paths()
self.checkpoints_roots = None self.checkpoints_roots = None
self.unet_roots = None self.unet_roots = None
self.embeddings_roots = None self.embeddings_roots = None
self.base_models_roots = self._init_checkpoint_paths() self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths() self.embeddings_roots = self._init_embedding_paths()
# Other-model roots (VAE, upscalers, text encoders, ...): flat deduped
# list plus a normalized root -> sub_type map and per-folder_paths-key
# roots for settings persistence.
self.other_roots: Optional[List[str]] = None
self.other_root_subtypes: Dict[str, str] = {}
self.other_folder_roots: Dict[str, List[str]] = {}
self.other_roots = self._init_other_paths()
# Extra paths (only for LoRA Manager, not shared with ComfyUI) # Extra paths (only for LoRA Manager, not shared with ComfyUI)
self.extra_loras_roots: List[str] = [] self.extra_loras_roots: List[str] = []
self.extra_checkpoints_roots: List[str] = [] self.extra_checkpoints_roots: List[str] = []
@@ -218,6 +254,8 @@ class Config:
if isinstance(recipes_path, str) and recipes_path: if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path self.recipes_path = recipes_path
self._remember_library_configured_paths(library_config)
extra_folder_paths = library_config.get("extra_folder_paths") extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict): if not isinstance(extra_folder_paths, dict):
return return
@@ -330,12 +368,40 @@ class Config:
comfy_library = libraries.get("comfyui", {}) comfy_library = libraries.get("comfyui", {})
default_library = libraries.get("default", {}) default_library = libraries.get("default", {})
# Persist what is *configured*, not what is readable right now:
# `upsert_library(folder_paths=...)` replaces the library's paths, so
# writing the existence-filtered live lists would erase the path of a
# drive that happened to be switched off when ComfyUI started.
target_folder_paths = { target_folder_paths = {
"loras": list(self.loras_roots), "loras": _append_new_paths(
"checkpoints": list(self.checkpoints_roots or []), self.configured_roots_for("lora"), self.loras_roots or []
"unet": list(self.unet_roots or []), ),
"embeddings": list(self.embeddings_roots or []), "checkpoints": _append_new_paths(
self.configured_roots_for("checkpoint"),
self.checkpoints_roots or [],
),
"unet": _append_new_paths(
self.configured_roots_for("unet"), self.unet_roots or []
),
"embeddings": _append_new_paths(
self.configured_roots_for("embedding"),
self.embeddings_roots or [],
),
} }
# Persist the other-model roots under their original folder_paths
# keys so library switching round-trips them.
for key, roots in (self.other_folder_roots or {}).items():
target_folder_paths[key] = list(roots)
# ...and keep an other-model root that is configured but unavailable.
configured_other = self.configured_roots_for("other")
if configured_other:
for key in self._get_enabled_other_folder_keys():
configured_key_roots = self._configured_other_paths_for_key(key)
if not configured_key_roots:
continue
target_folder_paths[key] = _append_new_paths(
configured_key_roots, target_folder_paths.get(key, [])
)
normalized_target_paths = _normalize_folder_paths_for_comparison( normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths target_folder_paths
@@ -406,7 +472,14 @@ class Config:
default_lora_root = _resolve_valid_default_root( default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""), comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []), # A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"lora"
),
self.loras_roots or [],
),
list(self.loras_roots or []) list(self.loras_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []), + list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
"default_lora_root", "default_lora_root",
@@ -414,7 +487,14 @@ class Config:
default_checkpoint_root = _resolve_valid_default_root( default_checkpoint_root = _resolve_valid_default_root(
comfy_library.get("default_checkpoint_root", ""), comfy_library.get("default_checkpoint_root", ""),
list(self.checkpoints_roots or []), # A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"checkpoint"
),
self.checkpoints_roots or [],
),
list(self.checkpoints_roots or []) list(self.checkpoints_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []), + list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
"default_checkpoint_root", "default_checkpoint_root",
@@ -422,7 +502,14 @@ class Config:
default_embedding_root = _resolve_valid_default_root( default_embedding_root = _resolve_valid_default_root(
comfy_library.get("default_embedding_root", ""), comfy_library.get("default_embedding_root", ""),
list(self.embeddings_roots or []), # A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"embedding"
),
self.embeddings_roots or [],
),
list(self.embeddings_roots or []) list(self.embeddings_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []), + list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
"default_embedding_root", "default_embedding_root",
@@ -522,6 +609,7 @@ class Config:
roots.extend(self.loras_roots or []) roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or []) roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or []) roots.extend(self.embeddings_roots or [])
roots.extend(self.other_roots or [])
# Include extra paths for scanning symlinks # Include extra paths for scanning symlinks
roots.extend(self.extra_loras_roots or []) roots.extend(self.extra_loras_roots or [])
roots.extend(self.extra_checkpoints_roots or []) roots.extend(self.extra_checkpoints_roots or [])
@@ -783,6 +871,16 @@ class Config:
except Exception as e: except Exception as e:
logger.error(f"Error scanning links in {root}: {e}") logger.error(f"Error scanning links in {root}: {e}")
def iter_path_mappings(self) -> List[Tuple[str, str]]:
"""Return the known ``(physical target, virtual link)`` symlink pairs.
Only symlinks directly under a model root are tracked (see
:meth:`_scan_symbolic_links`), so callers must treat this as a partial
view of the on-disk link layout — enough to notice that a linked drive
went away, not enough to resolve nested links.
"""
return list(self._path_mappings.items())
def add_path_mapping(self, link_path: str, target_path: str): def add_path_mapping(self, link_path: str, target_path: str):
"""Add a symbolic link path mapping """Add a symbolic link path mapping
target_path: actual target path target_path: actual target path
@@ -862,6 +960,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []: for root in self.embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
for root in self.other_roots or []:
preview_roots.update(self._expand_preview_root(root))
# Include extra paths for preview access # Include extra paths for preview access
for root in self.extra_loras_roots or []: for root in self.extra_loras_roots or []:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
@@ -874,6 +974,17 @@ class Config:
if self.recipes_path: if self.recipes_path:
preview_roots.update(self._expand_preview_root(self.recipes_path)) preview_roots.update(self._expand_preview_root(self.recipes_path))
# Centralized sidecar storage holds preview assets outside the model
# roots; allow serving them when the mode is active.
try:
from .utils.sidecar_paths import get_sidecar_root # Local import to avoid circular dependency
sidecar_root = get_sidecar_root()
except Exception: # pragma: no cover - defensive fallback
sidecar_root = ""
if sidecar_root:
preview_roots.update(self._expand_preview_root(sidecar_root))
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target)) preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link)) preview_roots.update(self._expand_preview_root(link))
@@ -882,7 +993,7 @@ class Config:
path for path in preview_roots if path.is_absolute() path for path in preview_roots if path.is_absolute()
} }
logger.debug( logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings", "Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d other roots, %d symlink mappings",
len(self._preview_root_paths), len(self._preview_root_paths),
len(self.loras_roots or []), len(self.loras_roots or []),
len(self.extra_loras_roots or []), len(self.extra_loras_roots or []),
@@ -890,6 +1001,7 @@ class Config:
len(self.extra_checkpoints_roots or []), len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []), len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []), len(self.extra_embeddings_roots or []),
len(self.other_roots or []),
len(self._path_mappings), len(self._path_mappings),
) )
@@ -1128,6 +1240,155 @@ class Config:
return unique_paths return unique_paths
def _get_enabled_other_folder_keys(self) -> List[str]:
"""Return the OTHER_MODEL_FOLDER_SUBTYPES keys that are enabled.
Other Models management is opt-in: while ``enable_other_models`` is
off (the default) no other-model folder is scanned at all. When it is
on, only the folder keys of the enabled sub_types are scanned
(text_encoder merges ``text_encoders`` with the legacy ``clip`` key).
"""
try:
from .services.settings_manager import get_settings_manager
enabled_sub_types = get_settings_manager().get_enabled_other_sub_types()
except Exception:
enabled_sub_types = []
if not enabled_sub_types:
return []
allowed = set(enabled_sub_types)
return [
key
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
if sub_type in allowed
]
@staticmethod
def _collapse_legacy_folder_keys(keys: List[str]) -> List[str]:
"""Drop folder keys the host already normalizes onto another queried key.
ComfyUI's ``folder_paths`` rewrites legacy names before every access
(``clip`` -> ``text_encoders``, ``unet`` -> ``diffusion_models``), and
registers both legacy directories under the canonical key, so
``get_folder_paths("clip")`` returns exactly the same list as
``get_folder_paths("text_encoders")``. Querying both therefore reports
every text-encoder folder twice and trips the overlap guard with a
conflict the user cannot fix.
When the host exposes ``map_legacy`` the alias is provably redundant and
is skipped (an empty canonical list implies an empty alias list).
Without it - the standalone mock, whose keys are independent
``settings.json`` entries - every key is kept, because a ``clip``-only
configuration is then genuinely distinct.
"""
map_legacy = getattr(folder_paths, "map_legacy", None)
if not callable(map_legacy):
return list(keys)
queried = set(keys)
collapsed: List[str] = []
for key in keys:
try:
canonical = map_legacy(key)
except Exception:
canonical = key
if canonical != key and canonical in queried:
logger.debug(
"Skipping legacy folder key '%s'; the host resolves it to "
"'%s', which is queried as well.",
key,
canonical,
)
continue
collapsed.append(key)
return collapsed
def _prepare_other_paths(
self, folder_path_map: Mapping[str, Iterable[str]]
) -> Tuple[List[str], Dict[str, str], Dict[str, List[str]]]:
"""Prepare other-model paths from a folder_paths-key -> raw paths map.
Returns:
Tuple of (all_unique_roots, business_root -> sub_type map,
folder_paths key -> business roots). This method does NOT modify
instance variables - callers must set them.
"""
unique_paths: List[str] = []
sub_type_map: Dict[str, str] = {}
per_key_roots: Dict[str, List[str]] = {}
# real path -> (business path, sub_type) of the category that claimed it
seen_real_paths: Dict[str, Tuple[str, str]] = {}
# Cross-scanner overlap detection: warn when an "other" root is
# already covered by the checkpoints/unet or embeddings scanners.
# Kept (not dropped) on purpose - duplicate cards across pages are
# cosmetic, while dropping would silently unmanage the files.
covered_real_paths = {
os.path.normpath(os.path.realpath(path)).replace(os.sep, "/"): path
for path in [
*(self.base_models_roots or []),
*(self.embeddings_roots or []),
]
if isinstance(path, str) and path.strip() and os.path.exists(path)
}
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
raw_paths = folder_path_map.get(key)
if not raw_paths:
continue
path_map = self._dedupe_existing_paths(raw_paths)
key_roots: List[str] = []
for real_path, business_path in sorted(
path_map.items(), key=lambda item: item[1].lower()
):
seen = seen_real_paths.get(real_path)
if seen is not None:
seen_business_path, seen_sub_type = seen
if seen_sub_type == sub_type:
# Same category reached through a second folder_paths
# key (legacy alias, or a sub_type spanning two keys).
# Expected, so never a "fix your configuration" warning.
logger.debug(
"Ignoring duplicate folder '%s' for category '%s' "
"(already covered by '%s').",
business_path,
sub_type,
seen_business_path,
)
else:
logger.warning(
"Detected the same folder '%s' under multiple other-model "
"categories ('%s' is already mapped as '%s'). Keeping the "
"first category; please fix your path configuration.",
business_path,
seen_business_path,
seen_sub_type,
)
continue
seen_real_paths[real_path] = (business_path, sub_type)
unique_paths.append(business_path)
key_roots.append(business_path)
sub_type_map[business_path] = sub_type
if real_path != business_path:
self.add_path_mapping(business_path, real_path)
covered_by = covered_real_paths.get(real_path)
if covered_by:
logger.warning(
"Detected an other-model root ('%s', category '%s') that "
"overlaps an existing checkpoints/embeddings root ('%s'). "
"The same files will appear on both pages; please review "
"your path configuration.",
business_path,
key,
covered_by,
)
if key_roots:
per_key_roots[key] = key_roots
return unique_paths, sub_type_map, per_key_roots
def _apply_library_paths( def _apply_library_paths(
self, self,
folder_paths: Mapping[str, Any], folder_paths: Mapping[str, Any],
@@ -1143,6 +1404,14 @@ class Config:
unet_paths = folder_paths.get("unet", []) or [] unet_paths = folder_paths.get("unet", []) or []
embedding_paths = folder_paths.get("embeddings", []) or [] embedding_paths = folder_paths.get("embeddings", []) or []
# The snapshot is the authoritative configured set: unlike the live lists
# below it keeps paths whose directory is missing right now, and a path
# the user removed from the library is forgotten here.
self._remember_configured_paths("lora", lora_paths)
self._remember_configured_paths("checkpoint", checkpoint_paths)
self._remember_configured_paths("unet", unet_paths)
self._remember_configured_paths("embedding", embedding_paths)
self.loras_roots = self._prepare_lora_paths(lora_paths) self.loras_roots = self._prepare_lora_paths(lora_paths)
( (
self.base_models_roots, self.base_models_roots,
@@ -1151,6 +1420,20 @@ class Config:
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths) ) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths) self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
other_path_map = {
key: folder_paths.get(key, []) or []
for key in self._get_enabled_other_folder_keys()
}
self._remember_configured_paths(
"other",
[path for paths in other_path_map.values() for path in paths],
)
(
self.other_roots,
self.other_root_subtypes,
self.other_folder_roots,
) = self._prepare_other_paths(other_path_map)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI) # Process extra paths (only for LoRA Manager, not shared with ComfyUI)
extra_paths = extra_folder_paths or {} extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get("loras", []) or [] extra_lora_paths = extra_paths.get("loras", []) or []
@@ -1200,10 +1483,60 @@ class Config:
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
def _remember_library_configured_paths(
self, library_config: Mapping[str, Any]
) -> None:
"""Remember the paths the library configures, missing directories included.
In standalone mode the host mock already filters non-existent paths out of
``folder_paths`` (see ``standalone.MockFolderPaths.get_folder_paths``), so
the library snapshot is the only record of what the user configured and it
wins there. In plugin mode ComfyUI's own list is unfiltered and therefore
authoritative — a path removed from the host config must be forgotten, not
resurrected from our mirror of it.
"""
if not standalone_mode:
return
from .services.settings_manager import get_settings_manager
folder_paths_map = (
library_config.get("folder_paths")
if isinstance(library_config, Mapping)
else None
)
if not isinstance(folder_paths_map, Mapping):
folder_paths_map = getattr(
get_settings_manager(), "settings", {}
).get("folder_paths", {})
if not isinstance(folder_paths_map, Mapping):
return
for key, model_type in (
("loras", "lora"),
("checkpoints", "checkpoint"),
("unet", "unet"),
("embeddings", "embedding"),
):
paths = folder_paths_map.get(key)
if isinstance(paths, (list, tuple)):
self._remember_configured_paths(model_type, paths)
core_keys = {"loras", "checkpoints", "unet", "embeddings"}
other_paths = [
path
for key, paths in folder_paths_map.items()
if key not in core_keys and isinstance(paths, (list, tuple))
for path in paths
]
if other_paths:
self._remember_configured_paths("other", other_paths)
def _init_lora_paths(self) -> List[str]: def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings""" """Initialize and validate LoRA paths from ComfyUI settings"""
try: try:
raw_paths = folder_paths.get_folder_paths("loras") raw_paths = folder_paths.get_folder_paths("loras")
self._remember_configured_paths("lora", raw_paths)
unique_paths = self._prepare_lora_paths(raw_paths) unique_paths = self._prepare_lora_paths(raw_paths)
logger.info( logger.info(
"Found LoRA roots:" "Found LoRA roots:"
@@ -1224,6 +1557,8 @@ class Config:
try: try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints") raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet") raw_unet_paths = folder_paths.get_folder_paths("unet")
self._remember_configured_paths("checkpoint", raw_checkpoint_paths)
self._remember_configured_paths("unet", raw_unet_paths)
( (
unique_paths, unique_paths,
self.checkpoints_roots, self.checkpoints_roots,
@@ -1250,6 +1585,7 @@ class Config:
"""Initialize and validate embedding paths from ComfyUI settings""" """Initialize and validate embedding paths from ComfyUI settings"""
try: try:
raw_paths = folder_paths.get_folder_paths("embeddings") raw_paths = folder_paths.get_folder_paths("embeddings")
self._remember_configured_paths("embedding", raw_paths)
unique_paths = self._prepare_embedding_paths(raw_paths) unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info( logger.info(
"Found embedding roots:" "Found embedding roots:"
@@ -1267,6 +1603,248 @@ class Config:
logger.warning(f"Error initializing embedding paths: {e}") logger.warning(f"Error initializing embedding paths: {e}")
return [] return []
def _init_other_paths(self) -> List[str]:
"""Initialize and validate other-model paths from ComfyUI settings.
Iterates the enabled OTHER_MODEL_FOLDER_SUBTYPES keys and pulls each
from ``folder_paths.get_folder_paths(key)`` (in standalone mode the
mock serves arbitrary keys from ``settings.json.folder_paths``).
Legacy aliases the host normalizes onto a canonical key (``clip`` ->
``text_encoders``) are collapsed first so the same folders are not
reported twice.
"""
try:
folder_path_map: Dict[str, List[str]] = {}
for key in self._collapse_legacy_folder_keys(
self._get_enabled_other_folder_keys()
):
try:
folder_path_map[key] = folder_paths.get_folder_paths(key)
except Exception as exc:
logger.debug("Error reading folder paths for '%s': %s", key, exc)
self._remember_configured_paths(
"other", [path for paths in folder_path_map.values() for path in paths]
)
(
unique_paths,
self.other_root_subtypes,
self.other_folder_roots,
) = self._prepare_other_paths(folder_path_map)
logger.info(
"Found other model roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths:
logger.info("No valid other-model folders found in configuration")
return []
return unique_paths
except Exception as e:
logger.warning(f"Error initializing other model paths: {e}")
return []
def _remember_configured_paths(
self, model_type: str, paths: Iterable[str]
) -> None:
"""Record the configured roots of a model type (they may not exist yet).
Replaces the previous value rather than merging: every caller knows the
complete configured set for its type, so a path the user removed must be
forgotten here too.
"""
configured = [
path.strip()
for path in paths or []
if isinstance(path, str) and path.strip()
]
# Tolerate partially constructed instances (`Config.__new__`), which the
# path-resolution tests build to exercise a single initializer.
configured_map = getattr(self, "_configured_root_paths", None)
if configured_map is None:
configured_map = {}
self._configured_root_paths = configured_map
configured_map[model_type] = configured
def configured_roots_for(self, model_type: str) -> List[str]:
"""Configured roots of a model type, including ones that are unavailable.
``get_model_roots()`` on the scanners answers "what can be walked right
now"; this answers "what did the user (or the host) configure", which is
the set that must survive a switched-off drive in the configuration and
the set the refresh menu reports as offline.
"""
return list((getattr(self, "_configured_root_paths", None) or {}).get(model_type, []))
def admit_configured_roots(self) -> List[str]:
"""Append configured roots that became readable again, never removing any.
The live lists are built once at startup (and when a library snapshot is
applied), so a drive plugged in later is invisible until the process is
restarted. Re-running the per-type prepare helpers against the configured
paths and appending only the new entries is what makes "plug the drive
in, scan it" work without a restart. Nothing is ever dropped or
re-ordered — see ``_append_new_paths`` for why order matters.
Returns the paths that were admitted by this call.
"""
admitted: List[str] = []
def _extend(current, candidates) -> List[str]:
merged = _append_new_paths(list(current or []), candidates)
admitted.extend(
path for path in merged if path not in (current or [])
)
return merged
try:
lora_configured = self.configured_roots_for("lora")
if lora_configured:
self.loras_roots = _extend(
self.loras_roots, self._prepare_lora_paths(lora_configured)
)
checkpoint_configured = self.configured_roots_for("checkpoint")
unet_configured = self.configured_roots_for("unet")
if checkpoint_configured or unet_configured:
(
all_roots,
checkpoint_roots,
unet_roots,
) = self._prepare_checkpoint_paths(
checkpoint_configured, unet_configured
)
self.base_models_roots = _extend(self.base_models_roots, all_roots)
self.checkpoints_roots = _extend(self.checkpoints_roots, checkpoint_roots)
self.unet_roots = _extend(self.unet_roots, unet_roots)
embedding_configured = self.configured_roots_for("embedding")
if embedding_configured:
self.embeddings_roots = _extend(
self.embeddings_roots,
self._prepare_embedding_paths(embedding_configured),
)
other_configured = self.configured_roots_for("other")
if other_configured:
(
other_roots,
other_subtypes,
other_per_key,
) = self._prepare_other_paths(
{
key: [
path
for path in self._configured_other_paths_for_key(key)
]
for key in self._get_enabled_other_folder_keys()
}
)
self.other_roots = _extend(self.other_roots, other_roots)
for root, sub_type in other_subtypes.items():
self.other_root_subtypes.setdefault(root, sub_type)
for key, roots in other_per_key.items():
self.other_folder_roots[key] = _append_new_paths(
self.other_folder_roots.get(key, []), roots
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to admit configured roots: %s", exc)
return admitted
if admitted:
logger.info(
"Admitted %d previously unavailable model root(s): %s",
len(admitted),
", ".join(admitted),
)
try:
# Previews of the newly admitted drive must be servable again.
self.refresh_preview_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh preview roots: %s", exc)
return admitted
def _configured_other_paths_for_key(self, key: str) -> List[str]:
"""Configured other-model paths for one folder_paths key (may not exist)."""
try:
return [
path.strip()
for path in folder_paths.get_folder_paths(key) or []
if isinstance(path, str) and path.strip()
]
except Exception:
return []
def refresh_other_roots(self) -> None:
"""Rebuild other-model roots after the management toggles changed.
Called when ``enable_other_models`` / ``enabled_other_sub_types`` are
updated so the scanner immediately reflects the new folder set without
a full application restart.
"""
self.other_roots = self._init_other_paths()
self._rebuild_preview_roots()
def refresh_preview_roots(self) -> None:
"""Rebuild the preview allowlist after path-affecting settings change.
Called when ``sidecar_storage_mode`` / ``sidecar_storage_path`` are
updated so centralized preview assets become servable (or stop being
servable) without a restart.
"""
self._rebuild_preview_roots()
def get_other_models_availability(self) -> Dict[str, Any]:
"""Report the other-model folders the host can actually expose.
Independent of the opt-in ``enable_other_models`` toggle: this answers
"could Other Models management work here at all?". ComfyUI mode almost
always has these folder keys registered, while standalone mode only
knows the keys present in ``settings.json.folder_paths`` - so the UI
uses this to decide whether announcing the feature would be actionable.
Returns:
``{"available": bool, "sub_types": {sub_type: [existing roots]}}``.
A folder only counts when it exists on disk; an empty folder still
counts because CivitAI downloads can target it.
"""
sub_types: Dict[str, List[str]] = {}
try:
keys = self._collapse_legacy_folder_keys(
list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
)
except Exception: # pragma: no cover - defensive
keys = list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
for key in keys:
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
if not sub_type:
continue
try:
raw_paths = folder_paths.get_folder_paths(key)
except Exception as exc:
logger.debug("Error probing folder paths for '%s': %s", key, exc)
continue
bucket = sub_types.setdefault(sub_type, [])
for root in sorted(
self._dedupe_existing_paths(raw_paths or []).values(),
key=lambda path: path.lower(),
):
if root not in bucket:
bucket.append(root)
available_sub_types = {
sub_type: roots for sub_type, roots in sub_types.items() if roots
}
return {
"available": bool(available_sub_types),
"sub_types": available_sub_types,
}
def get_preview_static_url(self, preview_path: str) -> str: def get_preview_static_url(self, preview_path: str) -> str:
if not preview_path: if not preview_path:
return "" return ""
+7 -8
View File
@@ -219,6 +219,7 @@ class LoraManager:
lora_scanner = await ServiceRegistry.get_lora_scanner() lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Initialize recipe scanner if needed # Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner() recipe_scanner = await ServiceRegistry.get_recipe_scanner()
@@ -236,6 +237,10 @@ class LoraManager:
embedding_scanner.initialize_in_background(), embedding_scanner.initialize_in_background(),
name="embedding_cache_init", name="embedding_cache_init",
), ),
asyncio.create_task(
other_scanner.initialize_in_background(),
name="other_cache_init",
),
asyncio.create_task( asyncio.create_task(
recipe_scanner.initialize_in_background(), name="recipe_cache_init" recipe_scanner.initialize_in_background(), name="recipe_cache_init"
), ),
@@ -328,6 +333,7 @@ class LoraManager:
all_roots.update(config.loras_roots) all_roots.update(config.loras_roots)
all_roots.update(config.base_models_roots or []) all_roots.update(config.base_models_roots or [])
all_roots.update(config.embeddings_roots or []) all_roots.update(config.embeddings_roots or [])
all_roots.update(config.other_roots or [])
total_deleted = 0 total_deleted = 0
total_size_freed = 0 total_size_freed = 0
@@ -460,18 +466,11 @@ class LoraManager:
# Cancel any in-flight scanner initialization tasks so thread-pool # Cancel any in-flight scanner initialization tasks so thread-pool
# workers (e.g. _initialize_cache_sync) can break out of their loops # workers (e.g. _initialize_cache_sync) can break out of their loops
# when the server shuts down (e.g. Ctrl+C on WSL). # when the server shuts down (e.g. Ctrl+C on WSL).
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"): for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner"):
scanner = ServiceRegistry.get_service_sync(name) scanner = ServiceRegistry.get_service_sync(name)
if scanner is not None and hasattr(scanner, "cancel_task"): if scanner is not None and hasattr(scanner, "cancel_task"):
scanner.cancel_task() scanner.cancel_task()
logger.debug("LoRA Manager: Cancelled %s", name) logger.debug("LoRA Manager: Cancelled %s", name)
# Close shared aiohttp sessions to avoid "Unclosed client session" warnings
try:
from py.routes.handlers.hf_handlers import close_hf_api_session
await close_hf_api_session()
except Exception as exc:
logger.debug("Error closing HF API session: %s", exc)
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True) logger.error(f"Error during cleanup: {e}", exc_info=True)
+8 -3
View File
@@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = {
"get_lora_scanner": "lora", "get_lora_scanner": "lora",
"get_checkpoint_scanner": "checkpoint", "get_checkpoint_scanner": "checkpoint",
"get_embedding_scanner": "embedding", "get_embedding_scanner": "embedding",
"get_other_scanner": "other",
} }
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys()) SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
@@ -80,8 +81,8 @@ async def _find_scanner_for_model(
async def identify_model_type(model_path: str) -> str: async def identify_model_type(model_path: str) -> str:
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or """Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
``\"embedding\"``) for *model_path*. ``\"embedding\"``, or ``\"other\"``) for *model_path*.
Falls back to ``\"lora\"`` when unknown. Falls back to ``\"lora\"`` when unknown.
""" """
@@ -171,12 +172,16 @@ async def download_preview(
""" """
from ..services.downloader import get_downloader from ..services.downloader import get_downloader
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.sidecar_paths import get_preview_dir
if not url or not url.strip(): if not url or not url.strip():
return None return None
base_name = os.path.splitext(os.path.basename(model_path))[0] base_name = os.path.splitext(os.path.basename(model_path))[0]
preview_dir = os.path.dirname(model_path) preview_dir = get_preview_dir(model_path)
# Centralized mirrors may not exist yet (unlike the model's own directory
# in alongside mode).
os.makedirs(preview_dir, exist_ok=True)
output_path = os.path.join(preview_dir, base_name + ".webp") output_path = os.path.join(preview_dir, base_name + ".webp")
downloader = await get_downloader() downloader = await get_downloader()
+2 -2
View File
@@ -78,7 +78,7 @@ class CheckpointLoaderLM:
# Filter only checkpoint type (not diffusion_model) and format names # Filter only checkpoint type (not diffusion_model) and format names
names = [] names = []
for item in cache.raw_data: for item in list(cache.raw_data):
if item.get("sub_type") == "checkpoint": if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "") file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI # Only offer models that still exist on disk so ComfyUI
@@ -126,7 +126,7 @@ class CheckpointLoaderLM:
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
base_models = set() base_models = set()
for item in cache.raw_data: for item in list(cache.raw_data):
if item.get("sub_type") != "checkpoint": if item.get("sub_type") != "checkpoint":
continue continue
base_model = item.get("base_model") base_model = item.get("base_model")
+451
View File
@@ -0,0 +1,451 @@
"""Load an image and expose locally resolved generation settings."""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.exif_utils import ExifUtils
from ..utils.generation_metadata import (
GenerationMetadata,
MetadataError,
extract_generation_metadata,
finite_number,
split_lora_tags,
)
from ..utils.utils import _format_model_name_for_comfyui
from .checkpoint_loader import CheckpointLoaderLM
DEFAULTS = {
"positive": "", "negative": "", "seed": 0, "steps": 20, "cfg": 7.0,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
}
# An SDXL-sized starter preset inspired by ComfyUI's bottle example. These
# values are explicitly synthetic, never presented as recovered metadata.
EMPTY_IMAGE_DEFAULTS = {
**DEFAULTS,
"positive": "beautiful scenery inside a glass bottle, purple galaxy, intricate miniature landscape, highly detailed",
"negative": "text, watermark",
"width": 1024,
"height": 1024,
}
ALLOWED_OVERRIDES = set(DEFAULTS) | {"model_name", "checkpoint_name", "unet_name", "width", "height", "loras"}
def parse_overrides(text: str) -> dict[str, Any]:
try:
value = json.loads(text or "{}")
except ValueError as exc:
raise MetadataError(f"Invalid overrides_json: {exc}") from exc
if not isinstance(value, dict):
raise MetadataError("overrides_json must be an object")
unknown = set(value) - ALLOWED_OVERRIDES
if unknown:
raise MetadataError(f"Unknown override keys: {', '.join(sorted(unknown))}")
model_keys = [key for key in ("model_name", "checkpoint_name", "unet_name") if key in value]
if len(model_keys) > 1:
raise MetadataError("Specify only one model_name override (checkpoint_name/unet_name are legacy aliases)")
if model_keys:
key = model_keys[0]
name = value.pop(key)
if not isinstance(name, str) or not name.strip():
raise MetadataError("model_name override must be nonempty text")
value["model_name"] = name.strip()
return value
_MODEL_FILE_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf")
def _model_stem(name: str) -> str:
"""Remove a known file extension, retaining dots in model/version names."""
for extension in _MODEL_FILE_EXTENSIONS:
if name.lower().endswith(extension):
return name[:-len(extension)]
return name
def resolve_resource(name: str, resources: list[dict[str, Any]], roots: list[str]) -> dict[str, Any]:
"""Match paths, filenames, then exact catalog aliases; never fuzzy-match."""
if not isinstance(name, str) or not name.strip():
raise MetadataError("Missing model name")
normalized = name.strip().replace("\\", "/")
levels: list[list[dict[str, Any]]] = [[], [], [], []]
for item in resources:
file_path = item.get("file_path")
if not file_path:
continue
path = file_path.replace("\\", "/")
relative = _format_model_name_for_comfyui(file_path, roots).replace("\\", "/")
exact = normalized in (path, relative, _model_stem(path), _model_stem(relative))
basename = normalized.rsplit("/", 1)[-1] == path.rsplit("/", 1)[-1]
stem = _model_stem(normalized.rsplit("/", 1)[-1]) == _model_stem(path.rsplit("/", 1)[-1])
aliases = [item.get("file_name"), item.get("model_name")]
alias = any(
isinstance(value, str) and normalized in (value.strip(), _model_stem(value.strip()))
for value in aliases
)
# Stat only plausible matches, not every file in a large library for
# each LoRA. Missing cached files must never win a match.
if not (exact or basename or stem or alias) or not os.path.isfile(file_path):
continue
if exact:
levels[0].append(item)
if basename:
levels[1].append(item)
if stem:
levels[2].append(item)
if alias:
levels[3].append(item)
for matches in levels:
unique = {os.path.abspath(item["file_path"]): item for item in matches}
if len(unique) == 1:
return next(iter(unique.values()))
if unique:
raise MetadataError(f"Ambiguous local model '{name}': {', '.join(unique)}. Specify its relative path in overrides_json.")
raise MetadataError(f"Model '{name}' could not be matched to an existing file in the local LoRA Manager catalog")
class LoadImageMetadataLM:
NAME = "Load Image Metadata (LoraManager)"
CATEGORY = "Lora Manager/loaders"
DESCRIPTION = (
"Load an image and recover prompts, LoRAs and sampling settings from its metadata. "
"Connect lora_stack to Lora Loader. Convert loader/sampler widgets to inputs for the other outputs. "
"Extraction failures use starter defaults and are shown as ERROR messages in readable_report."
)
# model_name, sampler_name and scheduler select a value from a loader or
# sampler dropdown. They must stay untyped (Any, "*"): ComfyUI rejects a
# "COMBO" (and a "STRING") output linked into the classic list-style combo
# inputs used by Load Checkpoint, KSampler, and the LoRA Manager loaders
# (comfy_execution/validation.py refuses a non-string input type), which
# surfaced as "Return type mismatch between linked nodes" at queue time.
# "*" is the same type ComfyUI's own Primitive node uses to feed widgets.
RETURN_TYPES = (
"IMAGE", "MASK", "STRING", "STRING", "*", "LORA_STACK", "STRING",
"INT", "INT", "FLOAT", "*", "*", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
)
RETURN_NAMES = (
"image", "mask", "positive", "negative", "model_name", "lora_stack", "lora_stack_text",
"seed", "steps", "cfg", "sampler_name", "scheduler", "width", "height", "denoise", "report", "readable_report", "missing_files",
)
FUNCTION = "load_metadata"
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
from nodes import LoadImage # pyright: ignore[reportMissingImports]
return {"required": {
"image": LoadImage.INPUT_TYPES()["required"]["image"],
"sampler_node_id": ("STRING", {"default": "", "tooltip": "Leave empty for a single sampler. Subgraphs: use the full API ID, e.g. 1481:1783 (or 1481/1783). A container or leaf ID works only when unique."}),
"missing_settings": (["use_defaults", "strict"], {"tooltip": "Extraction errors always return defaults and an ERROR report, including for saved strict settings. Unresolved files are listed in missing_files."}),
"overrides_json": ("STRING", {"default": "{}", "multiline": True, "dynamicPrompts": False, "tooltip": 'Explicit replacements, e.g. {"scheduler":"normal", "model_name":"folder/model.safetensors"}. Use "loras": [] to clear the recovered stack.'}),
"prefer_saved_image_metadata": ("BOOLEAN", {"default": True, "tooltip": "Prefer saved A1111-style generation parameters. Disable to select an active workflow sampler; muted/bypassed samplers are excluded."}),
}}
@classmethod
def VALIDATE_INPUTS(cls, image: str, **kwargs: Any) -> bool | str:
if not folder_paths.exists_annotated_filepath(image):
return f"Invalid image file: {image}"
return True
@classmethod
def IS_CHANGED(cls, image: str, **kwargs: Any) -> str:
digest = hashlib.sha256()
with open(folder_paths.get_annotated_filepath(image), "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _source_diagnostics(path: str) -> str:
"""Describe the actual selected file without including prompt contents."""
from PIL import Image
try:
with Image.open(path) as source:
if source.format == "PNG":
source.load()
details = (
f"File: {path}\nFormat: {source.format}; "
f"size: {os.path.getsize(path)} bytes; "
f"metadata keys: {', '.join(sorted(source.info)) or '(none)'}"
)
return details
except (OSError, ValueError) as exc:
return f"File: {path}\nCould not inspect image metadata: {exc}"
@staticmethod
def _library() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
from ..services.service_registry import ServiceRegistry
async def snapshot() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
models = await ServiceRegistry.get_checkpoint_scanner()
loras = await ServiceRegistry.get_lora_scanner()
model_cache = await models.get_cached_data()
lora_cache = await loras.get_cached_data()
return list(model_cache.raw_data), models.get_model_roots(), list(lora_cache.raw_data), loras.get_model_roots()
return CheckpointLoaderLM._run_async(snapshot)
def load_metadata(
self, image: str, sampler_node_id: str = "", missing_settings: str = "use_defaults",
overrides_json: str = "{}", prefer_saved_image_metadata: bool = True,
) -> tuple[Any, ...]:
import comfy.samplers # pyright: ignore[reportMissingImports]
from nodes import LoadImage # pyright: ignore[reportMissingImports]
overrides = parse_overrides(overrides_json)
if missing_settings not in ("strict", "use_defaults"):
raise MetadataError("Invalid missing_settings policy")
path = folder_paths.get_annotated_filepath(image)
pixels, mask = LoadImage().load_image(image)
fields = {}
no_metadata = False
try:
fields = ExifUtils._load_structured_metadata(path)
no_metadata = not any(fields.values())
if no_metadata:
extracted = GenerationMetadata(
values=dict(EMPTY_IMAGE_DEFAULTS),
notes=[
"ERROR: No generation metadata found. Using the SDXL bottle starter preset; these settings were not extracted from the image.",
self._source_diagnostics(path),
],
)
else:
extracted = extract_generation_metadata(fields, sampler_node_id, prefer_saved_image_metadata)
except (ValueError, TypeError, KeyError, OSError, RecursionError) as exc:
error = f"ERROR: Metadata extraction failed: {exc}"
extracted = GenerationMetadata(issues={"source": str(exc)})
# An unsupported API graph need not make valid saved generation
# parameters unusable. Do not execute or infer custom graph nodes.
if (fields.get("prompt") or fields.get("workflow")) and (fields.get("parameters") or fields.get("comment")):
try:
extracted = extract_generation_metadata({
"parameters": fields.get("parameters"), "comment": fields.get("comment"),
})
extracted.notes.append(error + "; recovered saved generation parameters instead.")
if sampler_node_id.strip():
extracted.notes.append("ERROR: Global saved parameters cannot verify the requested sampler stage; they are an image-level fallback.")
except (ValueError, TypeError, KeyError, RecursionError) as fallback_exc:
extracted.notes.append(f"ERROR: Parameter fallback failed: {fallback_exc}")
if "source" in extracted.issues:
extracted.notes.extend([error, self._source_diagnostics(path)])
source_resources = {"checkpoint_name": extracted.values.get("checkpoint_name"), "unet_name": extracted.values.get("unet_name"), "loras": list(extracted.loras), "resource_hints": extracted.resource_hints}
values = extracted.values
notes = extracted.notes
for key, value in overrides.items():
values[key] = value
extracted.issues.pop(key, None)
notes.append(f"Explicit override: {key}.")
if "model_name" in overrides:
extracted.issues.pop("model", None)
values.pop("checkpoint_name", None)
values.pop("unet_name", None)
if "loras" in overrides:
extracted.loras = self._override_loras(overrides["loras"])
notes.extend(f"ERROR: {key}: {message}" for key, message in extracted.issues.items())
# Discard incomplete graph results instead of outputting half a LoRA
# chain or a prompt known to differ from its conditioning.
for key in extracted.issues:
if key not in overrides:
values.pop(key, None)
if "loras" in extracted.issues and "loras" not in overrides:
extracted.loras = []
if "model" in extracted.issues and "model_name" not in overrides:
values.pop("checkpoint_name", None)
values.pop("unet_name", None)
# Extraction without a recognized latent source (e.g. img2img) leaves
# width/height unset; the source image dimensions are the best
# estimate then. The synthetic starter preset keeps its fixed size.
image_fallback = not no_metadata and "source" not in extracted.issues
try:
image_height, image_width = int(pixels.shape[1]), int(pixels.shape[2])
except (AttributeError, IndexError, TypeError, ValueError):
image_fallback = False
for key, default in EMPTY_IMAGE_DEFAULTS.items():
if key in values:
continue
if image_fallback and key in ("width", "height"):
values[key] = image_width if key == "width" else image_height
notes.append(f"WARNING Missing {key}; using source image dimension {values[key]}.")
else:
values[key] = default
notes.append(f"ERROR: Missing {key}; using default {default!r}.")
# Validate independently so one invalid value cannot erase the other
# successfully extracted settings. Invalid explicit overrides still
# identify a user configuration error rather than an extraction error.
for key in EMPTY_IMAGE_DEFAULTS:
trial = {**EMPTY_IMAGE_DEFAULTS, key: values[key]}
try:
self._validate_values(trial, comfy.samplers.KSampler.SAMPLERS, comfy.samplers.KSampler.SCHEDULERS, True, [])
values[key] = trial[key]
except (ValueError, TypeError, OverflowError) as exc:
if key in overrides:
raise MetadataError(f"Invalid override {key}: {exc}") from exc
values[key] = EMPTY_IMAGE_DEFAULTS[key]
notes.append(f"ERROR: Invalid {key}: {exc}; using default {values[key]!r}.")
# Only A1111 directives represent LoRA application. In ComfyUI graphs,
# literal tags in encoder text are not executed by CLIPTextEncode.
for key in ("positive", "negative"):
try:
clean, tags = split_lora_tags(values[key])
except (ValueError, TypeError) as exc:
if key in overrides:
raise MetadataError(f"Invalid override {key}: {exc}") from exc
values[key] = EMPTY_IMAGE_DEFAULTS[key]
notes.append(f"ERROR: Invalid LoRA directive in {key}: {exc}; using starter prompt.")
continue
if tags:
if notes and notes[0] == "A1111/Forge parameters.":
if "loras" not in overrides:
extracted.loras.extend(tags)
values[key] = clean
else:
notes.append(f"Literal LoRA tags retained in {key}; the embedded ComfyUI graph determines the stack.")
try:
models, roots, loras, lora_roots = self._library()
except Exception as exc:
models, roots, loras, lora_roots = [], [], [], []
notes.append(f"ERROR: Local library lookup failed: {exc}. Extracted names remain in source_resources.")
if (no_metadata or "source" in extracted.issues) and "model_name" not in overrides:
base_candidates = [
item for item in models
if item.get("sub_type") == "checkpoint"
and os.path.basename(item.get("file_path", "")).lower() == "sd_xl_base_1.0.safetensors"
and os.path.isfile(item["file_path"])
]
if len(base_candidates) == 1:
values["model_name"] = _format_model_name_for_comfyui(base_candidates[0]["file_path"], roots)
notes.append("Starter checkpoint: indexed sd_xl_base_1.0.safetensors.")
else:
notes.append("Select an SDXL checkpoint manually, or set model_name in overrides_json. No unambiguous SDXL base checkpoint was found.")
missing_entries = []
name = values.get("model_name") or values.get("checkpoint_name") or values.get("unet_name")
values.pop("checkpoint_name", None)
values.pop("unet_name", None)
values["model_name"] = ""
values["model_type"] = ""
if name:
try:
# A1111's generic Model label can refer to either category.
# Search both together so duplicate names remain ambiguous.
available_models = [item for item in models if item.get("sub_type") in ("checkpoint", "diffusion_model")]
item = resolve_resource(name, available_models, roots)
values["model_name"] = _format_model_name_for_comfyui(item["file_path"], roots)
values["model_type"] = item["sub_type"]
notes.append(f"Resolved model_name: {values['model_name']} ({values['model_type']}).")
except MetadataError as exc:
missing_entries.append(f"Model: {name} — {exc}")
notes.append(f"WARNING {exc}; model_name is empty.")
if not values["model_name"]:
notes.append("WARNING No model resolved. Select a model manually on your loader.")
stack = []
for name, model_strength, clip_strength in extracted.loras:
try:
item = resolve_resource(name, loras, lora_roots)
stack.append((os.path.abspath(item["file_path"]), model_strength, clip_strength))
except MetadataError as exc:
missing_entries.append(f"LoRA: {name} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g} — {exc}")
notes.append(f"WARNING Skipped LoRA: {exc}.")
notes.append(f"Resolved {len(stack)} LoRA entries; preserve stack order and avoid adding them again in the loader widget.")
notes.append("Metadata settings do not restore VAE, text encoders, ControlNet, regional conditioning or the original latent pipeline.")
lora_stack_text = "\n".join(
f"{path} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g}"
for path, model_strength, clip_strength in stack
)
missing_files = "\n".join(missing_entries)
report = "\n".join(notes) + "\n\n" + json.dumps({**values, "loras": stack, "lora_stack_text": lora_stack_text, "source_resources": source_resources, "missing_files": missing_files}, ensure_ascii=False, indent=2)
readable_report = self._readable_report(image, values, extracted.loras, stack, source_resources, notes)
return (pixels, mask, values["positive"], values["negative"], values["model_name"],
stack, lora_stack_text, values["seed"], values["steps"],
values["cfg"], values["sampler_name"], values["scheduler"], values["width"],
values["height"], values["denoise"], report, readable_report, missing_files)
@staticmethod
def _readable_report(
image: str, values: dict[str, Any], requested_loras: list[tuple[str, float, float]],
stack: list[tuple[str, float, float]], source: dict[str, Any], notes: list[str],
) -> str:
errors = [note for note in notes if note.startswith("ERROR")]
lines = ["🖼️ IMAGE GENERATION SETTINGS", f"Image: {image}"]
if errors:
lines.extend(["", "❌ ERROR — RECOVERED SETTINGS / DEFAULTS", *errors])
else:
lines.append("✅ Metadata extracted")
lines.extend(["", "📦 MODEL"])
for key, label in (("checkpoint_name", "Checkpoint"), ("unet_name", "UNet")):
if source.get(key):
lines.append(f"{label} recorded in image: {source[key]}")
if values["model_name"]:
lines.append(f"Model resolved locally: {values['model_name']} ({values['model_type']})")
else:
lines.append("No local model resolved.")
lines.extend([
"", "⚙️ SAMPLING", f"Seed: {values['seed']}", f"Steps: {values['steps']}",
f"CFG: {values['cfg']:g}", f"Sampler: {values['sampler_name']}",
f"Scheduler: {values['scheduler']}", f"Size: {values['width']} × {values['height']}",
f"Denoise: {values['denoise']:g}", "", "🧩 LORAS",
])
if requested_loras:
for name, model_strength, clip_strength in requested_loras:
lines.append(f"- {name} (model: {model_strength:g}, CLIP: {clip_strength:g})")
else:
lines.append("No LoRA entries extracted or selected.")
for hint in source.get("resource_hints", []):
if hint.get("name") not in {entry[0] for entry in requested_loras}:
lines.append(f"- Recorded resource: {hint['name']} (strength unresolved)")
lines.append(f"Resolved locally: {len(stack)} of {len(requested_loras)} requested entries.")
lines.extend(["", "➕ POSITIVE PROMPT", values["positive"] or "(empty)",
"", "➖ NEGATIVE PROMPT", values["negative"] or "(empty)",
"", "📋 NOTES AND WARNINGS"])
lines.extend(f"{'❌' if note.startswith('ERROR') else '⚠️' if note.startswith('WARNING') else 'ℹ️'} {note}" for note in notes)
return "\n".join(lines)
@staticmethod
def _override_loras(value: Any) -> list[tuple[str, float, float]]:
if not isinstance(value, list):
raise MetadataError("loras override must be a list of [name, model_strength, clip_strength]")
entries = []
for entry in value:
if not isinstance(entry, list) or len(entry) != 3 or not isinstance(entry[0], str):
raise MetadataError("Each LoRA override must be [name, model_strength, clip_strength]")
entries.append((entry[0], finite_number(entry[1]), finite_number(entry[2])))
return entries
@staticmethod
def _validate_values(values: dict[str, Any], samplers: list[str], schedulers: list[str], strict: bool, notes: list[str]) -> None:
for key in ("positive", "negative"):
if not isinstance(values[key], str):
raise MetadataError(f"{key} must be text")
for key, low, high in (("seed", 0, 2**64 - 1), ("steps", 1, 10000), ("width", 1, 16384), ("height", 1, 16384)):
raw = values[key]
try:
number = int(raw)
if isinstance(raw, bool) or (isinstance(raw, float) and raw != number) or not low <= number <= high:
raise ValueError()
except (ValueError, TypeError, OverflowError) as exc:
raise MetadataError(f"{key} must be an integer between {low} and {high}") from exc
values[key] = number
for key, low, high in (("cfg", 0, 100), ("denoise", 0, 1)):
try:
number = finite_number(values[key])
if not low <= number <= high:
raise ValueError()
except (ValueError, TypeError) as exc:
raise MetadataError(f"{key} must be a finite number between {low} and {high}") from exc
values[key] = number
for key, choices in (("sampler_name", samplers), ("scheduler", schedulers)):
if values[key] not in choices:
if strict:
raise MetadataError(f"Unsupported {key}: {values[key]!r}; set an explicit override")
fallback = DEFAULTS[key]
if fallback not in choices:
raise MetadataError(f"Default {key} {fallback!r} is unavailable in this ComfyUI installation")
notes.append(f"WARNING Replaced unsupported {key} {values[key]!r} with {fallback!r}.")
values[key] = fallback
+4 -1
View File
@@ -1,5 +1,6 @@
import importlib import importlib
import logging import logging
import os
import comfy.sd # pyright: ignore[reportMissingImports] import comfy.sd # pyright: ignore[reportMissingImports]
import comfy.utils # pyright: ignore[reportMissingImports] import comfy.utils # pyright: ignore[reportMissingImports]
@@ -37,7 +38,9 @@ def _collect_stack_entries(lora_stack):
for lora_path, model_strength, clip_strength in lora_stack: for lora_path, model_strength, clip_strength in lora_stack:
lora_name = extract_lora_name(lora_path) lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name) absolute_lora_path, trigger_words = get_lora_info_absolute(
lora_path if os.path.isabs(lora_path) else lora_name
)
entries.append({ entries.append({
"name": lora_name, "name": lora_name,
"absolute_path": absolute_lora_path, "absolute_path": absolute_lora_path,
+16 -12
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import inspect
import re import re
from typing import Any from typing import Any
@@ -22,20 +21,29 @@ def _stack_slot_number(name: str) -> int:
return 1 if letter == "a" else 2 return 1 if letter == "a" else 2
class _LoraStackOptionalInputs: class _LoraStackOptionalInputs(dict):
"""Lookup that preserves explicit optional inputs and dynamic lora_stack slots.""" """Optional-input mapping that also resolves dynamically added stack slots.
Inheriting ``dict`` keeps ``INPUT_TYPES()`` JSON-serializable for ComfyUI's
``/object_info`` route: it serializes the stored entries, exactly as the plain
dict did before. The overridden ``__contains__``/``__getitem__`` let the
execution side resolve ``lora_stack3``-style inputs the frontend adds on
demand. This replaces the previous ``inspect.stack()`` check for the
``get_input_info`` caller, which the registry security scan reports as
anti-debugging.
"""
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None: def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
self._explicit_inputs = explicit_inputs super().__init__(explicit_inputs)
def __contains__(self, item: object) -> bool: def __contains__(self, item: object) -> bool:
if not isinstance(item, str): if not isinstance(item, str):
return False return False
return item in self._explicit_inputs or _is_stack_input(item) return super().__contains__(item) or _is_stack_input(item)
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]: def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
if key in self._explicit_inputs: if super().__contains__(key):
return self._explicit_inputs[key] return super().__getitem__(key)
if _is_stack_input(key): if _is_stack_input(key):
return ( return (
"LORA_STACK", "LORA_STACK",
@@ -71,13 +79,9 @@ class LoraStackCombinerLM:
), ),
} }
stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info":
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
return { return {
"required": {}, "required": {},
"optional": optional_inputs, "optional": _LoraStackOptionalInputs(optional_inputs),
} }
RETURN_TYPES = ("LORA_STACK",) RETURN_TYPES = ("LORA_STACK",)
+31 -13
View File
@@ -1,29 +1,38 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
import inspect
from ..services.wildcard_service import ( from ..services.wildcard_service import (
contains_dynamic_syntax, contains_dynamic_syntax,
get_wildcard_service, get_wildcard_service,
is_trigger_words_input, is_trigger_words_input,
linked_text_requires_rerun,
) )
class _PromptOptionalInputs: class _PromptOptionalInputs(dict):
"""Lookup that preserves explicit optional inputs and dynamic trigger slots.""" """Optional-input mapping that also resolves dynamically added trigger slots.
Inheriting ``dict`` keeps ``INPUT_TYPES()`` JSON-serializable for ComfyUI's
``/object_info`` route: it serializes the stored entries, exactly as the plain
dict did before. The overridden ``__contains__``/``__getitem__`` let the
execution side resolve ``trigger_words3``-style inputs the frontend adds on
demand. This replaces the previous ``inspect.stack()`` check for the
``get_input_info`` caller, which the registry security scan reports as
anti-debugging.
"""
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None: def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
self._explicit_inputs = explicit_inputs super().__init__(explicit_inputs)
def __contains__(self, item: object) -> bool: def __contains__(self, item: object) -> bool:
if not isinstance(item, str): if not isinstance(item, str):
return False return False
return item in self._explicit_inputs or is_trigger_words_input(item) return super().__contains__(item) or is_trigger_words_input(item)
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]: def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
if key in self._explicit_inputs: if super().__contains__(key):
return self._explicit_inputs[key] return super().__getitem__(key)
if is_trigger_words_input(key): if is_trigger_words_input(key):
return ( return (
"STRING", "STRING",
@@ -65,10 +74,6 @@ class PromptLM:
), ),
} }
stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info":
optional_inputs = _PromptOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
return { return {
"required": { "required": {
"text": ( "text": (
@@ -84,7 +89,11 @@ class PromptLM:
{"tooltip": "The CLIP model used for encoding the text."}, {"tooltip": "The CLIP model used for encoding the text."},
), ),
}, },
"optional": optional_inputs, "optional": _PromptOptionalInputs(optional_inputs),
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
} }
RETURN_TYPES = ("CONDITIONING", "STRING") RETURN_TYPES = ("CONDITIONING", "STRING")
@@ -100,10 +109,16 @@ class PromptLM:
text: str, text: str,
clip: Any | None = None, clip: Any | None = None,
seed: int | None = None, seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
**kwargs: Any, **kwargs: Any,
): ):
del clip, kwargs del clip, kwargs
if contains_dynamic_syntax(text) and seed is None: if seed is not None:
return False
if contains_dynamic_syntax(text):
return float("NaN")
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
return float("NaN") return float("NaN")
return False return False
@@ -112,8 +127,11 @@ class PromptLM:
text: str, text: str,
clip: Any, clip: Any,
seed: int | None = None, seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
**kwargs: Any, **kwargs: Any,
): ):
del prompt, unique_id
expanded_text = get_wildcard_service().expand_text(text, seed=seed) expanded_text = get_wildcard_service().expand_text(text, seed=seed)
trigger_words = [] trigger_words = []
+1 -1
View File
@@ -601,7 +601,7 @@ class SaveImageLM:
os.path.basename(name), os.path.basename(name),
os.path.splitext(os.path.basename(name))[0], os.path.splitext(os.path.basename(name))[0],
] ]
for model in getattr(cache, "raw_data", []): for model in list(getattr(cache, "raw_data", [])):
file_name = model.get("file_name") file_name = model.get("file_name")
if file_name in candidates: if file_name in candidates:
return model return model
+29 -4
View File
@@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service from ..services.wildcard_service import (
contains_dynamic_syntax,
get_wildcard_service,
linked_text_requires_rerun,
)
class TextLM: class TextLM:
@@ -34,6 +38,10 @@ class TextLM:
}, },
), ),
}, },
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
} }
RETURN_TYPES = ("STRING",) RETURN_TYPES = ("STRING",)
@@ -42,10 +50,27 @@ class TextLM:
FUNCTION = "process" FUNCTION = "process"
@classmethod @classmethod
def IS_CHANGED(cls, text: str, seed: int | None = None): def IS_CHANGED(
if contains_dynamic_syntax(text) and seed is None: cls,
text: str,
seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
):
if seed is not None:
return False
if contains_dynamic_syntax(text):
return float("NaN")
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
return float("NaN") return float("NaN")
return False return False
def process(self, text: str, seed: int | None = None): def process(
self,
text: str,
seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
):
del prompt, unique_id
return (get_wildcard_service().expand_text(text, seed=seed),) return (get_wildcard_service().expand_text(text, seed=seed),)
+2 -2
View File
@@ -93,7 +93,7 @@ class UNETLoaderLM:
# Filter only diffusion_model type and format names # Filter only diffusion_model type and format names
names = [] names = []
for item in cache.raw_data: for item in list(cache.raw_data):
if item.get("sub_type") == "diffusion_model": if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "") file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI # Only offer models that still exist on disk so ComfyUI
@@ -141,7 +141,7 @@ class UNETLoaderLM:
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
base_models = set() base_models = set()
for item in cache.raw_data: for item in list(cache.raw_data):
if item.get("sub_type") != "diffusion_model": if item.get("sub_type") != "diffusion_model":
continue continue
base_model = item.get("base_model") base_model = item.get("base_model")
+1 -1
View File
@@ -156,7 +156,7 @@ def _find_missing_loras(names: list[str]) -> list[str]:
lookup = {} lookup = {}
basename_candidates = {} basename_candidates = {}
for item in cache.raw_data: for item in list(cache.raw_data):
file_path = item.get("file_path") file_path = item.get("file_path")
if not file_path: if not file_path:
continue continue
+21
View File
@@ -8,6 +8,7 @@ from typing import Dict, Any
from ..base import RecipeMetadataParser from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS from ..constants import GEN_PARAM_KEYS
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...utils.constants import is_empty_placeholder_hash
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -524,6 +525,26 @@ class AutomaticMetadataParser(RecipeMetadataParser):
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0 weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash) lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
if is_empty_placeholder_hash(lora_hash):
# The empty-hash placeholder (SHA256 of an empty byte
# string) is not a real hash: never look it up in the
# local hash index or on CivitAI. Match by filename;
# otherwise keep the item as unresolved (no hash, flagged
# hashInvalid so the UI shows the unresolvable-hash state
# and offers reconnect instead of download) rather than
# dropping it.
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if local_lora:
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
merge_or_append_local(local_entry)
continue
lora_entry['hash'] = ''
lora_entry['hashInvalid'] = True
if not resource_lora_count:
loras.append(lora_entry)
continue
if lora_hash and recipe_scanner and lora_type == 'lora': if lora_hash and recipe_scanner and lora_type == 'lora':
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash) local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
if local_lora: if local_lora:
+22 -1
View File
@@ -196,7 +196,7 @@ class RecipeFormatParser(RecipeMetadataParser):
filtered_gen_params[key] = value filtered_gen_params[key] = value
return { return {
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''), 'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else (recipe_metadata.get('base_model') or None),
'loras': loras, 'loras': loras,
'gen_params': filtered_gen_params, 'gen_params': filtered_gen_params,
'tags': recipe_metadata.get('tags', []), 'tags': recipe_metadata.get('tags', []),
@@ -208,3 +208,24 @@ class RecipeFormatParser(RecipeMetadataParser):
except Exception as e: except Exception as e:
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True) logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []} return {"error": str(e), "loras": []}
def strip_recipe_metadata(metadata_text: str) -> str:
"""Strip the ``Recipe metadata: {...}`` block appended by LoRA Manager.
The saved recipe image carries the original generation metadata followed
by an appended recipe JSON block (see ``ExifUtils.append_recipe_metadata``).
Re-import wants to re-parse the original embedded metadata, so this returns
only the text before the appended marker. The input is returned unchanged
when no marker is present.
"""
if not metadata_text:
return metadata_text
match = re.search(
RecipeFormatParser.METADATA_MARKER,
metadata_text,
re.IGNORECASE | re.DOTALL,
)
if not match:
return metadata_text
return metadata_text[: match.start()].strip()
+23
View File
@@ -24,9 +24,11 @@ from ..services.use_cases import (
AutoOrganizeUseCase, AutoOrganizeUseCase,
BulkMetadataRefreshUseCase, BulkMetadataRefreshUseCase,
DownloadModelUseCase, DownloadModelUseCase,
FilenameTemplateUseCase,
) )
from ..services.websocket_progress_callback import ( from ..services.websocket_progress_callback import (
WebSocketBroadcastCallback, WebSocketBroadcastCallback,
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback, WebSocketProgressCallback,
) )
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
ModelAutoOrganizeHandler, ModelAutoOrganizeHandler,
ModelCivitaiHandler, ModelCivitaiHandler,
ModelDownloadHandler, ModelDownloadHandler,
ModelFilenameTemplateHandler,
ModelHandlerSet, ModelHandlerSet,
ModelListingHandler, ModelListingHandler,
ModelManagementHandler, ModelManagementHandler,
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
self.model_lifecycle_service: ModelLifecycleService | None = None self.model_lifecycle_service: ModelLifecycleService | None = None
self.websocket_progress_callback = WebSocketProgressCallback() self.websocket_progress_callback = WebSocketProgressCallback()
self.metadata_progress_callback = WebSocketBroadcastCallback() self.metadata_progress_callback = WebSocketBroadcastCallback()
self.filename_template_progress_callback = (
WebSocketFilenameTemplateProgressCallback()
)
self._handler_set: ModelHandlerSet | None = None self._handler_set: ModelHandlerSet | None = None
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
@@ -149,6 +155,7 @@ class BaseModelRoutes(ABC):
settings_service=self._settings, settings_service=self._settings,
server_i18n=self._server_i18n, server_i18n=self._server_i18n,
logger=logger, logger=logger,
page_context_provider=self._get_page_context_provider(),
) )
listing = ModelListingHandler( listing = ModelListingHandler(
service=service, service=service,
@@ -201,6 +208,17 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager, ws_manager=self._ws_manager,
logger=logger, logger=logger,
) )
filename_template_use_case = FilenameTemplateUseCase(
scanner=service.scanner,
lifecycle_service=self._ensure_lifecycle_service(),
lock_provider=self._ws_manager,
model_type=service.model_type,
)
filename_template = ModelFilenameTemplateHandler(
use_case=filename_template_use_case,
progress_callback=self.filename_template_progress_callback,
logger=logger,
)
updates = ModelUpdateHandler( updates = ModelUpdateHandler(
service=service, service=service,
update_service=update_service, update_service=update_service,
@@ -217,6 +235,7 @@ class BaseModelRoutes(ABC):
civitai=civitai, civitai=civitai,
move=move, move=move,
auto_organize=auto_organize, auto_organize=auto_organize,
filename_template=filename_template,
updates=updates, updates=updates,
) )
@@ -250,6 +269,10 @@ class BaseModelRoutes(ABC):
"""Get expected model types string for error messages - to be overridden by subclasses.""" """Get expected model types string for error messages - to be overridden by subclasses."""
return "any model type" return "any model type"
def _get_page_context_provider(self):
"""Optional hook returning extra template context for the page view."""
return None
def _find_model_file(self, files): def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses.""" """Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None) return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
@@ -0,0 +1,120 @@
"""HTTP handler for download target routing decisions."""
from __future__ import annotations
import json
import logging
from aiohttp import web
from ...services.download_routing import (
is_diffusion_model_download,
resolve_other_download_sub_type,
)
from ...utils.constants import VALID_OTHER_CIVITAI_TYPES
logger = logging.getLogger(__name__)
class DownloadRoutingHandler:
"""Expose the download-time checkpoint/diffusion-model routing decision.
The web UI calls this when the user reaches the download location step
so the root dropdown offers the same root set (checkpoint vs unet) that
the download manager would pick for ``use_default_paths``.
"""
async def get_download_routing(self, request: web.Request) -> web.Response:
try:
payload = await request.json()
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON payload"}, status=400
)
model_type = payload.get("model_type", "")
base_model = payload.get("base_model") or ""
file_types = payload.get("file_types") or []
selected_file_type = payload.get("selected_file_type")
if not isinstance(model_type, str) or not model_type:
return web.json_response(
{"success": False, "error": "model_type is required"}, status=400
)
if not isinstance(base_model, str) or not isinstance(file_types, list):
return web.json_response(
{
"success": False,
"error": "base_model must be a string and file_types a list",
},
status=400,
)
if selected_file_type is not None and not isinstance(selected_file_type, str):
return web.json_response(
{"success": False, "error": "selected_file_type must be a string"},
status=400,
)
# CivitAI ModelType.UNet downloads go through the checkpoint branch,
# same as in the download manager.
if model_type.lower() == "unet":
model_type = "checkpoint"
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
from ...services.settings_manager import get_settings_manager
settings = get_settings_manager()
if not settings.is_other_models_enabled():
# Opt-in feature is off: never auto-route, the UI falls back to
# manual folder selection and the download manager rejects it.
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": None,
"disabled": True,
"reason": "other_models_disabled",
}
)
sub_type = resolve_other_download_sub_type(
model_type,
file_types=(str(t) for t in file_types),
selected_file_type=selected_file_type,
)
if sub_type and not settings.is_other_sub_type_enabled(sub_type):
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": None,
"disabled": True,
"reason": "other_sub_type_disabled",
"requested_sub_type": sub_type,
}
)
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": sub_type,
}
)
from ...services.settings_manager import get_settings_manager
is_diffusion = is_diffusion_model_download(
model_type,
file_types=(str(t) for t in file_types),
base_model=base_model,
unknown_base_model_default=get_settings_manager().get(
"unknown_base_model_routing", "diffusion_model"
),
)
return web.json_response(
{
"success": True,
"is_diffusion_model": is_diffusion,
"root_kind": "unet" if is_diffusion else model_type,
}
)
-508
View File
@@ -1,508 +0,0 @@
"""Handlers for Hugging Face model listing and download.
Minimal MVP implementation — uses direct HTTP to the HF API for file
listing and the project's existing aiohttp-based Downloader for
downloading. No huggingface_hub dependency required.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
import aiohttp
from aiohttp import web
from ...config import config
from ...services.downloader import (
DownloadProgress,
get_downloader,
)
from ...services.aria2_downloader import Aria2Downloader
from ...services.settings_manager import get_settings_manager
from ...services.service_registry import ServiceRegistry
from ...services.websocket_manager import ws_manager
from ...utils.constants import MODEL_FILE_EXTENSIONS
from ...utils.metadata_manager import MetadataManager
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
logger = logging.getLogger(__name__)
_DEFAULT_MODEL_CLASS = LoraMetadata
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
# Shared aiohttp session for HF API calls (created on first use)
_hf_api_session: aiohttp.ClientSession | None = None
async def _get_hf_api_session() -> aiohttp.ClientSession:
"""Get or create the shared aiohttp session for HF API calls."""
global _hf_api_session # needed because we reassign the module-level name
if _hf_api_session is None or _hf_api_session.closed:
_hf_api_session = aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
)
return _hf_api_session
async def close_hf_api_session() -> None:
"""Close the shared HF API session, if it was ever created."""
global _hf_api_session
if _hf_api_session is not None and not _hf_api_session.closed:
await _hf_api_session.close()
_hf_api_session = None
def _infer_model_type(model_root: str) -> tuple[Any, str]:
"""Determine model class and scanner by matching ``model_root`` against the
configured root paths for each model type (from ``Config``).
The ``model_root`` value comes from the frontend's model-root dropdown,
which is populated from the current page's scanner roots. By checking
which scanner's root list it belongs to, we avoid fragile heuristics
like substring-matching path names.
"""
norm = os.path.normpath(model_root).replace(os.sep, "/")
# LoRA roots
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return LoraMetadata, "get_lora_scanner"
# Checkpoint / UNet roots
for p in (
(config.checkpoints_roots or [])
+ (config.extra_checkpoints_roots or [])
+ (config.unet_roots or [])
+ (config.extra_unet_roots or [])
):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return CheckpointMetadata, "get_checkpoint_scanner"
# Embedding roots
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return EmbeddingMetadata, "get_embedding_scanner"
# Fallback — should not happen in normal use
logger.warning(
"Could not determine model type for root '%s'; defaulting to LoRA",
model_root,
)
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the
SHA256 hash, extracts safetensors header metadata (base_model), and
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
register the model in the in-memory scanner cache so it appears
immediately without a full filesystem walk.
"""
try:
hf_url = f"https://huggingface.co/{repo}"
model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path)
return
# 2. Overlay HF-specific fields
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
metadata_dict = metadata.to_dict()
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
del metadata_dict["trainedWords"]
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata_dict)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache
# model_root is an absolute path; dest_path is under it
folder = ""
if os.path.isabs(model_root) and dest_path.startswith(model_root):
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
if scanner is not None:
metadata_dict = metadata.to_dict()
metadata_dict["hf_url"] = hf_url
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
except Exception as exc:
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
def _find_matching_root(dest_dir: str) -> str | None:
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
all_roots = []
for root_list in (
config.loras_roots or [],
config.extra_loras_roots or [],
config.checkpoints_roots or [],
config.extra_checkpoints_roots or [],
config.unet_roots or [],
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
):
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
# Find the longest matching prefix
match: str | None = None
for root in all_roots:
if norm.startswith(root):
if match is None or len(root) > len(match):
match = root
return match
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
model_dir = os.path.dirname(dest_path)
model_root = _find_matching_root(model_dir)
if not model_root:
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
scanner_getter_name = _infer_model_type(model_root)[1]
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is None:
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
scanner = await scanner_getter()
if scanner is None:
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
class HfHandler:
"""Handle Hugging Face model browsing and download."""
async def set_hf_url(self, request: web.Request) -> web.Response:
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
file_path = (payload.get("file_path") or "").strip()
hf_url = (payload.get("hf_url") or "").strip()
if not file_path or not hf_url:
return web.json_response(
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
status=400,
)
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
if not m:
return web.json_response(
{
"success": False,
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
},
status=400,
)
if not os.path.isfile(file_path):
return web.json_response(
{"success": False, "error": f"File not found: {file_path}"},
status=404,
)
model_root = _find_matching_root(os.path.dirname(file_path))
if not model_root:
return web.json_response(
{
"success": False,
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
},
status=400,
)
try:
existing = await MetadataManager.load_metadata_payload(file_path)
if existing.get("hf_url") == hf_url:
return web.json_response({
"success": True,
"message": "hf_url already set",
"hf_url": hf_url,
})
existing["hf_url"] = hf_url
existing["from_civitai"] = False
await MetadataManager.save_metadata(file_path, existing)
await _add_to_scanner_cache(file_path, existing)
logger.info("Set hf_url=%s for %s", hf_url, file_path)
return web.json_response({
"success": True,
"message": f"hf_url set to {hf_url}",
"hf_url": hf_url,
})
except Exception as exc:
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
return web.json_response(
{"success": False, "error": str(exc)},
status=500,
)
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
"""List model-weight files from a HF repo with real file sizes.
Uses the HF tree API endpoint which returns accurate file sizes
(including LFS-tracked files), unlike the model info endpoint.
"""
repo = request.query.get("repo", "").strip()
if not repo or "/" not in repo:
return web.json_response(
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
status=400,
)
url = f"https://huggingface.co/api/models/{repo}/tree/main"
try:
session = await _get_hf_api_session()
async with session.get(url) as resp:
if resp.status == 404:
return web.json_response(
{"error": f"Repo '{repo}' not found"}, status=404
)
if resp.status != 200:
text = await resp.text()
return web.json_response(
{"error": f"HF API error {resp.status}: {text[:200]}"},
status=resp.status,
)
tree: list[dict[str, Any]] = await resp.json()
except Exception as exc:
logger.error("Failed to fetch HF repo files: %s", exc)
return web.json_response({"error": str(exc)}, status=502)
files: list[dict[str, Any]] = []
for entry in tree:
path: str = entry.get("path", "")
ext = os.path.splitext(path)[1].lower()
if ext not in MODEL_FILE_EXTENSIONS:
continue
size = entry.get("size", 0) or 0
if size == 0 and "lfs" in entry:
size = entry["lfs"].get("size", 0) or 0
files.append({
"filename": path,
"size": size,
})
files.sort(key=lambda f: f["size"], reverse=True)
return web.json_response(files)
async def download_hf_model(self, request: web.Request) -> web.Response:
"""Download a single file from Hugging Face into the model directory.
POST JSON body::
{
"repo": "dx8152/Flux2-Klein-9B-Consistency",
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
"revision": "main",
"model_root": "loras",
"relative_path": "",
"use_default_paths": false,
"download_id": "optional-batch-id"
}
If ``download_id`` is provided, real-time progress (bytes, speed,
percentage) is broadcast via the WebSocket progress system, matching
the CivitAI download experience.
Respects the ``download_backend`` setting (``aria2`` or ``default``).
"""
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON"}, status=400)
repo = (payload.get("repo") or "").strip()
filename = (payload.get("filename") or "").strip()
revision = (payload.get("revision") or "main").strip()
model_root = (payload.get("model_root") or "").strip()
relative_path = (payload.get("relative_path") or "").strip()
use_default_paths = bool(payload.get("use_default_paths", False))
download_id: str | None = payload.get("download_id")
logger.info(
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
repo, filename, model_root, download_id,
)
if not repo or not filename:
return web.json_response(
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
)
# Validate repo format — must be user/repo_name
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
author, repo_name = repo.split("/", 1)
if ".." in (author, repo_name) or "." in (author, repo_name):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
# Validate filename — must not contain path traversal
if ".." in filename:
return web.json_response({"error": "Invalid filename"}, status=400)
# Validate relative_path — must not be absolute or escape base directory
if relative_path:
if os.path.isabs(relative_path):
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
if ".." in relative_path.split("/") or "\\" in relative_path:
return web.json_response({"error": "Invalid relative_path"}, status=400)
# Use model_root directly as the base directory — same approach as
# CivitAI's download path (download_manager.py). No realpath, no
# allowed-roots validation, no path-traversal check; those are
# unnecessary when the frontend sends the path from its own dropdown
# (populated from scanner roots). Using the "business path" directly
# keeps dest_path consistent with scanner roots so that later folder
# derivation (in _save_hf_metadata) works correctly.
if os.path.isabs(model_root):
base_dir = os.path.normpath(model_root)
else:
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
if use_default_paths:
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
elif relative_path:
target_dir = os.path.join(base_dir, relative_path)
else:
target_dir = base_dir
# Strip HF repo subdirectory — "diffusion_models/xxx.safetensors"
# is an HF repo convention, not meaningful for local storage.
file_base = os.path.basename(filename)
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Build HF resolve URL
resolve_url = (
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
)
# Set up progress callback if download_id is provided
progress_callback = None
if download_id:
async def _progress_callback(
progress: float | DownloadProgress,
snapshot: DownloadProgress | None = None,
) -> None:
percent = 0.0
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
if isinstance(progress, DownloadProgress):
percent = progress.percent_complete
metrics = progress
elif isinstance(snapshot, DownloadProgress):
percent = snapshot.percent_complete
else:
percent = float(progress)
broadcast: dict[str, Any] = {
"status": "progress",
"progress": round(percent),
}
if metrics:
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
broadcast["total_bytes"] = metrics.total_bytes
broadcast["bytes_per_second"] = metrics.bytes_per_second
await ws_manager.broadcast_download_progress(download_id, broadcast)
progress_callback = _progress_callback
# Respect download backend setting (aria2 vs default)
download_backend = (
get_settings_manager().get("download_backend", "default")
)
if download_backend == "aria2":
aria2 = await Aria2Downloader.get_instance()
aid = download_id or f"hf_{repo}_{filename}"
try:
hf_success, hf_result = await aria2.download_file(
url=resolve_url,
save_path=dest_path,
download_id=aid,
progress_callback=progress_callback,
)
if hf_success:
await _save_hf_metadata(dest_path, repo, model_root)
return web.json_response({
"success": True,
"message": f"Downloaded to {dest_path}",
"path": dest_path,
})
else:
return web.json_response(
{"success": False, "error": hf_result or "aria2 download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download (aria2) failed: %s", exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
# Default: use built-in aiohttp Downloader
downloader = await get_downloader()
try:
success, result = await downloader.download_file(
url=resolve_url,
save_path=dest_path,
use_auth=False,
allow_resume=True,
progress_callback=progress_callback,
)
if success:
await _save_hf_metadata(dest_path, repo, model_root)
return web.json_response({
"success": True,
"message": f"Downloaded to {result}",
"path": result,
})
else:
return web.json_response(
{"success": False, "error": result or "Download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download failed: %s", exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
+496 -25
View File
@@ -45,7 +45,10 @@ from ...services.llm_service import (
get_provider_model_ids, get_provider_model_ids,
) )
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from ...services.websocket_progress_callback import WebSocketBroadcastCallback
from ...utils.models import BaseModelMetadata from ...utils.models import BaseModelMetadata
from ...utils.civitai_utils import build_civitai_model_page_url
from ...utils.constants import ( from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES, CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
@@ -53,17 +56,29 @@ from ...utils.constants import (
PREVIEW_EXTENSIONS, PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS, SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
folder_path_schema,
) )
from .hf_handlers import HfHandler from .model_source_handlers import ModelSourceHandler
from .agent_handlers import AgentHandler from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.directory_browser import browse_directory
from ...utils.example_images_paths import ( from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root, find_non_compliant_items_in_example_images_root,
is_valid_example_images_root, is_valid_example_images_root,
) )
from ...utils.lora_metadata import extract_trained_words from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.sidecar_paths import (
describe_sidecar_root,
get_configured_sidecar_root,
get_metadata_path,
get_preview_dir,
get_storage_mode,
get_unmatched_sidecar_components,
)
from ...utils.usage_stats import UsageStats from ...utils.usage_stats import UsageStats
from .base_model_handlers import BaseModelHandlerSet from .base_model_handlers import BaseModelHandlerSet
@@ -419,6 +434,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
return None return None
def _has_gui_display() -> bool:
"""Check whether a GUI session is reachable for xdg-open."""
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
class PromptServerProtocol(Protocol): class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
@@ -657,9 +677,21 @@ class HealthCheckHandler:
"lora": ServiceRegistry.get_lora_scanner, "lora": ServiceRegistry.get_lora_scanner,
"checkpoint": ServiceRegistry.get_checkpoint_scanner, "checkpoint": ServiceRegistry.get_checkpoint_scanner,
"embedding": ServiceRegistry.get_embedding_scanner, "embedding": ServiceRegistry.get_embedding_scanner,
"other": ServiceRegistry.get_other_scanner,
"recipe": ServiceRegistry.get_recipe_scanner, "recipe": ServiceRegistry.get_recipe_scanner,
} }
def _active_scanner_getters(
self,
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
getters = self._scanner_getters
if "other" not in getters:
return getters
if get_settings_manager().is_other_models_enabled():
return getters
return {name: getter for name, getter in getters.items() if name != "other"}
async def health_check(self, request: web.Request) -> web.Response: async def health_check(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
@@ -671,7 +703,7 @@ class HealthCheckHandler:
page accepts the update and only reloads once all scanners are done. page accepts the update and only reloads once all scanners are done.
""" """
pending: list[str] = [] pending: list[str] = []
for name, getter in self._scanner_getters.items(): for name, getter in self._active_scanner_getters().items():
try: try:
scanner = await getter() scanner = await getter()
except Exception: except Exception:
@@ -756,10 +788,19 @@ class DoctorHandler:
("lora", "LoRAs", ServiceRegistry.get_lora_scanner), ("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner), ("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner), ("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
("other", "Other Models", ServiceRegistry.get_other_scanner),
) )
) )
self._app_version_getter = app_version_getter self._app_version_getter = app_version_getter
def _active_scanner_factories(
self,
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
if self._settings.is_other_models_enabled():
return self._scanner_factories
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response: async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
try: try:
client_version = (request.query.get("clientVersion") or "").strip() client_version = (request.query.get("clientVersion") or "").strip()
@@ -768,6 +809,7 @@ class DoctorHandler:
await self._check_civitai_api_key(), await self._check_civitai_api_key(),
await self._check_cache_health(), await self._check_cache_health(),
await self._check_filename_conflicts(), await self._check_filename_conflicts(),
self._check_sidecar_mirror_orphans(),
self._check_ui_version(client_version, app_version), self._check_ui_version(client_version, app_version),
] ]
@@ -807,7 +849,7 @@ class DoctorHandler:
repaired: list[dict[str, Any]] = [] repaired: list[dict[str, Any]] = []
failures: list[dict[str, str]] = [] failures: list[dict[str, str]] = []
for model_type, label, factory in self._scanner_factories: for model_type, label, factory in self._active_scanner_factories():
try: try:
scanner = await factory() scanner = await factory()
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True) await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
@@ -839,7 +881,7 @@ class DoctorHandler:
renamed: list[dict[str, Any]] = [] renamed: list[dict[str, Any]] = []
try: try:
for model_type, label, factory in self._scanner_factories: for model_type, label, factory in self._active_scanner_factories():
try: try:
scanner = await factory() scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None) hash_index = getattr(scanner, "_hash_index", None)
@@ -913,15 +955,24 @@ class DoctorHandler:
os.rename(path, new_path) os.rename(path, new_path)
for suffix in (".metadata.json", ".civitai.info"): old_metadata_path = get_metadata_path(path)
old_sidecar = old_base_no_ext + suffix new_metadata_path = get_metadata_path(new_path)
new_sidecar = new_base_no_ext + suffix if os.path.exists(old_metadata_path):
if os.path.exists(old_sidecar): os.rename(old_metadata_path, new_metadata_path)
os.rename(old_sidecar, new_sidecar)
old_sidecar = old_base_no_ext + ".civitai.info"
new_sidecar = new_base_no_ext + ".civitai.info"
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
for preview_ext in PREVIEW_EXTENSIONS: for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_base_no_ext + preview_ext old_preview = os.path.join(
new_preview = new_base_no_ext + preview_ext get_preview_dir(path), base_name + preview_ext
)
new_preview = os.path.join(
get_preview_dir(new_path),
candidate_base + preview_ext,
)
if os.path.exists(old_preview): if os.path.exists(old_preview):
os.rename(old_preview, new_preview) os.rename(old_preview, new_preview)
@@ -933,7 +984,10 @@ class DoctorHandler:
old_preview_url = entry["preview_url"].replace("\\", "/") old_preview_url = entry["preview_url"].replace("\\", "/")
preview_ext = os.path.splitext(old_preview_url)[1] preview_ext = os.path.splitext(old_preview_url)[1]
if preview_ext: if preview_ext:
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/") entry["preview_url"] = os.path.join(
get_preview_dir(new_path),
candidate_base + preview_ext,
).replace(os.sep, "/")
await scanner.update_single_model_cache( await scanner.update_single_model_cache(
path, new_path, entry path, new_path, entry
) )
@@ -992,6 +1046,71 @@ class DoctorHandler:
logger.error("Error exporting doctor bundle: %s", exc, exc_info=True) logger.error("Error exporting doctor bundle: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
def _check_sidecar_mirror_orphans(self) -> dict[str, Any]:
"""Flag centralized sidecars stranded by a moved/removed model root.
Centralized sidecars live under a per-root mirror directory. A root
that was moved, renamed, or dropped from the configuration leaves its
mirror behind; without this check the loss is silent, because the
scanner simply rebuilds default metadata at the new location.
"""
actions = [{"id": "open-settings", "label": "Open Settings"}]
try:
mode = get_storage_mode()
except Exception as exc: # pragma: no cover - defensive fallback
logger.debug("Doctor: sidecar mode lookup failed: %s", exc)
mode = "alongside"
if mode != "centralized":
return {
"id": "sidecar_mirror_orphans",
"title": "Centralized Sidecars",
"status": "ok",
"summary": "Sidecar metadata is stored alongside the models.",
"details": [],
"actions": actions,
}
try:
orphans = get_unmatched_sidecar_components()
except Exception as exc: # pragma: no cover - defensive fallback
logger.warning("Doctor: sidecar orphan check failed: %s", exc)
orphans = []
if not orphans:
return {
"id": "sidecar_mirror_orphans",
"title": "Centralized Sidecars",
"status": "ok",
"summary": "Every mirrored sidecar directory is linked to a model root.",
"details": [f"Root: {describe_sidecar_root().get('root', '')}"],
"actions": actions,
}
details = [
"Metadata (favorites, notes, tags, usage tips) for these models is on disk but is not being read.",
"This usually means a model root was moved, renamed, or removed. Restore the original root path in Settings; the mirror is re-linked automatically.",
]
details.extend(
f"{item['component']} — last known root: {item['last_path'] or 'unknown'}"
for item in orphans[:5]
)
if len(orphans) > 5:
details.append(f"… and {len(orphans) - 5} more")
return {
"id": "sidecar_mirror_orphans",
"title": "Centralized Sidecars",
"status": "warning",
"summary": (
f"{len(orphans)} sidecar "
f"director{'y' if len(orphans) == 1 else 'ies'} could not be "
"linked to a configured model root."
),
"details": details,
"actions": actions,
}
async def _check_civitai_api_key(self) -> dict[str, Any]: async def _check_civitai_api_key(self) -> dict[str, Any]:
api_key = (self._settings.get("civitai_api_key", "") or "").strip() api_key = (self._settings.get("civitai_api_key", "") or "").strip()
if not api_key: if not api_key:
@@ -1071,7 +1190,7 @@ class DoctorHandler:
overall_status = "ok" overall_status = "ok"
summary = "All model caches look healthy." summary = "All model caches look healthy."
for model_type, label, factory in self._scanner_factories: for model_type, label, factory in self._active_scanner_factories():
try: try:
scanner = await factory() scanner = await factory()
persisted = None persisted = None
@@ -1156,7 +1275,7 @@ class DoctorHandler:
total_conflict_groups = 0 total_conflict_groups = 0
total_conflict_files = 0 total_conflict_files = 0
for model_type, label, factory in self._scanner_factories: for model_type, label, factory in self._active_scanner_factories():
# Duplicate filename detection targets LoRAs which use basename-only # Duplicate filename detection targets LoRAs which use basename-only
# syntax (<lora:name:strength>). Checkpoints/embeddings reference # syntax (<lora:name:strength>). Checkpoints/embeddings reference
# models via relative paths with extensions, so conflicts there would # models via relative paths with extensions, so conflicts there would
@@ -1476,6 +1595,7 @@ class SettingsHandler:
# Sensitive — never expose the actual value to the frontend; # Sensitive — never expose the actual value to the frontend;
# frontend receives a boolean instead (*_set). # frontend receives a boolean instead (*_set).
"civitai_api_key", "civitai_api_key",
"huggingface_api_key",
"llm_api_key", "llm_api_key",
} }
) )
@@ -1534,11 +1654,66 @@ class SettingsHandler:
# Sensitive fields: only expose a boolean indicating whether set # Sensitive fields: only expose a boolean indicating whether set
raw_key = self._settings.get("civitai_api_key") raw_key = self._settings.get("civitai_api_key")
response_data["civitai_api_key_set"] = bool(raw_key) response_data["civitai_api_key_set"] = bool(raw_key)
raw_hf_key = self._settings.get("huggingface_api_key")
response_data["huggingface_api_key_set"] = bool(raw_hf_key)
raw_llm_key = self._settings.get("llm_api_key") raw_llm_key = self._settings.get("llm_api_key")
response_data["llm_api_key_set"] = bool(raw_llm_key) response_data["llm_api_key_set"] = bool(raw_llm_key)
# Derived capability flag (not persisted): whether the host exposes
# any other-model folder at all. Standalone installs only know the
# folder_paths keys present in settings.json, so the announcement
# banner uses this to avoid promising a page that cannot list
# anything.
try:
availability = config.get_other_models_availability()
response_data["other_models_paths_available"] = bool(
availability.get("available")
)
except Exception as availability_error: # pragma: no cover - defensive
logger.debug(
"Could not resolve Other Models availability: %s",
availability_error,
)
response_data["other_models_paths_available"] = None
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
response_data["standalone_mode"] = standalone_mode
if standalone_mode:
# Standalone reads its model roots exclusively from
# settings.json, so the Model Paths settings UI needs the
# current values plus the editable-key schema. In plugin mode
# the paths come from the ComfyUI host and stay hidden.
folder_paths = self._settings.get("folder_paths") or {}
# A fresh install is seeded from settings.json.example, whose
# folder_paths are documentation placeholders — hide them so
# the UI starts with empty editors instead of fake paths.
get_placeholders = getattr(
self._settings, "get_template_folder_path_placeholders", None
)
placeholders = get_placeholders() if get_placeholders else set()
if placeholders:
folder_paths = {
key: [p for p in paths if p not in placeholders]
if isinstance(paths, list)
else paths
for key, paths in folder_paths.items()
}
response_data["folder_paths"] = folder_paths
response_data["folder_path_schema"] = folder_path_schema()
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
# Resolved centralized sidecar root (mode-independent): lets the
# settings UI show where sidecars actually live, including when the
# path setting is empty and the default kicks in. inside_repo flags
# the portable-mode hazard (root inside the plugin folder).
try:
sidecar_info = describe_sidecar_root()
response_data["sidecar_storage_root"] = sidecar_info["root"]
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
except Exception as sidecar_error: # pragma: no cover - defensive
logger.debug(
"Could not resolve sidecar storage info: %s", sidecar_error
)
messages_getter: Any = getattr(self._settings, "get_startup_messages", None) messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if messages_getter else [] messages = list(messages_getter()) if messages_getter else []
return web.json_response( return web.json_response(
@@ -1633,6 +1808,7 @@ class SettingsHandler:
if key in ( if key in (
"enable_metadata_archive_db", "enable_metadata_archive_db",
"enable_civarchive_api", "enable_civarchive_api",
"enable_openmodeldb_api",
"metadata_provider_order", "metadata_provider_order",
): ):
await self._metadata_provider_updater() await self._metadata_provider_updater()
@@ -2065,6 +2241,7 @@ class ServiceRegistryAdapter:
get_embedding_scanner: Callable[[], Awaitable[Any]] get_embedding_scanner: Callable[[], Awaitable[Any]]
get_downloaded_version_history_service: Callable[[], Awaitable[Any]] get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
class ModelLibraryHandler: class ModelLibraryHandler:
@@ -2089,6 +2266,8 @@ class ModelLibraryHandler:
return "checkpoint" return "checkpoint"
if normalized in {"embedding", "textualinversion"}: if normalized in {"embedding", "textualinversion"}:
return "embedding" return "embedding"
if normalized in VALID_OTHER_CIVITAI_TYPES:
return "other"
return None return None
async def _get_scanner_for_type(self, model_type: str | None): async def _get_scanner_for_type(self, model_type: str | None):
@@ -2099,6 +2278,13 @@ class ModelLibraryHandler:
return normalized_type, await self._service_registry.get_checkpoint_scanner() return normalized_type, await self._service_registry.get_checkpoint_scanner()
if normalized_type == "embedding": if normalized_type == "embedding":
return normalized_type, await self._service_registry.get_embedding_scanner() return normalized_type, await self._service_registry.get_embedding_scanner()
if normalized_type == "other":
# Opt-in feature: the other scanner only resolves while the master
# switch is on, so callers keep returning the legacy "required"
# error (400) when it is off.
if not get_settings_manager().is_other_models_enabled():
return None, None
return normalized_type, await self._service_registry.get_other_scanner()
return None, None return None, None
async def _get_download_history_service(self): async def _get_download_history_service(self):
@@ -2190,6 +2376,11 @@ class ModelLibraryHandler:
lora_scanner = await self._service_registry.get_lora_scanner() lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner() checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
# Opt-in: probe the other scanner only while Other Models is enabled,
# so the disabled behaviour stays byte-identical to the legacy one.
other_scanner = None
if get_settings_manager().is_other_models_enabled():
other_scanner = await self._service_registry.get_other_scanner()
if model_version_id_str: if model_version_id_str:
try: try:
@@ -2228,6 +2419,13 @@ class ModelLibraryHandler:
exists = True exists = True
model_type = "embedding" model_type = "embedding"
matched_scanner = embedding_scanner matched_scanner = embedding_scanner
elif (
other_scanner
and await other_scanner.check_model_version_exists(model_version_id)
):
exists = True
model_type = "other"
matched_scanner = other_scanner
if exists: if exists:
return web.json_response( return web.json_response(
@@ -2245,7 +2443,7 @@ class ModelLibraryHandler:
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
has_been_downloaded = False has_been_downloaded = False
history_type = None history_type = None
for candidate_type in ("lora", "checkpoint", "embedding"): for candidate_type in ("lora", "checkpoint", "embedding", "other"):
if await history_service.has_been_downloaded( if await history_service.has_been_downloaded(
candidate_type, candidate_type,
model_version_id, model_version_id,
@@ -2267,6 +2465,7 @@ class ModelLibraryHandler:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id) lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
checkpoint_versions = [] checkpoint_versions = []
embedding_versions = [] embedding_versions = []
other_versions = []
if not lora_versions and checkpoint_scanner: if not lora_versions and checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id( checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
model_id model_id
@@ -2275,6 +2474,13 @@ class ModelLibraryHandler:
embedding_versions = await embedding_scanner.get_model_versions_by_id( embedding_versions = await embedding_scanner.get_model_versions_by_id(
model_id model_id
) )
if (
not lora_versions
and not checkpoint_versions
and not embedding_versions
and other_scanner
):
other_versions = await other_scanner.get_model_versions_by_id(model_id)
model_type = None model_type = None
versions = [] versions = []
@@ -2306,9 +2512,18 @@ class ModelLibraryHandler:
"downloadedVersionIds": [], "downloadedVersionIds": [],
} }
) )
if other_versions:
return web.json_response(
{
"success": True,
"modelType": "other",
"versions": self._with_downloaded_flag(other_versions),
"downloadedVersionIds": [],
}
)
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
for candidate_type in ("lora", "checkpoint", "embedding"): for candidate_type in ("lora", "checkpoint", "embedding", "other"):
candidate_downloaded_version_ids = ( candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids( await history_service.get_downloaded_version_ids(
candidate_type, candidate_type,
@@ -2363,6 +2578,11 @@ class ModelLibraryHandler:
lora_scanner = await self._service_registry.get_lora_scanner() lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner() checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
# Opt-in: keep the other probe last so model cards for lora /
# checkpoint / embedding ids are unaffected by the extra scanner.
other_scanner = None
if get_settings_manager().is_other_models_enabled():
other_scanner = await self._service_registry.get_other_scanner()
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for model_id in model_ids: for model_id in model_ids:
@@ -2398,6 +2618,17 @@ class ModelLibraryHandler:
}) })
continue continue
if other_scanner:
other_versions = await other_scanner.get_model_versions_by_id(model_id)
if other_versions:
results.append({
"modelId": model_id,
"modelType": "other",
"versions": self._with_downloaded_flag(other_versions),
"downloadedVersionIds": [],
})
continue
results.append({ results.append({
"modelId": model_id, "modelId": model_id,
"modelType": None, "modelType": None,
@@ -2665,12 +2896,40 @@ class ModelLibraryHandler:
normalized_type, scanner = await self._get_scanner_for_type(model_type) normalized_type, scanner = await self._get_scanner_for_type(model_type)
if not normalized_type: if not normalized_type:
# The lookup cannot be served as a fully interactive list. Two
# cases share this branch: a CivitAI type with no scanner at all
# (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient)
# and an Other-model type while the opt-in master switch is off.
# Answer 200 with the CivitAI list marked read-only plus a
# machine-readable reason, so clients can still show the
# versions and explain why the actions are missing. Legacy
# clients keep working: they only read `success`/`versions`.
reason = (
"other_models_disabled"
if self._normalize_model_type(model_type) == "other"
else "model_type_unsupported"
)
return web.json_response( return web.json_response(
{ {
"success": False, "success": True,
"error": f'Model type "{model_type}" is not supported', "modelId": model_id,
}, "modelName": model_name,
status=400, "modelType": model_type,
"supported": False,
"reason": reason,
"versions": [
{
"id": version.get("id"),
"name": version.get("name", ""),
"thumbnailUrl": version.get("images")[0]["url"]
if version.get("images")
else None,
"inLibrary": False,
"hasBeenDownloaded": False,
}
for version in versions
],
}
) )
if not scanner: if not scanner:
@@ -2712,6 +2971,7 @@ class ModelLibraryHandler:
"modelId": model_id, "modelId": model_id,
"modelName": model_name, "modelName": model_name,
"modelType": model_type, "modelType": model_type,
"supported": True,
"versions": enriched_versions, "versions": enriched_versions,
} }
) )
@@ -2786,12 +3046,32 @@ class ModelLibraryHandler:
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
} }
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES} lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
other_type_aliases = {
model_type.lower() for model_type in VALID_OTHER_CIVITAI_TYPES
}
# Acquire the other scanner lazily so adapters without it only
# fail when the payload actually contains other-type models.
# While the opt-in feature is off the scanner still exists (its
# cache is empty), so other types simply report inLibrary=False.
needs_other_scanner = any(
isinstance(model, dict)
and str(model.get("type", "")).lower() in other_type_aliases
for model in models
)
other_scanner = None
if needs_other_scanner:
other_scanner = await self._service_registry.get_other_scanner()
type_scanner_map: Dict[str, Any] = { type_scanner_map: Dict[str, Any] = {
**{alias: lora_scanner for alias in lora_type_aliases}, **{alias: lora_scanner for alias in lora_type_aliases},
"checkpoint": checkpoint_scanner, "checkpoint": checkpoint_scanner,
"textualinversion": embedding_scanner, "textualinversion": embedding_scanner,
} }
if other_scanner is not None:
type_scanner_map.update(
{alias: other_scanner for alias in other_type_aliases}
)
versions: list[dict[str, Any]] = [] versions: list[dict[str, Any]] = []
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
@@ -2815,12 +3095,17 @@ class ModelLibraryHandler:
"embedding", "embedding",
model_ids, model_ids,
) )
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
"other",
model_ids,
)
downloaded_version_map: Dict[str, Dict[int, set[int]]] = { downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
"lora": lora_downloaded, "lora": lora_downloaded,
"locon": lora_downloaded, "locon": lora_downloaded,
"dora": lora_downloaded, "dora": lora_downloaded,
"checkpoint": checkpoint_downloaded, "checkpoint": checkpoint_downloaded,
"textualinversion": embedding_downloaded, "textualinversion": embedding_downloaded,
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
} }
for model in models: for model in models:
if not isinstance(model, dict): if not isinstance(model, dict):
@@ -3163,6 +3448,18 @@ class FileSystemHandler:
elif sys.platform == "darwin": elif sys.platform == "darwin":
subprocess.Popen(["open", path]) subprocess.Popen(["open", path])
else: else:
if not _has_gui_display():
# Headless/SSH session: xdg-open cannot open a file
# manager, so hand the path to the browser for copying
# instead of reporting a success that never happened.
return web.json_response(
{
"success": True,
"message": "Headless session: path available for copying",
"path": path,
"mode": "clipboard",
}
)
subprocess.Popen(["xdg-open", path]) subprocess.Popen(["xdg-open", path])
return web.json_response( return web.json_response(
@@ -3274,6 +3571,18 @@ class FileSystemHandler:
subprocess.Popen(["open", "-R", settings_file]) subprocess.Popen(["open", "-R", settings_file])
else: else:
folder = os.path.dirname(settings_file) folder = os.path.dirname(settings_file)
if not _has_gui_display():
# Headless/SSH session: xdg-open cannot open a file
# manager, so hand the path to the browser for copying
# instead of reporting a success that never happened.
return web.json_response(
{
"success": True,
"message": "Headless session: path available for copying",
"path": settings_file,
"mode": "clipboard",
}
)
subprocess.Popen(["xdg-open", folder]) subprocess.Popen(["xdg-open", folder])
return web.json_response( return web.json_response(
@@ -3307,6 +3616,94 @@ class FileSystemHandler:
logger.error("Failed to open wildcards location: %s", exc, exc_info=True) logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def open_sidecar_location(self, request: web.Request) -> web.Response:
"""Open the centralized sidecar storage root in the file manager."""
try:
root = get_configured_sidecar_root()
if not root:
return web.json_response(
{"success": False, "error": "Sidecar storage root is not resolvable"},
status=404,
)
# Create on demand so the button also works before the first
# migration/download has materialized the directory.
os.makedirs(root, exist_ok=True)
return await self._open_path(root)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory for the settings-UI directory picker."""
try:
data = await request.json()
payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to browse directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def validate_path(self, request: web.Request) -> web.Response:
"""Validate a filesystem path for the settings UI.
A well-formed request always returns HTTP 200; invalid paths are
reported via ``error_code`` in the payload. HTTP 400 is reserved for
malformed requests (missing path, invalid JSON).
"""
try:
data = await request.json()
raw_path = data.get("path")
expect = data.get("expect", "directory")
if not raw_path or not isinstance(raw_path, str):
return web.json_response(
{"success": False, "error": "Missing path parameter"}, status=400
)
# Business path convention: abspath only, never realpath.
path = os.path.abspath(os.path.expanduser(raw_path))
exists = os.path.exists(path)
is_directory = os.path.isdir(path) if exists else False
readable = bool(exists and os.access(path, os.R_OK))
writable = bool(exists and os.access(path, os.W_OK))
error_code = None
if not exists:
error_code = "path_not_found"
elif expect == "directory" and not is_directory:
error_code = "not_a_directory"
elif expect == "file" and not os.path.isfile(path):
error_code = "not_a_file"
elif not readable:
error_code = "not_readable"
elif not writable:
error_code = "not_writable"
return web.json_response(
{
"success": True,
"path": path,
"exists": exists,
"is_directory": is_directory,
"readable": readable,
"writable": writable,
"error_code": error_code,
}
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to validate path: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class CustomWordsHandler: class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex.""" """Handler for autocomplete via TagFTSIndex."""
@@ -3859,6 +4256,64 @@ class NodeRegistryHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class SidecarMigrationHandler:
"""Migrate sidecar metadata and previews between storage layouts."""
_VALID_DIRECTIONS = ("to_centralized", "to_alongside", "relocate_root")
def __init__(
self,
*,
use_case_factory: Callable[[], SidecarMigrationUseCase] = SidecarMigrationUseCase,
progress_callback_factory: Callable[[], Any] = WebSocketBroadcastCallback,
) -> None:
self._use_case_factory = use_case_factory
self._progress_callback_factory = progress_callback_factory
async def migrate_sidecars(self, request: web.Request) -> web.Response:
"""Run a sidecar migration; accepts POST JSON or GET query params."""
try:
if request.method == "GET":
params: Mapping[str, Any] = request.query
else:
try:
params = await request.json()
except Exception: # empty/invalid body: fall back to query
params = request.query
direction = str(params.get("direction") or "").strip()
if direction not in self._VALID_DIRECTIONS:
return web.json_response(
{
"success": False,
"error": "direction must be 'to_centralized', 'to_alongside' or 'relocate_root'",
},
status=400,
)
force = params.get("force") in (True, 1, "true", "1")
old_root = str(params.get("old_root") or "").strip()
if direction == "relocate_root" and not old_root:
return web.json_response(
{"success": False, "error": "old_root is required for relocate_root"},
status=400,
)
use_case = self._use_case_factory()
progress_cb = self._progress_callback_factory()
result = await use_case.execute_with_error_handling(
direction=direction,
progress_cb=progress_cb,
force=force,
old_root=old_root,
)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
logger.error("Sidecar migration failed: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MiscHandlerSet: class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar.""" """Aggregate handlers into a lookup compatible with the registrar."""
@@ -3882,8 +4337,10 @@ class MiscHandlerSet:
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet, base_model: BaseModelHandlerSet,
hf_handler: Any = None, model_source_handler: Any = None,
agent_handler: Any = None, agent_handler: Any = None,
download_routing: Any = None,
sidecar_migration: Any = None,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
@@ -3902,8 +4359,10 @@ class MiscHandlerSet:
self.doctor = doctor self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
self.base_model = base_model self.base_model = base_model
self.hf_handler = hf_handler self.model_source_handler = model_source_handler
self.agent_handler = agent_handler self.agent_handler = agent_handler
self.download_routing = download_routing
self.sidecar_migration = sidecar_migration
def to_route_mapping( def to_route_mapping(
self, self,
@@ -3949,19 +4408,30 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location, "open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location, "open_wildcards_location": self.filesystem.open_wildcards_location,
"open_sidecar_location": self.filesystem.open_sidecar_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words, "search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards, "search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows, "get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow, "get_example_workflow": self.example_workflows.get_example_workflow,
# Hugging Face handlers # Hugging Face handlers
"get_hf_repo_files": self.hf_handler.get_hf_repo_files, # External model sources (Hugging Face / ModelScope)
"download_hf_model": self.hf_handler.download_hf_model, "list_model_source_files": self.model_source_handler.list_model_source_files,
"set_hf_url": self.hf_handler.set_hf_url, "download_model_source": self.model_source_handler.download_model_source,
"get_hf_repo_files": self.model_source_handler.list_model_source_files,
"download_hf_model": self.model_source_handler.download_model_source,
"set_hf_url": self.model_source_handler.set_hf_url,
"get_model_sources": self.model_source_handler.get_model_sources,
# Agent skill handlers # Agent skill handlers
"get_agent_skills": self.agent_handler.get_agent_skills, "get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill, "execute_agent_skill": self.agent_handler.execute_agent_skill,
"cancel_agent_skill": self.agent_handler.cancel_agent_skill, "cancel_agent_skill": self.agent_handler.cancel_agent_skill,
# Download routing handler
"get_download_routing": self.download_routing.get_download_routing,
# Sidecar migration handler
"migrate_sidecars": self.sidecar_migration.migrate_sidecars,
# Base model handlers # Base model handlers
"get_base_models": self.base_model.get_base_models, "get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models, "refresh_base_models": self.base_model.refresh_base_models,
@@ -3975,6 +4445,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_lora_scanner=ServiceRegistry.get_lora_scanner, get_lora_scanner=ServiceRegistry.get_lora_scanner,
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner, get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
get_embedding_scanner=ServiceRegistry.get_embedding_scanner, get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
get_other_scanner=ServiceRegistry.get_other_scanner,
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
get_backup_service=ServiceRegistry.get_backup_service, get_backup_service=ServiceRegistry.get_backup_service,
) )
+388 -26
View File
@@ -15,13 +15,21 @@ from aiohttp import web
import jinja2 import jinja2
from ...config import config from ...config import config
from ...services.active_filters_store import (
ActiveFiltersStore,
active_filters_to_query_kwargs,
)
from ...services.download_coordinator import DownloadCoordinator from ...services.download_coordinator import DownloadCoordinator
from ...services.connectivity_guard import ( from ...services.connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE, OFFLINE_FRIENDLY_MESSAGE,
is_expected_offline_error, is_expected_offline_error,
) )
from ...services.metadata_sync_service import MetadataSyncService from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService from ...services.model_file_service import (
ModelMoveService,
normalize_relative_folder,
)
from ...services.model_scanner import ReconcileScope
from ...services.preview_asset_service import PreviewAssetService from ...services.preview_asset_service import PreviewAssetService
from ...services.service_registry import ServiceRegistry from ...services.service_registry import ServiceRegistry
from ...services.settings_manager import SettingsManager, get_settings_manager from ...services.settings_manager import SettingsManager, get_settings_manager
@@ -33,15 +41,22 @@ from ...services.use_cases import (
DownloadModelEarlyAccessError, DownloadModelEarlyAccessError,
DownloadModelUseCase, DownloadModelUseCase,
DownloadModelValidationError, DownloadModelValidationError,
FilenameTemplateUseCase,
MetadataRefreshProgressReporter, MetadataRefreshProgressReporter,
) )
from ...services.websocket_manager import WebSocketManager from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback from ...services.websocket_progress_callback import (
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ...services.download_queue_service import DownloadQueueService from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256 from ...utils.file_utils import calculate_sha256
from ...utils.metadata_manager import MetadataManager from ...utils.metadata_manager import MetadataManager
from ...utils.paid_access import is_early_access_deadline_active
from ...utils.sidecar_paths import get_metadata_path
from ...utils.url_utils import relative_root_prefix
LICENSE_FIELDS = ( LICENSE_FIELDS = (
"allowNoCredit", "allowNoCredit",
@@ -86,6 +101,7 @@ class ModelPageView:
settings_service: SettingsManager, settings_service: SettingsManager,
server_i18n, server_i18n,
logger: logging.Logger, logger: logging.Logger,
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
) -> None: ) -> None:
self._template_env = template_env self._template_env = template_env
self._template_name = template_name self._template_name = template_name
@@ -93,6 +109,7 @@ class ModelPageView:
self._settings = settings_service self._settings = settings_service
self._server_i18n = server_i18n self._server_i18n = server_i18n
self._logger = logger self._logger = logger
self._page_context_provider = page_context_provider
def _load_supporters(self) -> dict[str, Any]: def _load_supporters(self) -> dict[str, Any]:
"""Load supporters data from JSON file.""" """Load supporters data from JSON file."""
@@ -194,6 +211,7 @@ class ModelPageView:
"version": self._get_app_version(), "version": self._get_app_version(),
"provider_presets_json": json.dumps(PROVIDER_PRESETS), "provider_presets_json": json.dumps(PROVIDER_PRESETS),
"provider_models_json": "{}", "provider_models_json": "{}",
"rel_prefix": relative_root_prefix(request.path),
} }
if not is_initializing: if not is_initializing:
@@ -206,6 +224,16 @@ class ModelPageView:
self._logger.error("Error loading cache data: %s", cache_error) self._logger.error("Error loading cache data: %s", cache_error)
template_context["is_initializing"] = True template_context["is_initializing"] = True
if self._page_context_provider is not None:
try:
extra_context = self._page_context_provider(request)
if isinstance(extra_context, dict):
template_context.update(extra_context)
except Exception as context_error: # pragma: no cover - logging path
self._logger.error(
"Error building page context: %s", context_error
)
rendered = self._template_env.get_template(self._template_name).render( rendered = self._template_env.get_template(self._template_name).render(
**template_context **template_context
) )
@@ -364,7 +392,6 @@ class ModelListingHandler:
== "true", == "true",
"tags": request.query.get("search_tags", "false").lower() == "true", "tags": request.query.get("search_tags", "false").lower() == "true",
"creator": request.query.get("search_creator", "false").lower() == "true", "creator": request.query.get("search_creator", "false").lower() == "true",
"hash": request.query.get("search_hash", "false").lower() == "true",
"recursive": request.query.get("recursive", "true").lower() == "true", "recursive": request.query.get("recursive", "true").lower() == "true",
} }
@@ -654,7 +681,7 @@ class ModelManagementHandler:
status=400, status=400,
) )
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
local_metadata = await self._metadata_sync.load_local_metadata( local_metadata = await self._metadata_sync.load_local_metadata(
metadata_path metadata_path
) )
@@ -1116,8 +1143,50 @@ class ModelQueryHandler:
async def scan_models(self, request: web.Request) -> web.Response: async def scan_models(self, request: web.Request) -> web.Response:
try: try:
full_rebuild = request.query.get("full_rebuild", "false").lower() == "true" full_rebuild = request.query.get("full_rebuild", "false").lower() == "true"
await self._service.scan_models( requested_roots = [
force_refresh=True, rebuild_cache=full_rebuild value for value in request.query.getall("roots", []) if value
]
raw_folder = (request.query.get("folder") or "").strip()
if (requested_roots or raw_folder) and full_rebuild:
return web.json_response(
{
"error": "Scoped scans are not supported with "
"full_rebuild=true; a full rebuild always walks every root"
},
status=400,
)
folder = None
if raw_folder:
try:
folder = normalize_relative_folder(raw_folder)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
# A drive that was plugged in after startup must be scannable without
# a restart, and callers that never open the Refresh menu (the browser
# extension) have no other chance to admit it.
self._service.refresh_model_roots()
if requested_roots:
configured = self._service.get_model_roots()
unknown = [root for root in requested_roots if root not in configured]
if unknown:
return web.json_response(
{"error": "Unknown model root(s)", "roots": unknown}, status=400
)
# `folder` alone means "this relative folder in every root that has
# it" — the sidebar's unified tree carries no root identity.
scope = None
if requested_roots or folder:
scope = ReconcileScope(
roots=tuple(requested_roots) if requested_roots else None,
folder=folder,
)
summary = await self._service.scan_models(
force_refresh=True, rebuild_cache=full_rebuild, scope=scope
) )
_broadcast_models_changed() _broadcast_models_changed()
if self._service.scanner.is_cancelled(): if self._service.scanner.is_cancelled():
@@ -1127,12 +1196,13 @@ class ModelQueryHandler:
"message": f"{self._service.model_type.capitalize()} scan cancelled", "message": f"{self._service.model_type.capitalize()} scan cancelled",
} }
) )
return web.json_response( payload: Dict[str, Any] = {
{ "status": "success",
"status": "success", "message": f"{self._service.model_type.capitalize()} scan completed",
"message": f"{self._service.model_type.capitalize()} scan completed", }
} if summary:
) payload.update(summary)
return web.json_response(payload)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
"Error scanning %ss: %s", self._service.model_type, exc, exc_info=True "Error scanning %ss: %s", self._service.model_type, exc, exc_info=True
@@ -1141,8 +1211,18 @@ class ModelQueryHandler:
async def get_model_roots(self, request: web.Request) -> web.Response: async def get_model_roots(self, request: web.Request) -> web.Response:
try: try:
# A drive plugged in after startup is admitted here, so the menu shows
# it as a normal row instead of "unavailable" until the next restart.
self._service.refresh_model_roots()
roots = self._service.get_model_roots() roots = self._service.get_model_roots()
return web.json_response({"success": True, "roots": roots}) try:
root_details = self._service.describe_model_roots()
except Exception as exc: # pragma: no cover - defensive
self._logger.debug("Root details unavailable: %s", exc)
root_details = []
return web.json_response(
{"success": True, "roots": roots, "root_details": root_details}
)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
"Error getting %s roots: %s", "Error getting %s roots: %s",
@@ -1595,12 +1675,50 @@ class ModelQueryHandler:
allow_selling_generated_content.lower() not in ("false", "0", "") allow_selling_generated_content.lower() not in ("false", "0", "")
) )
# When requested, merge the manager page's active filters stored
# server-side. Explicit query parameters take precedence over the
# stored values.
use_active_filters = (
request.query.get("use_active_filters", "").lower() in ("1", "true")
)
if use_active_filters:
stored = ActiveFiltersStore.get_instance().get_filters(
self._service.model_type
)
injected = active_filters_to_query_kwargs(stored)
if folder is None and "folder" in injected:
folder = injected["folder"]
if "recursive" not in request.query and "recursive" in injected:
recursive = injected["recursive"]
if not base_models and injected.get("base_models"):
base_models = injected["base_models"]
if not model_types and injected.get("model_types"):
model_types = injected["model_types"]
if not tag_filters and injected.get("tags"):
tag_filters = injected["tags"]
if not auto_tag_filters and injected.get("auto_tags"):
auto_tag_filters = injected["auto_tags"]
if "tag_logic" not in request.query and injected.get("tag_logic"):
injected_logic = str(injected["tag_logic"]).lower()
if injected_logic in ("any", "all"):
tag_logic = injected_logic
if credit_required is None and "credit_required" in injected:
credit_required = injected["credit_required"]
if (
allow_selling_generated_content is None
and "allow_selling_generated_content" in injected
):
allow_selling_generated_content = injected[
"allow_selling_generated_content"
]
# The presence of the recursive param (always sent by the loras # The presence of the recursive param (always sent by the loras
# widget when filter mode is on) signals that the filter pipeline # widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings # must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint. # like show_only_sfw stay consistent with the list endpoint.
apply_filters = ( apply_filters = (
"recursive" in request.query use_active_filters
or "recursive" in request.query
or folder is not None or folder is not None
or bool(base_models) or bool(base_models)
or bool(model_types) or bool(model_types)
@@ -1634,6 +1752,50 @@ class ModelQueryHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_active_filters(self, request: web.Request) -> web.Response:
"""Store the manager page's active filters for this model type."""
try:
payload = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
if not isinstance(payload, dict):
return web.json_response(
{"success": False, "error": "Body must be a JSON object"}, status=400
)
try:
ActiveFiltersStore.get_instance().set_filters(
self._service.model_type, payload
)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error(
"Error updating active filters for %s: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_active_filters(self, request: web.Request) -> web.Response:
"""Return the stored active filters for this model type."""
try:
filters = ActiveFiltersStore.get_instance().get_filters(
self._service.model_type
)
return web.json_response({"success": True, "filters": filters})
except Exception as exc:
self._logger.error(
"Error getting active filters for %s: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelDownloadHandler: class ModelDownloadHandler:
"""Coordinate downloads and progress reporting.""" """Coordinate downloads and progress reporting."""
@@ -1656,7 +1818,8 @@ class ModelDownloadHandler:
payload = await request.json() payload = await request.json()
result = await self._download_use_case.execute(payload) result = await self._download_use_case.execute(payload)
if not result.get("success", False): if not result.get("success", False):
return web.json_response(result, status=500) status = 429 if result.get("reason") == "rate_limited" else 500
return web.json_response(result, status=status)
return web.json_response(result) return web.json_response(result)
except DownloadModelValidationError as exc: except DownloadModelValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400) return web.json_response({"success": False, "error": str(exc)}, status=400)
@@ -1714,7 +1877,8 @@ class ModelDownloadHandler:
mock_request = type("MockRequest", (), {"json": lambda self=None: future})() mock_request = type("MockRequest", (), {"json": lambda self=None: future})()
result = await self._download_use_case.execute(data) result = await self._download_use_case.execute(data)
if not result.get("success", False): if not result.get("success", False):
return web.json_response(result, status=500) status = 429 if result.get("reason") == "rate_limited" else 500
return web.json_response(result, status=status)
return web.json_response(result) return web.json_response(result)
except DownloadModelValidationError as exc: except DownloadModelValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400) return web.json_response({"success": False, "error": str(exc)}, status=400)
@@ -1812,6 +1976,11 @@ class ModelDownloadHandler:
response_payload["status"] = status response_payload["status"] = status
if "message" in progress_data: if "message" in progress_data:
response_payload["message"] = progress_data["message"] response_payload["message"] = progress_data["message"]
# Post-transfer stage (indexing / source metadata); polling
# consumers need it to tell "working" from "stuck".
for field in ("stage", "platform"):
if field in progress_data:
response_payload[field] = progress_data[field]
elif status is None and "message" in progress_data: elif status is None and "message" in progress_data:
response_payload["message"] = progress_data["message"] response_payload["message"] = progress_data["message"]
@@ -2381,6 +2550,106 @@ class ModelMoveHandler:
self._move_service = move_service self._move_service = move_service
self._logger = logger self._logger = logger
async def create_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
result = await self._move_service.create_folder(folder_path)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error creating folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def resolve_folder(self, request: web.Request) -> web.Response:
"""Resolve a library-relative folder onto the roots that hold it.
The unified folder tree is a merged relative-path namespace, so the
client cannot tell which root a node came from; this is what lets the
sidebar act on the directory the user actually sees.
"""
try:
folder = request.query.get("folder", "")
result = self._move_service.resolve_folder(folder)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error resolving folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
dry_run = bool(data.get("dry_run"))
result = await self._move_service.delete_folder(
folder_path, dry_run=dry_run
)
if result.get("success"):
if not dry_run:
_broadcast_models_changed()
return web.json_response(result, status=200)
# "not_empty" / "busy" are conflicts between the tree the client
# rendered and the on-disk truth; everything else is a bad request.
code = result.get("code")
status = 409 if code in ("not_empty", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
new_name = data.get("new_name")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
if not new_name:
return web.json_response(
{"success": False, "error": "New folder name is required"}, status=400
)
result = await self._move_service.rename_folder(folder_path, new_name)
if result.get("success"):
if result.get("renamed"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# A name collision or a staged delete inside the subtree is a
# conflict with the state the client rendered, not a bad request.
code = result.get("code")
status = 409 if code in ("target_exists", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_model(self, request: web.Request) -> web.Response: async def move_model(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -2505,6 +2774,71 @@ class ModelAutoOrganizeHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelFilenameTemplateHandler:
"""Apply the configured filename template to existing library models."""
def __init__(
self,
*,
use_case: FilenameTemplateUseCase,
progress_callback: WebSocketFilenameTemplateProgressCallback,
logger: logging.Logger,
) -> None:
self._use_case = use_case
self._progress_callback = progress_callback
self._logger = logger
async def apply_filename_template(self, request: web.Request) -> web.Response:
try:
file_paths = None
if request.method == "POST":
try:
data = await request.json()
file_paths = data.get("file_paths")
except Exception: # pragma: no cover - permissive path
pass
else:
# GET variant (browser extension is GET-only): comma-separated
# file_paths query parameter.
raw_file_paths = request.query.get("file_paths")
if raw_file_paths:
file_paths = [
path.strip()
for path in raw_file_paths.split(",")
if path.strip()
]
result = await self._use_case.execute(
file_paths=file_paths,
progress_callback=self._progress_callback,
)
_broadcast_models_changed()
return web.json_response(result.to_dict())
except AutoOrganizeInProgressError:
return web.json_response(
{
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
},
status=409,
)
except Exception as exc:
self._logger.error(
"Error in apply_filename_template: %s", exc, exc_info=True
)
try:
await self._progress_callback.on_progress(
{
"type": "filename_template_progress",
"status": "error",
"error": str(exc),
}
)
except Exception: # pragma: no cover - defensive reporting
pass
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelUpdateHandler: class ModelUpdateHandler:
"""Handle update tracking requests.""" """Handle update tracking requests."""
@@ -2681,6 +3015,20 @@ class ModelUpdateHandler:
same_base_scope = self._uses_same_base_update_scope() same_base_scope = self._uses_same_base_update_scope()
# Gate/price transitions are reported for every refreshed model, not only
# for the ones that qualify as updates: "this version became free" matters
# for a version the user already has, which never shows up as an update.
events = []
for record in records.values():
for event in getattr(record, "events", None) or []:
events.append(
{
"modelId": record.model_id,
"modelType": record.model_type,
**event,
}
)
serialized_records = [] serialized_records = []
for record in records.values(): for record in records.values():
has_update_fn = getattr(record, "has_update", None) has_update_fn = getattr(record, "has_update", None)
@@ -2702,6 +3050,7 @@ class ModelUpdateHandler:
{ {
"success": True, "success": True,
"records": serialized_records, "records": serialized_records,
"events": events,
} }
) )
@@ -3131,6 +3480,7 @@ class ModelUpdateHandler:
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid, hide_paid=hide_paid,
), ),
"events": list(getattr(record, "events", None) or []),
"versions": [ "versions": [
self._serialize_version(version, context.get(version.version_id)) self._serialize_version(version, context.get(version.version_id))
for version in record.versions for version in record.versions
@@ -3154,16 +3504,11 @@ class ModelUpdateHandler:
if getattr(version, "is_paid", False) and not version.early_access_ends_at: if getattr(version, "is_paid", False) and not version.early_access_ends_at:
is_early_access = False is_early_access = False
elif version.early_access_ends_at: elif version.early_access_ends_at:
try: # Shared with the update service and the download gate so the badge,
from datetime import datetime, timezone # the update filter and the download warning cannot disagree.
is_early_access = is_early_access_deadline_active(
ea_date = datetime.fromisoformat( version.early_access_ends_at
version.early_access_ends_at.replace("Z", "+00:00") )
)
is_early_access = ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
is_early_access = True
elif getattr(version, "is_early_access", False): elif getattr(version, "is_early_access", False):
# Fallback to basic EA flag from bulk API # Fallback to basic EA flag from bulk API
is_early_access = True is_early_access = True
@@ -3190,6 +3535,15 @@ class ModelUpdateHandler:
"usageControl": version.usage_control, "usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)), "isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload, "paidAccess": paid_access_payload,
# Set when a version that used to be gated became free, so the UI can
# keep showing "Free" long after the transition.
"gateLapsedAt": getattr(version, "gate_lapsed_at", None),
"priceBuzz": getattr(version, "price_buzz", None),
"listPriceBuzz": getattr(version, "list_price_buzz", None),
"generationPriceBuzz": getattr(version, "generation_price_buzz", None),
"acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)),
"priceSaleEndsAt": getattr(version, "price_sale_ends_at", None),
"priceCheckedAt": getattr(version, "price_checked_at", None),
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
# Weight-file variant count (None when unknown); lets the UI hide # Weight-file variant count (None when unknown); lets the UI hide
@@ -3272,6 +3626,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler civitai: ModelCivitaiHandler
move: ModelMoveHandler move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler auto_organize: ModelAutoOrganizeHandler
filename_template: ModelFilenameTemplateHandler
updates: ModelUpdateHandler updates: ModelUpdateHandler
def to_route_mapping( def to_route_mapping(
@@ -3331,14 +3686,21 @@ class ModelHandlerSet:
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
"move_model": self.move.move_model, "move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk, "move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder,
"resolve_folder": self.move.resolve_folder,
"delete_folder": self.move.delete_folder,
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"apply_filename_template": self.filename_template.apply_filename_template,
"get_model_notes": self.query.get_model_notes, "get_model_notes": self.query.get_model_notes,
"get_model_preview_url": self.query.get_model_preview_url, "get_model_preview_url": self.query.get_model_preview_url,
"get_model_civitai_url": self.query.get_model_civitai_url, "get_model_civitai_url": self.query.get_model_civitai_url,
"get_model_metadata": self.query.get_model_metadata, "get_model_metadata": self.query.get_model_metadata,
"get_model_description": self.query.get_model_description, "get_model_description": self.query.get_model_description,
"get_relative_paths": self.query.get_relative_paths, "get_relative_paths": self.query.get_relative_paths,
"update_active_filters": self.query.update_active_filters,
"get_active_filters": self.query.get_active_filters,
"refresh_model_updates": self.updates.refresh_model_updates, "refresh_model_updates": self.updates.refresh_model_updates,
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data, "fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
"set_model_update_ignore": self.updates.set_model_update_ignore, "set_model_update_ignore": self.updates.set_model_update_ignore,
+656
View File
@@ -0,0 +1,656 @@
"""Handlers for external model sources: linking, file listing and downloads.
Covers every site registered in :mod:`py.services.model_sources`. The module
was Hugging Face only (``hf_handlers.py`` / ``HfHandler``) until ModelScope
downloads were added; the per-site differences now live in the providers, so
this file has no platform branches beyond the capability lookups.
The historical route paths (``/api/lm/set-hf-url``, ``/api/lm/hf-repo-files``,
``/api/lm/download-hf-model``) are still registered as aliases of the generic
handlers, so existing callers keep working.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any
from aiohttp import web
from ...config import config
from ...services.downloader import (
DownloadProgress,
get_downloader,
)
from ...services.aria2_downloader import Aria2Downloader
from ...services.model_sources import (
ModelSourceError,
SourceRef,
detect_source,
get_download_source,
hydrate_from_source,
list_sources,
normalize_metadata_source,
)
from ...services.settings_manager import get_settings_manager
from ...services.service_registry import ServiceRegistry
from ...services.websocket_manager import ws_manager
from ...utils.metadata_manager import MetadataManager
from ...utils.models import (
LoraMetadata,
CheckpointMetadata,
EmbeddingMetadata,
OtherModelMetadata,
)
logger = logging.getLogger(__name__)
_DEFAULT_MODEL_CLASS = LoraMetadata
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
def _infer_model_type(model_root: str) -> tuple[Any, str]:
"""Determine model class and scanner by matching ``model_root`` against the
configured root paths for each model type (from ``Config``).
The ``model_root`` value comes from the frontend's model-root dropdown,
which is populated from the current page's scanner roots. By checking
which scanner's root list it belongs to, we avoid fragile heuristics
like substring-matching path names.
"""
norm = os.path.normpath(model_root).replace(os.sep, "/")
# LoRA roots
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return LoraMetadata, "get_lora_scanner"
# Checkpoint / UNet roots
for p in (
(config.checkpoints_roots or [])
+ (config.extra_checkpoints_roots or [])
+ (config.unet_roots or [])
+ (config.extra_unet_roots or [])
):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return CheckpointMetadata, "get_checkpoint_scanner"
# Embedding roots
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return EmbeddingMetadata, "get_embedding_scanner"
# Other-model roots (VAE, text encoders, upscalers, ...)
for p in config.other_roots or []:
if os.path.normpath(p).replace(os.sep, "/") == norm:
return OtherModelMetadata, "get_other_scanner"
# Fallback — should not happen in normal use
logger.warning(
"Could not determine model type for root '%s'; defaulting to LoRA",
model_root,
)
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _report_phase(
download_id: str | None, stage: str, platform: str = ""
) -> None:
"""Tell the progress UI which post-transfer stage is running.
A download's byte counter stops the moment the last byte lands, but the
backend still has to index the file and read the model site's API. Without
this the bar sits at 100% reporting "0 B/s" and the download looks stuck for
several seconds. *stage* is machine-readable — the UI localises it — and
*platform* lets it name the site the metadata comes from.
"""
if not download_id:
return
try:
await ws_manager.broadcast_download_progress(
download_id,
{
"status": "metadata",
"stage": stage,
"platform": platform,
"progress": 100,
},
)
except Exception as exc: # pragma: no cover - progress must never be fatal
logger.debug("Failed to report the '%s' phase: %s", stage, exc)
async def _save_source_metadata(
dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache.
The metadata is created through the owning scanner rather than
``MetadataManager.create_default_metadata()``, because that is the only
factory that knows when hashing must be deferred: ``CheckpointScanner`` and
``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
``sha256`` for their multi-GB files, and the generic helper would read a
10 GB checkpoint end to end *inside the download request*. Scanners for the
small types delegate straight back to it, so nothing changes for them.
The external-source fields are then overlaid and the model is registered in
the in-memory scanner cache so it appears immediately without a full
filesystem walk.
Finally the site's own published metadata is applied (see
:func:`~py.services.model_sources.hydration.hydrate_from_source`), so a
ModelScope or Hugging Face download lands with the same populated model
card a CivitAI download produces instead of a bare filename and hash.
Both post-transfer stages are reported through *download_id* when the UI is
watching one, because neither advances the byte counter.
"""
try:
model_class, scanner_getter_name = _infer_model_type(model_root)
scanner = None
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
# 1. Create proper metadata (reads safetensors headers; hashes only for
# the model types whose scanner does not defer it)
await _report_phase(download_id, "indexing", ref.platform)
create_metadata = getattr(scanner, "_create_default_metadata", None)
if create_metadata is not None:
metadata = await create_metadata(dest_path)
else:
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path)
return
# 2. Overlay the external-source fields (`hf_url` is written by
# normalisation for Hugging Face only)
fields = metadata._unknown_fields
fields["source_url"] = ref.url
fields["source_platform"] = ref.platform
if ref.platform == "huggingface":
fields["hf_url"] = ref.url
metadata.from_civitai = False # externally-sourced models are not from CivitAI
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata)
logger.info(
"Saved %s metadata (source=%s, hash_status=%s) for %s",
ref.platform, ref.url, getattr(metadata, "hash_status", "?"), dest_path,
)
# 4. Determine relative folder path for cache
# model_root is an absolute path; dest_path is under it
folder = ""
if os.path.isabs(model_root) and dest_path.startswith(model_root):
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
if scanner is not None:
metadata_dict = normalize_metadata_source(metadata.to_dict())
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
# 6. Top up from the site's public API. Runs last so the scanner-cache
# refresh it performs lands on the entry created above. It never
# raises and never fails the download.
await _report_phase(download_id, "source", ref.platform)
await hydrate_from_source(dest_path, ref=ref)
except Exception as exc:
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
def _find_matching_root(dest_dir: str) -> str | None:
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
all_roots = []
for root_list in (
config.loras_roots or [],
config.extra_loras_roots or [],
config.checkpoints_roots or [],
config.extra_checkpoints_roots or [],
config.unet_roots or [],
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
config.other_roots or [],
):
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
# Find the longest matching prefix. The boundary check prevents a root like
# `/models/vae` from swallowing a sibling directory like `/models/vae-old`.
match: str | None = None
for root in all_roots:
if norm == root or norm.startswith(root + "/"):
if match is None or len(root) > len(match):
match = root
return match
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
model_dir = os.path.dirname(dest_path)
model_root = _find_matching_root(model_dir)
if not model_root:
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
scanner_getter_name = _infer_model_type(model_root)[1]
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is None:
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
scanner = await scanner_getter()
if scanner is None:
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
def _unsupported_platform_error(platform: str) -> web.Response:
supported = ", ".join(source.label for source in list_sources() if source.supports_download)
return web.json_response(
{"error": f"'{platform}' does not support downloads. Supported: {supported}"},
status=400,
)
class ModelSourceHandler:
"""Handle external model browsing, linking and downloads."""
async def get_model_sources(self, request: web.Request) -> web.Response:
"""List the external model sites the UI can link a model to.
Used by the "Link Model" dialog to validate URLs client-side, to
explain which sites support AI metadata enrichment, and to pick the
right download endpoint/revision.
"""
return web.json_response([
{
"platform": source.platform,
"label": source.label,
"supports_enrichment": source.supports_enrichment,
"supports_download": source.supports_download,
"default_revision": source.default_revision,
"example_url": source.canonical_url(source.example_source_id),
}
for source in list_sources()
])
async def set_hf_url(self, request: web.Request) -> web.Response:
"""Link a model file to its page on an external model site.
Accepts ``source_url`` (preferred) or the legacy ``hf_url`` / ``url``
payload key. Every registered site is recognised and the platform is
stored alongside the canonical URL. TensorArt models can be linked and
browsed, but not AI-enriched.
The route path keeps its historical ``set-hf-url`` name.
"""
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
file_path = (payload.get("file_path") or "").strip()
raw_url = (
payload.get("source_url")
or payload.get("hf_url")
or payload.get("url")
or ""
)
source_url = raw_url.strip() if isinstance(raw_url, str) else ""
if not file_path or not source_url:
return web.json_response(
{
"success": False,
"error": "Missing required fields: 'file_path' and 'source_url'",
},
status=400,
)
ref = detect_source(source_url, strict=True)
if ref is None:
return web.json_response(
{
"success": False,
"error": (
"Unsupported model URL. Supported formats: "
+ ", ".join(
f"{s.label} ({s.canonical_url(s.example_source_id)})"
for s in list_sources()
)
),
},
status=400,
)
if not os.path.isfile(file_path):
return web.json_response(
{"success": False, "error": f"File not found: {file_path}"},
status=404,
)
model_root = _find_matching_root(os.path.dirname(file_path))
if not model_root:
return web.json_response(
{
"success": False,
"error": "File is not within any configured model directory. Cannot link to a model source.",
},
status=400,
)
try:
existing = await MetadataManager.load_metadata_payload(file_path)
already_linked = (
(existing.get("source_url") or "").strip() == ref.url
and (existing.get("source_platform") or "").strip().lower()
== ref.platform
) or (
not existing.get("source_url")
and ref.platform == "huggingface"
and (existing.get("hf_url") or "").strip() == ref.url
)
if already_linked:
return web.json_response({
"success": True,
"message": "source_url already set",
"source_url": ref.url,
"source_platform": ref.platform,
"hf_url": ref.url if ref.platform == "huggingface" else "",
})
existing["source_url"] = ref.url
existing["source_platform"] = ref.platform
if ref.platform == "huggingface":
existing["hf_url"] = ref.url
else:
existing.pop("hf_url", None)
normalize_metadata_source(existing)
# NOTE: deliberately do NOT touch `from_civitai` here. It records
# where the metadata came from, and the UI must show the CivitAI
# link whenever CivitAI data is present — linking an external
# source must not hide it (#1094). Source provenance is tracked
# via `source_platform` / `source_url`.
await MetadataManager.save_metadata(file_path, existing)
await _add_to_scanner_cache(file_path, existing)
logger.info(
"Linked %s to %s source (%s)", file_path, ref.platform, ref.url
)
return web.json_response({
"success": True,
"message": f"Linked to {ref.url}",
"source_url": ref.url,
"source_platform": ref.platform,
"hf_url": existing.get("hf_url", ""),
})
except Exception as exc:
logger.error("Failed to link %s to a model source: %s", file_path, exc)
return web.json_response(
{"success": False, "error": str(exc)},
status=500,
)
async def list_model_source_files(self, request: web.Request) -> web.Response:
"""List the downloadable weight files of an external repository.
Query params: ``platform``, ``repo`` (``owner/name``), ``revision``
(optional; each site has its own default branch).
Returns a JSON array of ``{"filename", "size"}``, largest first —
the same shape the Hugging Face endpoint has always returned.
"""
platform = (request.query.get("platform") or "").strip()
repo = (request.query.get("repo") or "").strip()
revision = (request.query.get("revision") or "").strip()
source = get_download_source(platform)
if source is None:
return _unsupported_platform_error(platform)
if not source.is_valid_source_id(repo):
return web.json_response(
{"error": "Missing or invalid 'repo' parameter"},
status=400,
)
try:
files = await source.list_files(repo, revision)
except ModelSourceError as exc:
return web.json_response({"error": str(exc)}, status=exc.status)
except Exception as exc:
logger.error("Failed to list %s files in %s: %s", platform, repo, exc)
return web.json_response({"error": str(exc)}, status=502)
return web.json_response(files)
async def download_model_source(self, request: web.Request) -> web.Response:
"""Download a single file from an external repository.
POST JSON body::
{
"platform": "modelscope",
"repo": "owner/name",
"filename": "subdir/model.safetensors",
"revision": "master",
"model_root": "loras",
"relative_path": "",
"use_default_paths": false,
"download_id": "optional-batch-id"
}
``platform`` defaults to ``huggingface`` when omitted, which keeps the
legacy ``/api/lm/download-hf-model`` payload working unchanged.
If ``download_id`` is provided, real-time progress (bytes, speed,
percentage) is broadcast via the WebSocket progress system.
Respects the ``download_backend`` setting (``aria2`` or ``default``).
"""
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON"}, status=400)
platform = (payload.get("platform") or "huggingface").strip()
repo = (payload.get("repo") or "").strip()
filename = (payload.get("filename") or "").strip()
revision = (payload.get("revision") or "").strip()
model_root = (payload.get("model_root") or "").strip()
relative_path = (payload.get("relative_path") or "").strip()
use_default_paths = bool(payload.get("use_default_paths", False))
download_id: str | None = payload.get("download_id")
logger.info(
"download_model_source: platform=%s repo=%s file=%s root=%s download_id=%s",
platform, repo, filename, model_root, download_id,
)
source = get_download_source(platform)
if source is None:
return _unsupported_platform_error(platform)
if not repo or not filename:
return web.json_response(
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
)
# The id becomes a path segment below; each site defines what a safe
# id looks like (`owner/name` for repository sites, a flat token for
# OpenModelDB).
if not source.is_valid_source_id(repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
# Validate filename — must not contain path traversal
if ".." in filename:
return web.json_response({"error": "Invalid filename"}, status=400)
# Validate relative_path — must not be absolute or escape base directory
if relative_path:
if os.path.isabs(relative_path):
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
if ".." in relative_path.split("/") or "\\" in relative_path:
return web.json_response({"error": "Invalid relative_path"}, status=400)
# Use model_root directly as the base directory — same approach as
# CivitAI's download path (download_manager.py). No realpath, no
# allowed-roots validation, no path-traversal check; those are
# unnecessary when the frontend sends the path from its own dropdown
# (populated from scanner roots). Using the "business path" directly
# keeps dest_path consistent with scanner roots so that later folder
# derivation (in _save_source_metadata) works correctly.
if os.path.isabs(model_root):
base_dir = os.path.normpath(model_root)
else:
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
if use_default_paths:
target_dir = os.path.join(base_dir, *source.default_subdir_parts(repo))
elif relative_path:
target_dir = os.path.join(base_dir, relative_path)
else:
target_dir = base_dir
# Strip the repository sub-directory — "diffusion_models/xxx.safetensors"
# is a repository convention, not meaningful for local storage.
file_base = os.path.basename(filename)
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base)
# Built per request: sites that redirect to a CDN hand out a
# time-limited token in the redirect, so the URL must never be cached.
try:
resolve_url = await source.resolve_download_url(repo, filename, revision)
except ModelSourceError as exc:
return web.json_response({"error": str(exc)}, status=exc.status)
ref = SourceRef(
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
# The sidecar may predate the source metadata being fetched, or may
# have been deleted, so top it up instead of skipping past it.
# Hydration no-ops when there is no sidecar to update.
await _report_phase(download_id, "source", source.platform)
await hydrate_from_source(dest_path, ref=ref)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Set up progress callback if download_id is provided
progress_callback = None
if download_id:
async def _progress_callback(
progress: float | DownloadProgress,
snapshot: DownloadProgress | None = None,
) -> None:
percent = 0.0
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
if isinstance(progress, DownloadProgress):
percent = progress.percent_complete
metrics = progress
elif isinstance(snapshot, DownloadProgress):
percent = snapshot.percent_complete
else:
percent = float(progress)
broadcast: dict[str, Any] = {
"status": "progress",
"progress": round(percent),
}
if metrics:
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
broadcast["total_bytes"] = metrics.total_bytes
broadcast["bytes_per_second"] = metrics.bytes_per_second
await ws_manager.broadcast_download_progress(download_id, broadcast)
progress_callback = _progress_callback
# Respect download backend setting (aria2 vs default)
download_backend = (
get_settings_manager().get("download_backend", "default")
)
# Site-specific credentials (e.g. a Hugging Face access token for
# gated/private repositories); empty for anonymous downloads.
auth_headers = source.auth_headers()
if download_backend == "aria2":
aria2 = await Aria2Downloader.get_instance()
aid = download_id or f"{source.platform}_{repo}_{filename}"
try:
ok, result = await aria2.download_file(
url=resolve_url,
save_path=dest_path,
download_id=aid,
progress_callback=progress_callback,
headers=auth_headers or None,
)
if ok:
await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({
"success": True,
"message": f"Downloaded to {dest_path}",
"path": dest_path,
})
return web.json_response(
{"success": False, "error": result or "aria2 download failed"},
status=500,
)
except Exception as exc:
logger.error("%s download (aria2) failed: %s", platform, exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
# Default: use built-in aiohttp Downloader
downloader = await get_downloader()
try:
success, result = await downloader.download_file(
url=resolve_url,
save_path=dest_path,
use_auth=False,
allow_resume=True,
progress_callback=progress_callback,
custom_headers=auth_headers or None,
)
if success:
await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({
"success": True,
"message": f"Downloaded to {result}",
"path": result,
})
return web.json_response(
{"success": False, "error": result or "Download failed"},
status=500,
)
except Exception as exc:
logger.error("%s download failed: %s", platform, exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
@@ -35,6 +35,7 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
"loras": "get_lora_scanner", "loras": "get_lora_scanner",
"checkpoints": "get_checkpoint_scanner", "checkpoints": "get_checkpoint_scanner",
"embeddings": "get_embedding_scanner", "embeddings": "get_embedding_scanner",
"other": "get_other_scanner",
} }
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is # Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
+12 -1
View File
@@ -54,7 +54,18 @@ class PreviewHandler:
if not resolved.is_file(): if not resolved.is_file():
logger.debug("Preview file not found at %s", str(resolved)) logger.debug("Preview file not found at %s", str(resolved))
asyncio.create_task(self._cleanup_stale_preview_url(normalized)) if resolved.parent.is_dir():
# The file is really gone from a reachable directory, so the
# cached preview_url is stale and can be cleared.
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
else:
# The directory itself is unreachable (drive switched off,
# unmounted share). Nothing was deleted: keep the cached
# preview_url so the card recovers when the drive is back.
logger.debug(
"Preview directory unreachable, keeping cached preview_url: %s",
str(resolved.parent),
)
raise web.HTTPNotFound(text="Preview file not found") raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests, content headers, and # aiohttp's FileResponse handles range requests, content headers, and
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -22,6 +22,8 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
# App-wide and registered once: the alerts panel spans every model type, and
# the update DB is shared, so there is nothing per-type about it.
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"), RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
@@ -37,6 +39,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"), RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"), RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"), RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"), RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
@@ -99,16 +103,41 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version" "GET", "/api/lm/delete-model-version", "delete_model_version"
), ),
# Hugging Face model endpoints # External model source endpoints (Hugging Face / ModelScope).
# The hf-* paths are the historical names, kept as aliases.
RouteDefinition(
"GET", "/api/lm/model-source-files", "list_model_source_files"
),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files" "GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
), ),
# Download target routing decision (checkpoint vs diffusion model roots)
RouteDefinition(
"POST", "/api/lm/download/routing", "get_download_routing"
),
# Sidecar storage layout migration (GET supported for the extension)
RouteDefinition(
"POST", "/api/lm/sidecars/migrate", "migrate_sidecars"
),
RouteDefinition(
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
),
RouteDefinition(
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
),
RouteDefinition(
"POST", "/api/lm/download-model-source", "download_model_source"
),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model" "POST", "/api/lm/download-hf-model", "download_hf_model"
), ),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/set-hf-url", "set_hf_url" "POST", "/api/lm/set-hf-url", "set_hf_url"
), ),
# Supported external model sites (Hugging Face / ModelScope / TensorArt)
RouteDefinition(
"GET", "/api/lm/model-sources", "get_model_sources"
),
# Agent skill endpoints # Agent skill endpoints
RouteDefinition( RouteDefinition(
"GET", "/api/lm/agent/skills", "get_agent_skills" "GET", "/api/lm/agent/skills", "get_agent_skills"
+9 -3
View File
@@ -32,6 +32,7 @@ from .handlers.misc_handlers import (
NodeRegistry, NodeRegistry,
NodeRegistryHandler, NodeRegistryHandler,
SettingsHandler, SettingsHandler,
SidecarMigrationHandler,
SupportersHandler, SupportersHandler,
TrainedWordsHandler, TrainedWordsHandler,
UsageStatsHandler, UsageStatsHandler,
@@ -39,8 +40,9 @@ from .handlers.misc_handlers import (
build_service_registry_adapter, build_service_registry_adapter,
) )
from .handlers.base_model_handlers import BaseModelHandlerSet from .handlers.base_model_handlers import BaseModelHandlerSet
from .handlers.hf_handlers import HfHandler from .handlers.model_source_handlers import ModelSourceHandler
from .handlers.agent_handlers import AgentHandler from .handlers.agent_handlers import AgentHandler
from .handlers.download_routing_handlers import DownloadRoutingHandler
from .misc_route_registrar import MiscRouteRegistrar from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -138,8 +140,10 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings) doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet() base_model = BaseModelHandlerSet()
hf_handler = HfHandler() model_source_handler = ModelSourceHandler()
agent_handler = AgentHandler() agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler()
sidecar_migration = SidecarMigrationHandler()
return self._handler_set_factory( return self._handler_set_factory(
health=health, health=health,
@@ -159,8 +163,10 @@ class MiscRoutes:
doctor=doctor, doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,
base_model=base_model, base_model=base_model,
hf_handler=hf_handler, model_source_handler=model_source_handler,
agent_handler=agent_handler, agent_handler=agent_handler,
download_routing=download_routing,
sidecar_migration=sidecar_migration,
) )
+12
View File
@@ -40,11 +40,20 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"), RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"), RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress" "GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
), ),
RouteDefinition(
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"), RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
@@ -52,6 +61,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"), RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"), RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"), RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/{prefix}/resolve-folder", "resolve_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"), RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree" "GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"
@@ -68,6 +78,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/model-description", "get_model_description" "GET", "/api/lm/{prefix}/model-description", "get_model_description"
), ),
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"), RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
RouteDefinition("PUT", "/api/lm/{prefix}/active-filters", "update_active_filters"),
RouteDefinition("GET", "/api/lm/{prefix}/active-filters", "get_active_filters"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions" "GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
), ),
+144
View File
@@ -0,0 +1,144 @@
import logging
import os
from typing import Any, Dict, List
from aiohttp import web
from .base_model_routes import BaseModelRoutes
from .model_route_registrar import ModelRouteRegistrar
from ..config import config
from ..services.other_model_service import OtherModelService
from ..services.service_registry import ServiceRegistry
from ..utils.constants import (
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
OTHER_MODEL_FOLDER_SUBTYPES,
VALID_OTHER_CIVITAI_TYPES,
)
logger = logging.getLogger(__name__)
class OtherRoutes(BaseModelRoutes):
"""Other-model-specific route controller (VAE, upscaler, text encoder, ...)"""
def __init__(self):
"""Initialize Other-model routes with OtherModel service"""
super().__init__()
self.template_name = "other.html"
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
other_scanner = await ServiceRegistry.get_other_scanner()
update_service = await ServiceRegistry.get_model_update_service()
self.service = OtherModelService(other_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
def setup_routes(self, app: web.Application, prefix: str = "other"):
"""Setup Other-model routes"""
# Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'other' prefix (includes page route)
super().setup_routes(app, prefix)
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Other-model-specific routes"""
# Other-model info by name
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_other_model_info)
# Other-model roots grouped by sub_type (text_encoders + legacy clip
# are aggregated under text_encoder)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/roots_by_subtype', prefix, self.get_roots_by_subtype)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for other models.
Accepts retired CivitAI types (CLIP, CLIPVision) as well — grandfathered
models on CivitAI still carry them. Types whose sub_type is currently
disabled (or every type while the opt-in feature is off) are rejected.
"""
normalized = (model_type or "").strip().lower()
if normalized not in VALID_OTHER_CIVITAI_TYPES:
return False
if not self._settings.is_other_models_enabled():
return False
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized)
if sub_type is None:
# CivitAI "Other" has no sub_type of its own; it is only usable
# while at least one sub_type is enabled.
return bool(self._settings.get_enabled_other_sub_types())
return self._settings.is_other_sub_type_enabled(sub_type)
def _get_page_context_provider(self):
"""Expose the opt-in feature state to the Other Models page template."""
return self._page_context_for_other
def _page_context_for_other(self, request: web.Request) -> Dict[str, Any]:
if not self._settings.is_other_models_enabled():
return {"other_disabled": True, "other_no_paths": False}
# Enabled but nothing to scan: folder paths for the managed sub_types
# resolved to no existing folder. Render an actionable empty state
# instead of an apparently broken empty grid.
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
context = {
"other_disabled": False,
"other_no_paths": not bool(config.other_roots),
"standalone_mode": standalone_mode,
}
if standalone_mode:
# The empty state points at the Model Paths settings section and
# shows the settings.json path as a fallback reference.
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
return context
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
return "VAE, Upscaler, TextEncoder, CLIPVision, Controlnet, or Other"
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
"""Parse other-model-specific parameters (none in Phase 1)."""
return {}
async def get_roots_by_subtype(self, request: web.Request) -> web.Response:
"""Return other-model roots grouped by sub_type.
Aggregates the per-folder_paths-key roots from config
(``text_encoders`` and the legacy ``clip`` key both land under
``text_encoder``).
"""
try:
roots_by_subtype: Dict[str, List[str]] = {}
for key, roots in (config.other_folder_roots or {}).items():
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
if not sub_type:
continue
bucket = roots_by_subtype.setdefault(sub_type, [])
for root in roots:
if root and root not in bucket:
bucket.append(root)
return web.json_response(
{"success": True, "roots_by_subtype": roots_by_subtype}
)
except Exception as e:
logger.error(f"Error getting other roots by sub_type: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": str(e)}, status=500
)
async def get_other_model_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific other model by name"""
try:
name = request.match_info.get('name', '')
model_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
if model_info:
return web.json_response(model_info)
else:
return web.json_response({"error": "Model not found"}, status=404)
except Exception as e:
logger.error(f"Error in get_other_model_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
+21 -5
View File
@@ -58,6 +58,22 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid" "POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
), ),
RouteDefinition(
"POST", "/api/lm/recipe/checkpoint/reconnect", "reconnect_checkpoint"
),
RouteDefinition(
"POST", "/api/lm/recipe/checkpoint/restore", "restore_checkpoint"
),
RouteDefinition(
"GET",
"/api/lm/recipe/{recipe_id}/checkpoint/reconnect-suggestions",
"get_checkpoint_reconnect_suggestions",
),
RouteDefinition(
"POST",
"/api/lm/recipe/checkpoint/mark-hash-invalid",
"mark_checkpoint_hash_invalid",
),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition( RouteDefinition(
@@ -68,11 +84,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint" "GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
), ),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"), RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
@@ -99,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" "POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
), ),
# The companion browser extension only ever issues GET requests, so the
# payload-based re-import variant must also be reachable via GET.
RouteDefinition(
"GET", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow" "POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
), ),
+2
View File
@@ -13,6 +13,7 @@ from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..services.model_query import normalize_sub_type, resolve_sub_type from ..services.model_query import normalize_sub_type, resolve_sub_type
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.url_utils import relative_root_prefix
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -106,6 +107,7 @@ class StatsRoutes:
settings=settings_manager, settings=settings_manager,
request=request, request=request,
t=server_i18n.get_translation, t=server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
) )
return web.Response( return web.Response(
+14 -1
View File
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
# otherwise delete them because they are untracked and, in released tags, # otherwise delete them because they are untracked and, in released tags,
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning # not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
# regardless of whether it is ignored. # regardless of whether it is ignored.
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache') # ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
# are listed too because a portable install can predate the cache/ move.
_PRESERVE_DIRS = (
'settings.json',
'civitai',
'wildcards',
'backups',
'stats',
'logs',
'cache',
'model_cache',
'recipe_cache',
)
def _clean_excludes() -> List[str]: def _clean_excludes() -> List[str]:
+135
View File
@@ -0,0 +1,135 @@
"""In-memory store for the LoRA Manager page's active filters.
The manager page keeps its filter state in localStorage for its own
restoration, but the ComfyUI node autocomplete runs in a potentially
different browser/origin (or Electron shell) where that storage is not
shared. This store mirrors the active filters server-side so the
``/api/lm/{prefix}/relative-paths`` endpoint can inject them into
autocomplete searches regardless of which client set them.
State is process-local and intentionally not persisted; the manager page
re-pushes its restored state on load.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Keys copied from the manager page's persisted filter snapshot.
_FILTER_KEYS = (
"baseModel",
"tags",
"autoTags",
"modelTypes",
"tagLogic",
"license",
)
class ActiveFiltersStore:
"""Process-local store of active filters, keyed by model type."""
_instance: Optional["ActiveFiltersStore"] = None
def __init__(self) -> None:
self._filters: Dict[str, Dict[str, Any]] = {}
@classmethod
def get_instance(cls) -> "ActiveFiltersStore":
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Drop the singleton (test isolation)."""
cls._instance = None
def set_filters(self, model_type: str, payload: Dict[str, Any]) -> None:
"""Replace the stored active filters for a model type.
Only recognized keys are kept; everything else is discarded.
"""
filters = payload.get("filters")
sanitized: Dict[str, Any] = {
"activeFolder": payload.get("activeFolder"),
"recursiveSearch": bool(payload.get("recursiveSearch", True)),
"filters": (
{key: filters[key] for key in _FILTER_KEYS if key in filters}
if isinstance(filters, dict)
else None
),
}
self._filters[model_type] = sanitized
def get_filters(self, model_type: str) -> Optional[Dict[str, Any]]:
"""Return the stored payload for a model type, or None if unset."""
return self._filters.get(model_type)
def clear(self, model_type: str) -> None:
self._filters.pop(model_type, None)
def active_filters_to_query_kwargs(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Map a stored active-filters payload to ``search_relative_paths`` kwargs.
Mirrors the query-param mapping that the ComfyUI autocomplete used to
build client-side from localStorage (web/comfyui/autocomplete.js).
"""
kwargs: Dict[str, Any] = {}
if not payload:
return kwargs
active_folder = payload.get("activeFolder")
recursive = payload.get("recursiveSearch", True)
if active_folder and active_folder != "null":
kwargs["folder"] = active_folder
elif not recursive:
# Root folder with recursion disabled mirrors the page list,
# which matches only root-level files via folder=''.
kwargs["folder"] = ""
filters = payload.get("filters")
if isinstance(filters, dict):
base_models = filters.get("baseModel")
if isinstance(base_models, list):
kwargs["base_models"] = [m for m in base_models if m]
for source_key, target_key in (("tags", "tags"), ("autoTags", "auto_tags")):
states = filters.get(source_key)
if isinstance(states, dict):
mapped = {
tag: state
for tag, state in states.items()
if state in ("include", "exclude")
}
if mapped:
kwargs[target_key] = mapped
model_types = filters.get("modelTypes")
if isinstance(model_types, list):
kwargs["model_types"] = [t for t in model_types if t]
tag_logic = filters.get("tagLogic")
if tag_logic:
kwargs["tag_logic"] = tag_logic
license_filter = filters.get("license")
if isinstance(license_filter, dict):
no_credit = license_filter.get("noCredit")
if no_credit == "include":
kwargs["credit_required"] = False
elif no_credit == "exclude":
kwargs["credit_required"] = True
allow_selling = license_filter.get("allowSelling")
if allow_selling == "include":
kwargs["allow_selling_generated_content"] = True
elif allow_selling == "exclude":
kwargs["allow_selling_generated_content"] = False
kwargs["recursive"] = recursive
return kwargs
+212 -49
View File
@@ -19,16 +19,21 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import aiohttp
import os
from ...config import config from ...config import config
from ..llm_service import LLMService from ..llm_service import LLMService
from ..model_sources import (
ModelCardContext,
ModelSourceCache,
get_source,
resolve_source_ref,
source_label,
)
from ..model_sources.hydration import load_model_card, resolve_site_base_model
from ..websocket_manager import ws_manager from ..websocket_manager import ws_manager
from .post_processor import PostProcessor from .post_processor import PostProcessor
from .skill_registry import SkillRegistry from .skill_registry import SkillRegistry
@@ -255,6 +260,11 @@ class AgentService:
llm = await self._ensure_llm() llm = await self._ensure_llm()
llm_configured = llm.is_configured() if skill.llm_required else True llm_configured = llm.is_configured() if skill.llm_required else True
# A collection repository holds many model files under one source id;
# this memo keeps the README and the repository metadata from being
# re-fetched once per file. It lives for this run only.
source_cache = ModelSourceCache()
for model_path in model_paths: for model_path in model_paths:
model_filename = os.path.basename(model_path) model_filename = os.path.basename(model_path)
logger.info( logger.info(
@@ -267,24 +277,50 @@ class AgentService:
from ...metadata_ops import read_metadata from ...metadata_ops import read_metadata
metadata = await read_metadata(model_path) metadata = await read_metadata(model_path)
# Fast-fail: enrich_hf_metadata requires hf_url to have HF README context # Fast-fail: enrich_hf_metadata needs an external model source
if skill_name == "enrich_hf_metadata" and not metadata.get("hf_url", ""): # that exposes an accessible model card.
logger.info( if skill_name == "enrich_hf_metadata":
"[%s] SKIP %s — no hf_url in metadata", skip_reason = self._enrichment_skip_reason(metadata)
skill_name, model_filename, if skip_reason:
) logger.info(
skipped_count += 1 "[%s] SKIP %s — %s",
skip_model = True skill_name, model_filename, skip_reason,
)
skipped_count += 1
skip_model = True
if not skip_model: if not skip_model:
prompt_vars: Dict[str, Any] = {"model_path": model_path} # The site's own data is deterministic and must land whether
if skill.llm_required and llm_configured: # or not an LLM is available: a user without a key still gets
prompt_vars = await self._build_prompt_context( # the author summary, the example images and the tags.
skill_name, model_path, metadata, registry, llm, source_vars, source_context = await self._load_source_card(
model_path, metadata, cache=source_cache,
)
resolved_base_model = ""
if skill_name == "enrich_hf_metadata" and not (
metadata.get("base_model") or ""
).strip():
resolved_base_model = await self._resolve_site_base_model(
source_context,
) )
llm_response: Optional[Dict[str, Any]] = None llm_response: Optional[Dict[str, Any]] = None
if skill.llm_required and llm_configured: if skill.llm_required and not llm_configured:
# Without a provider the deterministic model-source data
# still lands; the LLM-only fields simply stay untouched.
logger.info(
"[%s] No LLM configured for %s — applying %s data only",
skill_name, model_filename,
"model-source"
if not source_context.is_empty()
else "README",
)
elif skill.llm_required:
prompt_vars = await self._build_prompt_context(
skill_name, model_path, metadata, registry, llm,
source_vars=source_vars,
source_context=source_context,
)
prompt_template = registry.load_prompt(skill_name) prompt_template = registry.load_prompt(skill_name)
rendered = _render_prompt(prompt_template, prompt_vars) rendered = _render_prompt(prompt_template, prompt_vars)
llm_response = await llm.chat_completion_json( llm_response = await llm.chat_completion_json(
@@ -307,7 +343,9 @@ class AgentService:
model_path=model_path, model_path=model_path,
llm_output=llm_response or {}, llm_output=llm_response or {},
metadata=metadata, metadata=metadata,
readme_content=prompt_vars.get("readme_content_full", ""), readme_content=source_vars.get("readme_content_full", ""),
source_context=source_context,
resolved_base_model=resolved_base_model,
) )
if model_result.get("success", True): if model_result.get("success", True):
@@ -358,6 +396,28 @@ class AgentService:
# Base model grouping (keeps the prompt compact) # Base model grouping (keeps the prompt compact)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod
def _enrichment_skip_reason(metadata: Dict[str, Any]) -> str:
"""Return why ``enrich_hf_metadata`` cannot run, or ``""`` if it can.
Distinguishes the three cases the user can act on: no source linked,
a source we don't know, and a known source whose model card is not
reachable from the backend (TensorArt).
"""
ref = resolve_source_ref(metadata)
if ref is None:
return "no model source linked (source_url missing)"
source = get_source(ref.platform)
if source is None:
return f"unsupported model source platform '{ref.platform}'"
if not source.supports_enrichment:
return (
f"{source.label} does not expose a model card to the backend; "
"AI metadata enrichment is not available for this source"
)
return ""
@staticmethod @staticmethod
def _format_base_models(models: List[str]) -> str: def _format_base_models(models: List[str]) -> str:
"""Format the base model list as a flat, one-per-line list. """Format the base model list as a flat, one-per-line list.
@@ -368,6 +428,82 @@ class AgentService:
""" """
return "\n".join(f"- {m}" for m in models) return "\n".join(f"- {m}" for m in models)
async def _load_source_card(
self,
model_path: str,
metadata: Dict[str, Any],
*,
cache: Optional[ModelSourceCache] = None,
) -> tuple[Dict[str, Any], ModelCardContext]:
"""Fetch the model card and site-published extras for one model.
Runs for every source-backed enrichment regardless of LLM
availability, because everything it returns is deterministic data that
should be applied even without a configured provider.
*cache* is the per-run memo created by :meth:`execute_skill`. The
README is repository-wide, so it is fetched once per source id; only
successful reads are memoised, leaving a transient failure to be
retried for the next file.
"""
variables: Dict[str, Any] = {
"asset_base_url": "",
"source_description": "",
"source_base_model": "",
"source_official_tags": "",
"source_example_images": "",
"source_trigger_words": "",
"readme_content": "(README not available)",
"readme_content_full": "",
}
ref = resolve_source_ref(metadata)
source = get_source(ref.platform) if ref is not None else None
if ref is None or source is None or not source.supports_enrichment:
return variables, ModelCardContext()
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
readme = await load_model_card(source, ref.source_id, cache)
# Sites such as ModelScope keep part of the model card outside the
# README (author summary, curated tags, per-file example images). The
# recorded hash identifies the file even after the user renames it.
card_context = await source.fetch_model_card_context(
ref.source_id,
os.path.basename(model_path),
sha256=(metadata.get("sha256") or "").strip(),
cache=cache,
)
variables["source_description"] = card_context.description
variables["source_base_model"] = card_context.base_model
variables["source_official_tags"] = "\n".join(
f"- {tag}" for tag in card_context.official_tags
)
variables["source_example_images"] = "\n".join(
f"- {url}" for url in card_context.example_images
)
variables["source_trigger_words"] = ", ".join(card_context.trigger_words)
# Trim README to the section relevant to this model file
# (collection repos often have multiple models in one README).
if readme and raw_basename:
trimmed = extract_relevant_section(readme, raw_basename)
cleaned = clean_readme_for_llm(trimmed) if trimmed else ""
else:
cleaned = clean_readme_for_llm(readme) if readme else ""
variables["readme_content"] = cleaned if cleaned else "(README not available)"
variables["readme_content_full"] = readme or ""
return variables, card_context
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``."""
return await resolve_site_base_model(source_context)
async def _build_prompt_context( async def _build_prompt_context(
self, self,
skill_name: str, skill_name: str,
@@ -375,19 +511,45 @@ class AgentService:
metadata: Dict[str, Any], metadata: Dict[str, Any],
registry: SkillRegistry, registry: SkillRegistry,
llm: Any, llm: Any,
*,
source_vars: Optional[Dict[str, Any]] = None,
source_context: Optional[ModelCardContext] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Gather variables for the skill's prompt template. """Gather variables for the skill's prompt template.
Reads metadata, fetches the HF README (if applicable), lists available Reads metadata, fetches the model card (unless a pre-fetched
*source_vars* / *source_context* pair is supplied), lists available
base models, loads user priority tags, and returns a dict that maps to base models, loads user priority tags, and returns a dict that maps to
``{{variable}}`` placeholders in ``prompt.md``. ``{{variable}}`` placeholders in ``prompt.md``.
""" """
from ...metadata_ops import identify_model_type, list_base_models from ...metadata_ops import identify_model_type, list_base_models
from ..settings_manager import SettingsManager from ..settings_manager import SettingsManager
if source_vars is None or source_context is None:
source_vars, source_context = await self._load_source_card(
model_path, metadata,
)
context: Dict[str, Any] = { context: Dict[str, Any] = {
"model_path": model_path, "model_path": model_path,
"model_basename": "", "model_basename": "",
# Canonical external-source variables
"source_url": "",
"source_id": "",
"source_platform": "",
"source_label": "",
"asset_base_url": "",
# Site-provided card extras (see ModelSource.fetch_model_card_context)
"source_description": "",
"source_base_model": "",
"source_official_tags": "",
"source_example_images": "",
"source_trigger_words": "",
# Carrier for the structured context handed to the post-processor;
# never rendered into the prompt.
"source_context": ModelCardContext(),
# Legacy Hugging Face aliases (kept so older prompt templates and
# third-party skills keep rendering)
"hf_url": "", "hf_url": "",
"repo": "", "repo": "",
"readme_content": "", "readme_content": "",
@@ -407,26 +569,33 @@ class AgentService:
"base_model": metadata.get("base_model", ""), "base_model": metadata.get("base_model", ""),
"tags": metadata.get("tags", []), "tags": metadata.get("tags", []),
"modelDescription": metadata.get("modelDescription", ""), "modelDescription": metadata.get("modelDescription", ""),
"trainedWords": metadata.get("trainedWords", []),
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "", "sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
"size": metadata.get("size", 0), "size": metadata.get("size", 0),
} }
hf_url = metadata.get("hf_url", "") ref = resolve_source_ref(metadata)
context["hf_url"] = hf_url if ref is not None:
repo = self._extract_repo_from_url(hf_url) if hf_url else "" context["source_url"] = ref.url
context["repo"] = repo or "" context["source_id"] = ref.source_id
if repo: context["source_platform"] = ref.platform
readme = await self._fetch_readme(repo) context["source_label"] = source_label(ref.platform, ref.platform)
# Trim README to the section relevant to this model file if ref.platform == "huggingface":
# (collection repos often have multiple models in one README). context["hf_url"] = ref.url
if readme and raw_basename: context["repo"] = ref.source_id
trimmed = extract_relevant_section(readme, raw_basename)
cleaned = clean_readme_for_llm(trimmed) if trimmed else "" source = get_source(ref.platform) if ref is not None else None
else: if ref is not None and source is not None and source.supports_enrichment:
cleaned = clean_readme_for_llm(readme) if readme else "" # Values fetched once by _load_source_card and shared with the
context["readme_content"] = cleaned if cleaned else "(README not available)" # post-processor, so the network is not hit twice per model.
context["readme_content_full"] = readme or "" context["asset_base_url"] = source_vars["asset_base_url"]
context["source_context"] = source_context
context["source_description"] = source_vars["source_description"]
context["source_base_model"] = source_vars["source_base_model"]
context["source_official_tags"] = source_vars["source_official_tags"]
context["source_example_images"] = source_vars["source_example_images"]
context["source_trigger_words"] = source_vars["source_trigger_words"]
context["readme_content"] = source_vars["readme_content"]
context["readme_content_full"] = source_vars["readme_content_full"]
try: try:
raw_models = await list_base_models() raw_models = await list_base_models()
@@ -459,20 +628,14 @@ class AgentService:
@staticmethod @staticmethod
async def _fetch_readme(repo: str) -> str: async def _fetch_readme(repo: str) -> str:
"""Fetch README.md from HuggingFace (tries ``main``, then ``master``).""" """Fetch a Hugging Face README (tries ``main``, then ``master``).
async with aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"}, Kept for backward compatibility; new code should go through the
timeout=aiohttp.ClientTimeout(total=30), model-source registry so every supported site works.
) as session: """
for branch in ("main", "master"): from ..model_sources import HuggingFaceSource
url = f"https://huggingface.co/{repo}/raw/{branch}/README.md"
try: return await HuggingFaceSource().fetch_model_card(repo)
async with session.get(url) as resp:
if resp.status == 200:
return await resp.text()
except Exception as exc:
logger.debug("Failed to fetch README from %s: %s", url, exc)
return ""
async def _emit_progress( async def _emit_progress(
self, self,
+94
View File
@@ -0,0 +1,94 @@
"""Map a site-reported base model onto this system's canonical vocabulary.
Model sites name base models in their own terms: ModelScope publishes
``krea/Krea-2-Turbo`` and ``KREA_2_TURBO`` where this system expects the
canonical ``Krea 2``. Turning one into the other is normally the LLM's job;
this module resolves the cases that can be decided safely so the canonical
field is still populated when the LLM returns nothing usable for it.
The resolver is deliberately strict, because a wrong base model written with
apparent authority is worse than no value at all:
* it only ever returns a name that is already present in *known_names*;
* matching is on the normalised form (lowercased, non-alphanumerics removed),
so separators and casing are ignored but nothing is inferred;
* a bounded set of published variant suffixes may be stripped, and only when
the remainder still matches a known name exactly.
Anything it cannot decide returns ``""``, and the caller falls back to the LLM.
"""
from __future__ import annotations
import re
from typing import Iterable, Sequence
#: Variant suffixes sites append to a base-model *family* name. Stripping one
#: is only attempted when the remainder matches a known name exactly, so an
#: unrecognised suffix can never produce a bogus match.
_VARIANT_SUFFIXES: tuple[str, ...] = (
"turbo",
"schnell",
"lightning",
"dev",
"beta",
"alpha",
)
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def _normalize(value: str) -> str:
"""Return the comparison form of *value*.
Lowercases and drops every non-alphanumeric character, so ``KREA_2``,
``Krea 2``, ``krea-2`` and ``krea.2`` all collapse to ``krea2``.
"""
return _NON_ALNUM.sub("", (value or "").lower())
def resolve_base_model(
hints: Iterable[str], known_names: Sequence[str]
) -> str:
"""Return the canonical base model that *hints* refers to, or ``""``.
Args:
hints: Site-reported names, best first (e.g. an architecture enum
before a link-style repository id).
known_names: The canonical vocabulary; only these are ever returned.
Returns:
One of *known_names*, or ``""`` when nothing matches exactly.
"""
normalized: dict[str, str] = {}
for name in known_names:
key = _normalize(name)
if key and key not in normalized:
normalized[key] = name
if not normalized:
return ""
ordered = [hint for hint in hints if hint]
# 1. Exact normalised match — the unambiguous case.
for hint in ordered:
candidate = _normalize(hint)
if candidate in normalized:
return normalized[candidate]
# 2. Drop one published variant suffix and retry exactly.
for hint in ordered:
candidate = _normalize(hint)
for suffix in _VARIANT_SUFFIXES:
if not candidate.endswith(suffix) or candidate == suffix:
continue
stem = candidate[: -len(suffix)]
if stem in normalized:
return normalized[stem]
return ""
__all__ = ["resolve_base_model"]
+325 -75
View File
@@ -10,12 +10,16 @@ refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
from __future__ import annotations from __future__ import annotations
import html
import json import json
import logging import logging
import os import os
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Dict, List, Optional
if TYPE_CHECKING: # pragma: no cover - typing only
from ..model_sources import ModelCardContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,6 +46,9 @@ class PostProcessor:
llm_output: Dict[str, Any], llm_output: Dict[str, Any],
metadata: Dict[str, Any], metadata: Dict[str, Any],
readme_content: str = "", readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor. """Route *llm_output* to the correct skill post-processor.
@@ -49,12 +56,26 @@ class PostProcessor:
that is converted to HTML and stored as ``modelDescription`` for that is converted to HTML and stored as ``modelDescription`` for
the description tab. the description tab.
*source_context* carries the extras the model site publishes outside
the README (author description, per-file example images, trigger
words). It is ``None`` for callers that have none.
*resolved_base_model* is the canonical base-model name the site's own
hints resolve to, used when the LLM did not supply one (which is the
normal case when the LLM was skipped).
*metadata_source* records who produced the metadata. The AI skill
keeps its historical value; the deterministic download-time hydration
passes its own so the two remain distinguishable. ``llm_enriched_at``
is only stamped when *llm_output* actually carries a provider answer.
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list), Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
``preview_downloaded`` (bool), and ``errors`` (list). ``preview_downloaded`` (bool), and ``errors`` (list).
""" """
if skill_name == "enrich_hf_metadata": if skill_name == "enrich_hf_metadata":
return await self._process_enrich_hf_metadata( return await self._process_enrich_hf_metadata(
model_path, llm_output, metadata, readme_content, model_path, llm_output, metadata, readme_content, source_context,
resolved_base_model, metadata_source,
) )
return { return {
"success": False, "success": False,
@@ -72,12 +93,16 @@ class PostProcessor:
llm_output: Dict[str, Any], llm_output: Dict[str, Any],
metadata: Dict[str, Any], metadata: Dict[str, Any],
readme_content: str = "", readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from ...metadata_ops import ( from ...metadata_ops import (
apply_metadata_updates, apply_metadata_updates,
download_preview, download_preview,
refresh_cache, refresh_cache,
) )
from ..model_sources import get_source, has_external_source, resolve_source_ref
from .skills.enrich_hf_metadata.readme_processor import ( from .skills.enrich_hf_metadata.readme_processor import (
convert_readme_to_html, convert_readme_to_html,
extract_gallery_images, extract_gallery_images,
@@ -85,24 +110,49 @@ class PostProcessor:
extract_relevant_section, extract_relevant_section,
extract_simple_markdown_images, extract_simple_markdown_images,
extract_html_img_tags, extract_html_img_tags,
extract_repo_from_hf_url,
) )
updated_fields: List[str] = [] updated_fields: List[str] = []
preview_downloaded = False preview_downloaded = False
# -- Determine whether this is an HF-sourced model ----------------- # -- Determine whether this is an externally-sourced model ---------
is_hf_model = not metadata.get("from_civitai", True) # Key off the source fields directly: `from_civitai` records provenance
# and can be true for a model that is also linked to an external site
# (both sources coexist, see #1094), so it must not gate enrichment.
is_source_model = has_external_source(metadata)
source_ref = resolve_source_ref(metadata)
source = get_source(source_ref.platform) if source_ref else None
source_id = source_ref.source_id if source_ref else ""
asset_base_url = (
source.asset_base_url(source_id)
if source is not None and source_id
else None
)
# -- Collect updates ----------------------------------------------- # -- Collect updates -----------------------------------------------
updates: Dict[str, Any] = {} updates: Dict[str, Any] = {}
# base_model # base_model — the LLM's mapping wins; when it returned nothing usable,
# fall back to the canonical name the site's own hints resolve to.
new_base = (llm_output.get("base_model") or "").strip() new_base = (llm_output.get("base_model") or "").strip()
if not new_base:
new_base = (resolved_base_model or "").strip()
current_base = metadata.get("base_model", "") or "" current_base = metadata.get("base_model", "") or ""
if new_base and self._should_overwrite(current_base, is_hf_model): if new_base and self._should_overwrite(current_base, is_source_model):
updates["base_model"] = new_base updates["base_model"] = new_base
# model_name — the site's own display name, so a source download never
# shows up under its local filename. Written only while the name is
# still the untouched file stem: once a user renames a model that
# choice is theirs to keep.
site_name = ((source_context.model_name if source_context else "") or "").strip()
if is_source_model and site_name:
current_name = (metadata.get("model_name") or "").strip()
file_stem = (metadata.get("file_name") or "").strip()
if not current_name or current_name == file_stem:
updates["model_name"] = site_name
# trigger words → civitai.trainedWords # trigger words → civitai.trainedWords
new_triggers = llm_output.get("trigger_words", []) new_triggers = llm_output.get("trigger_words", [])
trigger_words_empty = True trigger_words_empty = True
@@ -110,45 +160,80 @@ class PostProcessor:
cleaned = [t.strip() for t in new_triggers if t.strip()] cleaned = [t.strip() for t in new_triggers if t.strip()]
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")] cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
trigger_words_empty = not cleaned trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {} current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
current_triggers = current_civitai.get("trainedWords") or [] if self._should_overwrite_list(current_triggers, is_source_model):
if self._should_overwrite_list(current_triggers, is_hf_model): self._merge_civitai(updates, metadata, trainedWords=cleaned)
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = cleaned
updates["civitai"] = trig_civitai
# modelDescription — from raw README content (converted to HTML) # modelDescription — the author's own summary (when the site keeps one
if readme_content and is_hf_model: # outside the README, e.g. ModelScope's ``Description``) followed by the
converted = convert_readme_to_html(readme_content) # README converted to HTML.
if converted: site_description = (
updates["modelDescription"] = converted (source_context.description if source_context else "") or ""
).strip()
if is_source_model and (site_description or readme_content):
parts: List[str] = []
if site_description:
parts.append(f"<p>{html.escape(site_description)}</p>")
if readme_content:
converted = convert_readme_to_html(readme_content)
if converted:
parts.append(converted)
if parts:
updates["modelDescription"] = "\n".join(parts)
# short_description → civitai.description (for "About this version") # short_description → civitai.description (for "About this version").
# Falls back to the site's author summary, which for ModelScope AIGC
# models is frequently the only human-written text available.
short_desc = (llm_output.get("short_description") or "").strip() short_desc = (llm_output.get("short_description") or "").strip()
if short_desc and is_hf_model: if not short_desc:
current_civitai = metadata.get("civitai") or {} short_desc = site_description
desc_civitai = dict(current_civitai) if short_desc and is_source_model:
if "civitai" in updates and isinstance(updates["civitai"], dict): self._merge_civitai(updates, metadata, description=short_desc)
desc_civitai.update(updates["civitai"])
desc_civitai["description"] = short_desc # The version label completes the card the way a CivitAI download does:
updates["civitai"] = desc_civitai # the UI renders `civitai.name` as the version chip. It is per file,
# so a collection repository shows that checkpoint's own label.
site_version = (
(source_context.version_name if source_context else "") or ""
).strip()
if is_source_model and site_version:
self._merge_civitai(updates, metadata, name=site_version)
# Site-native identity ids (ModelScope's published model/version ids).
# They are what version grouping keys off, so they must reach the
# sidecar even when nothing else about the card changed.
if is_source_model and source_context is not None:
if source_context.source_model_id:
updates["source_model_id"] = source_context.source_model_id
if source_context.source_version_id:
updates["source_version_id"] = source_context.source_version_id
# gallery images → civitai.images (site example images, YAML frontmatter
# widget entries, and Sample Gallery markdown tables in the README body)
rec_width = llm_output.get("recommended_width") or 0
rec_height = llm_output.get("recommended_height") or 0
# Example images the site publishes for *this* file. They are matched
# by filename, so they are the most precise preview source available
# and the only one for repositories whose README carries no images.
site_images: List[Dict[str, Any]] = []
if is_source_model and source_context is not None:
site_images = [
_example_image(url, rec_width, rec_height)
for url in source_context.example_images
if url
]
# gallery images → civitai.images (from YAML frontmatter widget entries
# and Sample Gallery markdown tables in the README body)
gallery_images: List[Dict[str, Any]] = [] gallery_images: List[Dict[str, Any]] = []
if readme_content and is_hf_model: if (readme_content or site_images) and is_source_model:
hf_url = metadata.get("hf_url", "") or "" repo = source_id
repo = extract_repo_from_hf_url(hf_url) readme_images: List[Dict[str, Any]] = []
if repo: if readme_content and repo:
rec_w = llm_output.get("recommended_width") or 0
rec_h = llm_output.get("recommended_height") or 0
# 1. Widget images (YAML frontmatter) # 1. Widget images (YAML frontmatter)
gallery = extract_gallery_images( gallery = extract_gallery_images(
readme_content, repo, readme_content, repo,
default_width=rec_w, default_height=rec_h, default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
) )
# 2. Sample Gallery table images (markdown body), deduplicated # 2. Sample Gallery table images (markdown body), deduplicated
@@ -156,7 +241,8 @@ class PostProcessor:
table_images = extract_gallery_table_images( table_images = extract_gallery_table_images(
readme_content, repo, readme_content, repo,
existing_urls=existing_urls, existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h, default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
) )
existing_urls.update(img["url"] for img in table_images if img.get("url")) existing_urls.update(img["url"] for img in table_images if img.get("url"))
@@ -164,7 +250,8 @@ class PostProcessor:
simple_images = extract_simple_markdown_images( simple_images = extract_simple_markdown_images(
readme_content, repo, readme_content, repo,
existing_urls=existing_urls, existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h, default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
) )
existing_urls.update(img["url"] for img in simple_images if img.get("url")) existing_urls.update(img["url"] for img in simple_images if img.get("url"))
@@ -172,54 +259,71 @@ class PostProcessor:
html_images = extract_html_img_tags( html_images = extract_html_img_tags(
readme_content, repo, readme_content, repo,
existing_urls=existing_urls, existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h, default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
) )
all_images = gallery + table_images + simple_images + html_images readme_images = gallery + table_images + simple_images + html_images
if all_images:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
# tags # Site images come first so the preview fallback below prefers an
# image that is known to belong to this exact file.
all_images = _dedupe_images(site_images + readme_images)
if all_images:
gallery_images = all_images
self._merge_civitai(updates, metadata, images=all_images)
# tags — the site's curated tags are authoritative content vocabulary, so
# they are kept alongside whatever the LLM proposed (the LLM is skipped
# entirely when the site data is complete, which is why this cannot rely
# on ``llm_output`` alone).
new_tags = llm_output.get("tags", []) new_tags = llm_output.get("tags", [])
if isinstance(new_tags, list) and new_tags: candidate_tags: List[str] = []
if is_source_model and source_context is not None:
candidate_tags.extend(source_context.official_tags)
if isinstance(new_tags, list):
candidate_tags.extend(
tag for tag in new_tags if tag not in candidate_tags
)
if candidate_tags:
existing_tags = metadata.get("tags") or [] existing_tags = metadata.get("tags") or []
merged = self._merge_tags(existing_tags, new_tags) merged = self._merge_tags(existing_tags, candidate_tags)
if len(merged) > len(existing_tags) or is_hf_model: if len(merged) > len(existing_tags) or is_source_model:
updates["tags"] = merged updates["tags"] = merged
# metadata_source & llm_enriched_at (always set) # metadata_source is recorded for provenance; llm_enriched_at only means
updates["metadata_source"] = "agent:enrich_hf_metadata" # something when a provider actually answered, so the deterministic
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat() # download-time hydration does not claim an enrichment that never ran.
updates["metadata_source"] = metadata_source
if llm_output:
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# Store LLM confidence in metadata so it's accessible for evaluation # LLM confidence, stored for the enrichment evaluation harness. The key
# must NOT start with an underscore: `BaseModelMetadata.from_dict()`
# deliberately drops underscore-prefixed keys so they never round-trip,
# which silently erased this field on the next metadata write.
raw_confidence = (llm_output.get("confidence") or "").strip() raw_confidence = (llm_output.get("confidence") or "").strip()
if raw_confidence: if raw_confidence:
updates["_llm_confidence"] = raw_confidence updates["llm_confidence"] = raw_confidence
# Fallback: extract instance_prompt from YAML frontmatter when the LLM # Fallback: use the trigger words the site records for this exact file,
# returned empty trigger words but the README has instance_prompt. # then the README's YAML `instance_prompt`, when the LLM returned none.
if trigger_words_empty: if trigger_words_empty:
instance_prompt = _extract_yaml_instance_prompt(readme_content) site_triggers = (
if instance_prompt: list(source_context.trigger_words) if source_context else []
current_civitai = metadata.get("civitai") or {} )
trig_civitai = dict(current_civitai) if not site_triggers:
if "civitai" in updates and isinstance(updates["civitai"], dict): instance_prompt = _extract_yaml_instance_prompt(readme_content)
trig_civitai.update(updates["civitai"]) if instance_prompt:
trig_civitai["trainedWords"] = [instance_prompt] site_triggers = [instance_prompt]
updates["civitai"] = trig_civitai if site_triggers:
self._merge_civitai(updates, metadata, trainedWords=site_triggers)
preview_remote_url = (llm_output.get("preview_url") or "").strip() preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned # Fallback: if the LLM couldn't find a preview image in the cleaned
# README, find the first gallery image from the *model-specific # README, find the first gallery image from the *model-specific
# section* of the README (not the repo-wide first image, which # section* of the README (not the repo-wide first image, which
# belongs to a different model in collection repos). # belongs to a different model in collection repos).
if not preview_remote_url and readme_content and is_hf_model: if not preview_remote_url and readme_content and is_source_model:
model_basename = os.path.splitext(os.path.basename(model_path))[0] model_basename = os.path.splitext(os.path.basename(model_path))[0]
relevant_section = extract_relevant_section( relevant_section = extract_relevant_section(
readme_content, model_basename, readme_content, model_basename,
@@ -245,8 +349,12 @@ class PostProcessor:
if new_notes: if new_notes:
updates["notes"] = new_notes updates["notes"] = new_notes
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4}) # usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4}).
# When the LLM returned nothing, recover an explicitly stated strength
# range from the author summary so the value is not lost.
raw_tips = (llm_output.get("usage_tips") or "").strip() raw_tips = (llm_output.get("usage_tips") or "").strip()
if not raw_tips or raw_tips == "{}":
raw_tips = _extract_usage_tips(site_description)
if raw_tips and raw_tips != "{}": if raw_tips and raw_tips != "{}":
try: try:
json.loads(raw_tips) json.loads(raw_tips)
@@ -276,16 +384,35 @@ class PostProcessor:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def _should_overwrite(current_value: str, is_hf_model: bool) -> bool: def _should_overwrite(current_value: str, is_source_model: bool) -> bool:
"""Return ``True`` when a scalar field should be overwritten.""" """Return ``True`` when a scalar field should be overwritten."""
return is_hf_model or not current_value or current_value.lower() in ( return is_source_model or not current_value or current_value.lower() in (
"", "unknown", "", "unknown",
) )
@staticmethod @staticmethod
def _should_overwrite_list(current_list: List[str], is_hf_model: bool) -> bool: def _merge_civitai(
updates: Dict[str, Any], metadata: Dict[str, Any], **fields: Any
) -> None:
"""Layer *fields* onto the ``civitai`` block being assembled.
Description, version label, trigger words and gallery images all live
in the same dict and are contributed by separate branches, so each one
starts from what is already on disk and then applies whatever an
earlier branch queued in *updates*.
"""
merged = dict(metadata.get("civitai") or {})
queued = updates.get("civitai")
if isinstance(queued, dict):
merged.update(queued)
merged.update(fields)
updates["civitai"] = merged
@staticmethod
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
"""Return ``True`` when a list field should be overwritten.""" """Return ``True`` when a list field should be overwritten."""
return is_hf_model or not current_list return is_source_model or not current_list
@staticmethod @staticmethod
def _merge_tags(existing: List[str], new: List[str]) -> List[str]: def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
@@ -309,6 +436,129 @@ class PostProcessor:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
#: Separator between a label and its value. Published model cards routinely
#: wrap the numbers in markdown emphasis or quotes (``strength: **0.85 - 1.4**``,
#: ``CLIP 强度「0.5」``), so those are absorbed rather than treated as a break.
_EMPHASIS = "[\"'\u201c\u201d\u300c\u300d*_`\\s]*"
#: An explicitly stated strength/weight range, e.g. ``权重0.5-1.2``,
#: ``强度 0.8 ~ 1.2``, ``strength: **0.85 - 1.4**``.
_RANGE_DASH = "(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\uff0d|~|\uff5e|\u81f3|\u5230|to)"
_STRENGTH_RANGE_RE = re.compile(
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+(?:\.\d+)?)" + _EMPHASIS + _RANGE_DASH + _EMPHASIS
+ r"(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
#: A single strength/weight value, e.g. ``strength: 0.6``, ``权重 0.8``.
_STRENGTH_VALUE_RE = re.compile(
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
#: ``clip strength: 0.5`` / ``CLIP 强度 0.5``.
_CLIP_STRENGTH_RE = re.compile(
"clip" + _EMPHASIS + "(?:\u5f3a\u5ea6|strength)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
#: ``clip skip: 2`` / ``CLIP 跳过 2``.
_CLIP_SKIP_RE = re.compile(
"clip" + _EMPHASIS + "(?:skip|\u8df3\u8fc7)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+)",
re.IGNORECASE,
)
def _extract_usage_tips(text: str) -> str:
"""Extract stated strength/CLIP recommendations from prose.
This is the deterministic counterpart to the LLM's ``usage_tips`` output,
used when the LLM was skipped. It only recognises explicitly written
values — it never infers a range — and returns ``""`` when it finds none.
Returns:
A JSON string matching the skill's ``usage_tips`` schema, or ``""``.
"""
if not text:
return ""
tips: Dict[str, Any] = {}
# CLIP strength is resolved first and then blanked out, so the generic
# strength patterns cannot mistake `CLIP 强度 0.5` for the LoRA strength.
text_for_strength = text
clip_strength = _CLIP_STRENGTH_RE.search(text_for_strength)
if clip_strength:
tips["clip_strength"] = float(clip_strength.group(1))
text_for_strength = (
text_for_strength[: clip_strength.start()]
+ " "
+ text_for_strength[clip_strength.end() :]
)
range_match = _STRENGTH_RANGE_RE.search(text_for_strength)
if range_match:
low = float(range_match.group(1))
high = float(range_match.group(2))
if low > high:
low, high = high, low
tips["strength_min"] = low
tips["strength_max"] = high
tips["strength_range"] = f"{low:g}-{high:g}"
else:
value_match = _STRENGTH_VALUE_RE.search(text_for_strength)
if value_match:
tips["strength"] = float(value_match.group(1))
clip_skip = _CLIP_SKIP_RE.search(text)
if clip_skip:
tips["clip_skip"] = int(clip_skip.group(1))
if not tips:
return ""
return json.dumps(tips, ensure_ascii=False)
def _example_image(url: str, width: int, height: int) -> Dict[str, Any]:
"""Build a ``civitai.images`` entry for a site-provided example image.
The site publishes no prompt alongside these images, so the entry carries
empty prompt metadata and the LLM's recommended dimensions when it found
any (falling back to the same 512px placeholder the README extractors use).
"""
return {
"url": url,
"type": "image",
"nsfwLevel": 0,
"width": width or 512,
"height": height or 512,
"meta": {"prompt": "", "negativePrompt": ""},
"hasMeta": False,
"hasPositivePrompt": False,
}
def _dedupe_images(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Drop later entries that repeat an earlier image URL, keeping order."""
seen: set[str] = set()
unique: List[Dict[str, Any]] = []
for image in images:
url = image.get("url") or ""
if not url or url in seen:
continue
seen.add(url)
unique.append(image)
return unique
def _extract_yaml_instance_prompt(readme_content: str) -> str: def _extract_yaml_instance_prompt(readme_content: str) -> str:
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README. """Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
@@ -1,20 +1,23 @@
--- ---
name: enrich_hf_metadata name: enrich_hf_metadata
title: "Enrich Metadata from HuggingFace" title: "Enrich Metadata from Model Card"
description: > description: >
Parse the HuggingFace model card via LLM to extract description, trigger Parse the model card (README) from HuggingFace, ModelScope, or any other
words, base model, tags, and preview image URL. supported model site via LLM to extract description, trigger words, base
model, tags, and preview image URL.
llm_required: true llm_required: true
--- ---
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a HuggingFace model card (README.md). You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a model card (README).
## Model Information ## Model Information
- **Repository**: {{hf_url}} - **Source site**: {{source_label}} ({{source_platform}})
- **Model page**: {{source_url}}
- **Model file path**: {{model_path}} - **Model file path**: {{model_path}}
- **Model filename**: {{model_basename}} - **Model filename**: {{model_basename}}
- **Repository ID**: {{repo}} - **Repository ID**: {{source_id}}
- **Repository raw-file base URL**: {{asset_base_url}}
## Current Metadata (may be incomplete) ## Current Metadata (may be incomplete)
@@ -22,6 +25,34 @@ You are an expert assistant for AI image generation models. Your task is to extr
{{current_metadata}} {{current_metadata}}
``` ```
## Site-Provided Metadata (any field may be empty)
The model site publishes the following **alongside** the README. It is
first-hand information recorded by the site itself, so it outranks anything
you would otherwise guess:
- **Author description**: {{source_description}}
- **Base model reported by the site**: {{source_base_model}}
- **Trigger words recorded for this file**: {{source_trigger_words}}
- **Site-curated tags**:
{{source_official_tags}}
- **Example image URLs for this file**:
{{source_example_images}}
Use it as follows:
- A weight or strength range stated in the **author description** belongs in
``usage_tips`` (and in ``notes``); do not leave ``usage_tips`` empty when the
description states one.
- When the author description exists, base ``short_description`` on it rather
than on the README, which on some sites is auto-generated boilerplate.
- Treat the **site-curated tags** as strong signals for ``tags``: they are
already a curated content vocabulary, so prefer them over invented words.
- Treat the **base model reported by the site** as a strong hint for
``base_model``, but still map it to the EXACT canonical name from the
available base-model list.
- Use the **example image URLs** when the README contains no usable image.
## User Priority Tags Reference ## User Priority Tags Reference
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`): The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
@@ -39,7 +70,7 @@ name listed — do not invent aliases or modify variant suffixes.
{{base_models}} {{base_models}}
## HuggingFace README Content ## Model Card Content
``` ```
{{readme_content}} {{readme_content}}
@@ -52,10 +83,11 @@ Extract the following information from the README content above:
### base_model ### base_model
The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases. The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
Check the YAML frontmatter for ``base_model:`` first. If the frontmatter has no ``base_model:``, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name Check the **base model reported by the site** (above) and the YAML frontmatter ``base_model:`` first. If neither yields a match, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
### trigger_words ### trigger_words
The trigger words or activation prompts needed to use this LoRA. Look for: The trigger words or activation prompts needed to use this LoRA. Look for:
- The **trigger words recorded for this file** in the site-provided metadata (most authoritative)
- `instance_prompt:` in the YAML frontmatter - `instance_prompt:` in the YAML frontmatter
- Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:" - Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:"
- In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs) - In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs)
@@ -63,12 +95,13 @@ The trigger words or activation prompts needed to use this LoRA. Look for:
Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist. Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist.
### short_description ### short_description
A concise 1-2 sentence summary of what this model does. Extract from the "Model description" section or the first paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Return empty string if the README is too minimal. A concise 1-2 sentence summary of what this model does. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Prefer the **author description** from the site-provided metadata when it is present; otherwise extract from the "Model description" section or the first paragraph. Return empty string if the available content is too minimal.
### tags ### tags
3-8 relevant tags for categorizing this model. **Quality over quantity.** 3-8 relevant tags for categorizing this model. **Quality over quantity.**
Sources to consider: Sources to consider:
- The **site-curated tags** from the site-provided metadata (these are already filtered content tags — prefer them)
- The YAML frontmatter `tags:` list (filter out technical ones — see below) - The YAML frontmatter `tags:` list (filter out technical ones — see below)
- The subject, style, character, or concept the model represents - The subject, style, character, or concept the model represents
- The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart") - The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart")
@@ -79,7 +112,9 @@ Sources to consider:
2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags. 2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags.
3. **All lowercase, no spaces, no hyphens** (use single words like `"photorealistic"`, `"anime"`, `"character"`). 3. **All lowercase, and keep each tag's own wording.** Prefer the spelling already used by the site, the frontmatter, or the author — including hyphenated and multi-word tags such as `"sci-fi"`, `"semi-realistic"`, `"character-enhancement"` or `"art style"`. Do **not** strip separators or invent a single-word variant of a tag you are already including (e.g. do not emit both `"character-enhancement"` and `"character"`). When a tag is written in another script (e.g. Chinese), likewise keep it verbatim instead of translating it.
4. **Never invent a tag** that neither the site-provided metadata, the YAML frontmatter, nor the README text supports.
Return empty array if no meaningful content tags remain after filtering. Return empty array if no meaningful content tags remain after filtering.
@@ -92,13 +127,13 @@ The URL of the most suitable preview image from the README. Look for:
- The YAML frontmatter `widget:` section (which often has `output.url` fields) - The YAML frontmatter `widget:` section (which often has `output.url` fields)
- In collection repos: the sample images listed **under the section** for this specific model version - In collection repos: the sample images listed **under the section** for this specific model version
- Generic `![alt](url)` in the body - Generic `![alt](url)` in the body
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL as `https://huggingface.co/{{repo}}/resolve/main/{filename}`. If no suitable image is found, return an empty string. Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL from the repository raw-file base URL (`{{asset_base_url}}`) plus the relative path. If the README has no suitable image, fall back to the site-provided **example image URLs** for this file. If nothing is available, return an empty string.
### notes ### notes
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Return empty string if the README has no useful usage info. A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Include the **author description** from the site-provided metadata when it is present. Return empty string if there is no useful usage info.
### usage_tips ### usage_tips
A JSON string with structured usage recommendations. Extract from the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5"). Possible fields (include only those you can determine): A JSON string with structured usage recommendations. Extract from the **author description** (site-provided metadata) and the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5", "权重0.5-1.2"). Possible fields (include only those you can determine):
```json ```json
{ {
@@ -121,7 +156,7 @@ Your confidence level in the extracted data:
## Important: Handling Collection Repos (multiple model files) ## Important: Handling Collection Repos (multiple model files)
Many HuggingFace repos contain **multiple model files** in a single repository Many model repositories contain **multiple model files** in a single repository
(e.g. a "LoRA collection" with different styles/characters in separate files). (e.g. a "LoRA collection" with different styles/characters in separate files).
The model file currently being enriched is: **`{{model_basename}}`** The model file currently being enriched is: **`{{model_basename}}`**
@@ -1,8 +1,15 @@
"""HF README processing for the ``enrich_hf_metadata`` skill. """Model card (README) processing for the ``enrich_hf_metadata`` skill.
Provides README cleaning for LLM injection, gallery/image extraction from Provides README cleaning for LLM injection, gallery/image extraction from
multiple formats (YAML widget, markdown, HTML ``<img>``, gallery tables), multiple formats (YAML widget, markdown, HTML ``<img>``, gallery tables),
and section-based README trimming for collection repos. and section-based README trimming for collection repos.
The extractors default to Hugging Face asset URLs, but every one of them
accepts an explicit ``base_url`` so the same parsing works for any model
source (ModelScope, ...). See :mod:`py.services.model_sources`.
This module deliberately has no package-relative imports: it is also loaded
standalone by the README-processing test harness.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,12 +22,25 @@ from typing import Any, List, Tuple
_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)") _REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
def resolve_asset_base_url(repo: str, base_url: str | None = None) -> str:
"""Return the base URL used to resolve repository-relative assets.
Falls back to the historical Hugging Face layout when *base_url* is not
supplied, so existing callers keep their behaviour.
"""
if base_url:
return base_url.rstrip("/")
return f"https://huggingface.co/{repo}/resolve/main"
def extract_simple_markdown_images( def extract_simple_markdown_images(
markdown_text: str, markdown_text: str,
repo: str, repo: str,
existing_urls: set[str] | None = None, existing_urls: set[str] | None = None,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Extract standalone markdown images from the README body. """Extract standalone markdown images from the README body.
@@ -32,10 +52,10 @@ def extract_simple_markdown_images(
Returns a list of dicts in the same ``civitai.images`` format as Returns a list of dicts in the same ``civitai.images`` format as
:func:`extract_gallery_images`. :func:`extract_gallery_images`.
""" """
if not markdown_text or not repo: if not markdown_text or not (repo or base_url):
return [] return []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = [] images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set() seen_urls: set[str] = set(existing_urls) if existing_urls else set()
@@ -89,20 +109,21 @@ def extract_html_img_tags(
existing_urls: set[str] | None = None, existing_urls: set[str] | None = None,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README. """Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
``<img>`` tags exclusively for their sample images, with no markdown ``<img>`` tags exclusively for their sample images, with no markdown
``![]()`` equivalents. This function finds those tags and constructs ``![]()`` equivalents. This function finds those tags and constructs
resolvable HF URLs. resolvable URLs.
Returns a list of dicts in the ``civitai.images`` format. Returns a list of dicts in the ``civitai.images`` format.
""" """
if not markdown_text or not repo: if not markdown_text or not (repo or base_url):
return [] return []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = [] images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set() seen_urls: set[str] = set(existing_urls) if existing_urls else set()
@@ -166,7 +187,7 @@ def extract_html_img_tags(
def extract_repo_from_hf_url(hf_url: str) -> str: def extract_repo_from_hf_url(hf_url: str) -> str:
"""Extract ``user/repo`` from a HuggingFace URL.""" """Extract ``user/repo`` from a HuggingFace URL."""
m = _REPO_URL_PATTERN.match(hf_url) m = _REPO_URL_PATTERN.match(hf_url or "")
return m.group(1) if m else "" return m.group(1) if m else ""
@@ -175,21 +196,23 @@ def extract_gallery_images(
repo: str, repo: str,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
base_url: str | None = None,
) -> List[dict[str, Any]]: ) -> List[dict[str, Any]]:
"""Extract widget/gallery images from the YAML frontmatter of a HF README. """Extract widget/gallery images from the YAML frontmatter of a README.
Args: Args:
markdown_text: Raw README content. markdown_text: Raw README content.
repo: HF repo identifier (``user/repo``). repo: Repository identifier (``user/repo``).
default_width: Fallback width when the README provides no dimension. default_width: Fallback width when the README provides no dimension.
default_height: Fallback height when the README provides no dimension. default_height: Fallback height when the README provides no dimension.
base_url: Overrides the asset base URL (defaults to Hugging Face).
Returns a list of dicts compatible with the ``civitai.images`` metadata Returns a list of dicts compatible with the ``civitai.images`` metadata
format, each containing ``url`` (absolute HF URL), ``meta.prompt``, format, each containing ``url`` (absolute), ``meta.prompt``,
``width``, ``height``, and ``type``. Returns an empty list when no ``width``, ``height``, and ``type``. Returns an empty list when no
widget entries are found or when *repo* is empty. widget entries are found or when *repo* is empty.
""" """
if not markdown_text or not repo: if not markdown_text or not (repo or base_url):
return [] return []
frontmatter = _extract_frontmatter(markdown_text) frontmatter = _extract_frontmatter(markdown_text)
@@ -197,7 +220,7 @@ def extract_gallery_images(
return [] return []
images: List[dict[str, Any]] = [] images: List[dict[str, Any]] = []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = resolve_asset_base_url(repo, base_url)
w = default_width or 512 w = default_width or 512
h = default_height or 512 h = default_height or 512
@@ -279,10 +302,11 @@ def extract_gallery_table_images(
existing_urls: set[str] | None = None, existing_urls: set[str] | None = None,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Extract images from ``| Preview | Prompt |`` markdown gallery tables. """Extract images from ``| Preview | Prompt |`` markdown gallery tables.
Many HF READMEs include a sample-gallery table in the body (outside Many READMEs include a sample-gallery table in the body (outside
the YAML frontmatter) that shows generation examples with their the YAML frontmatter) that shows generation examples with their
prompts. This function parses those tables and merges results with prompts. This function parses those tables and merges results with
the widget-sourced images from :func:`extract_gallery_images`. the widget-sourced images from :func:`extract_gallery_images`.
@@ -291,10 +315,10 @@ def extract_gallery_table_images(
:func:`extract_gallery_images`. Already-seen URLs (from *existing_urls*) :func:`extract_gallery_images`. Already-seen URLs (from *existing_urls*)
are skipped. are skipped.
""" """
if not markdown_text or not repo: if not markdown_text or not (repo or base_url):
return [] return []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = [] images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set() seen_urls: set[str] = set(existing_urls) if existing_urls else set()
lines = markdown_text.split("\n") lines = markdown_text.split("\n")
@@ -368,12 +392,18 @@ def _extract_frontmatter(text: str) -> str:
def convert_readme_to_html(markdown_text: str | None) -> str: def convert_readme_to_html(markdown_text: str | None) -> str:
"""Convert HF README markdown to sanitised HTML.""" """Convert HF README markdown to sanitised HTML.
Site-generated placeholder notices are dropped here too, so a repository
whose author wrote nothing does not store the download instructions as its
model description; the result is an empty string in that case.
"""
if not markdown_text: if not markdown_text:
return "" return ""
text = markdown_text text = markdown_text
text = _strip_frontmatter(text) text = _strip_frontmatter(text)
text = _strip_generated_card_boilerplate(text)
text = _strip_gallery(text) text = _strip_gallery(text)
text = _strip_badge_images(text) text = _strip_badge_images(text)
text = _strip_html_comments(text) text = _strip_html_comments(text)
@@ -420,6 +450,59 @@ _MASSIVE_LIST_LINE_MIN_LEN = 150
#: Minimum consecutive enumeration lines to trigger massive-list stripping. #: Minimum consecutive enumeration lines to trigger massive-list stripping.
_MASSIVE_LIST_THRESHOLD = 8 _MASSIVE_LIST_THRESHOLD = 8
#: Substrings identifying text a *site* generated to fill a model card whose
#: author wrote nothing, as opposed to the author's own content. ModelScope
#: renders such a card as a placeholder notice, a block of SDK/git download
#: instructions, and a closing invitation to improve the card.
#:
#: Matched as substrings rather than whole headings because the notices are
#: prose, and because non-Latin scripts are not space-delimited — the notice
#: continues with a full-width period, so the ``title == kw`` style matching
#: used for :data:`_BOILERPLATE_HEADERS` would never fire.
_GENERATED_CARD_MARKERS: tuple[str, ...] = (
"当前模型的贡献者未提供更加详细的模型介绍",
"您可以通过如下",
"如果您是本模型的贡献者",
)
def _strip_generated_card_boilerplate(text: str) -> str:
"""Remove the notices a site generates to fill an empty model card.
A repository whose uploader wrote no README still gets a card: ModelScope
answers with "the contributor provided no further description", the SDK
and git download commands, and an invitation to complete the card. None
of it describes the model, yet it was landing in both the LLM prompt and
the stored description.
A notice that is a heading takes its whole section with it, so the
download block goes too; a stand-alone notice line is dropped on its own.
Content the author added later — under a heading of equal or higher
level — is kept, so an improved card is not thrown away.
"""
lines = text.split("\n")
out: list[str] = []
skip_until_level: int | None = None
for line in lines:
level = _heading_level(line)
if any(marker in line for marker in _GENERATED_CARD_MARKERS):
if level > 0:
skip_until_level = level
continue
if skip_until_level is not None:
if level > 0 and level <= skip_until_level:
skip_until_level = None
else:
continue
out.append(line)
return "\n".join(out)
def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> str: def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> str:
"""Clean a HF README for injection into an LLM metadata-extraction prompt. """Clean a HF README for injection into an LLM metadata-extraction prompt.
@@ -429,6 +512,8 @@ def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> s
* ``widget:`` YAML block (example prompts + output URLs) * ``widget:`` YAML block (example prompts + output URLs)
* ``<Gallery />`` tags and wrappers * ``<Gallery />`` tags and wrappers
* Site-generated placeholder notices for a card the author never wrote
(see :func:`_strip_generated_card_boilerplate`)
* Fenced code blocks (Python / bash / bibtex / yaml) * Fenced code blocks (Python / bash / bibtex / yaml)
* Standalone ``![...](...)`` image lines and ``<img>`` tags * Standalone ``![...](...)`` image lines and ``<img>`` tags
* Training-parameter tables * Training-parameter tables
@@ -454,6 +539,7 @@ def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> s
# Order matters — broader strips first, then finer ones. # Order matters — broader strips first, then finer ones.
text = _strip_gallery(text) text = _strip_gallery(text)
text = _strip_widget_section(text) text = _strip_widget_section(text)
text = _strip_generated_card_boilerplate(text)
text = _strip_fenced_code_blocks(text) text = _strip_fenced_code_blocks(text)
text = _strip_standalone_images(text) text = _strip_standalone_images(text)
text = _strip_training_tables(text) text = _strip_training_tables(text)
+96 -18
View File
@@ -81,6 +81,14 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.red/api/download/", "https://civitai.red/api/download/",
) )
#: Hosts whose authenticated downloads redirect to a signed CDN URL. aria2
#: forwards custom headers to redirect targets, so for these hosts the
#: redirect is resolved first and the signed URL is handed to aria2 without
#: the credentials.
AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES = CIVITAI_DOWNLOAD_URL_PREFIXES + (
"https://huggingface.co/",
)
def _is_no_uri_available_error(message: str) -> bool: def _is_no_uri_available_error(message: str) -> bool:
"""Return True for aria2's "No URI available" transfer failure. """Return True for aria2's "No URI available" transfer failure.
@@ -161,6 +169,11 @@ class Aria2Downloader:
(typically an expired CivitAI signed URL): a fresh URL is resolved (typically an expired CivitAI signed URL): a fresh URL is resolved
and the partial download continues. Recovery is bounded by and the partial download continues. Recovery is bounded by
``MAX_TRANSFER_RECOVERY_ATTEMPTS``. ``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
Cancellation never leaks daemon transfers: the gid is tracked in
``_transfers`` before any post-``addUri`` await, and a gid accepted
by the daemon while the caller is being cancelled is removed again
before the ``CancelledError`` propagates.
""" """
await self._ensure_process() await self._ensure_process()
@@ -251,7 +264,11 @@ class Aria2Downloader:
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)
finally: finally:
current = self._transfers.get(download_id) current = self._transfers.get(download_id)
if current is not None and current.gid == transfer.gid: if (
transfer is not None
and current is not None
and current.gid == transfer.gid
):
self._transfers.pop(download_id, None) self._transfers.pop(download_id, None)
async def _get_status_with_retry( async def _get_status_with_retry(
@@ -299,12 +316,12 @@ class Aria2Downloader:
resolved_url = url resolved_url = url
request_headers = headers request_headers = headers
if headers and url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES): if headers and url.startswith(AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES):
resolved_url = await self._resolve_authenticated_redirect_url(url, headers) resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
if resolved_url != url: if resolved_url != url:
request_headers = None request_headers = None
logger.debug( logger.debug(
"Resolved Civitai download %s to signed URL for aria2", "Resolved authenticated download %s to signed URL for aria2",
download_id, download_id,
) )
@@ -332,28 +349,50 @@ class Aria2Downloader:
] ]
logger.debug( logger.debug(
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)", "Submitting aria2 download %s -> %s (auth=%s, signed_url=%s)",
download_id, download_id,
save_path, save_path,
bool(request_headers), bool(request_headers),
resolved_url != url, resolved_url != url,
) )
# Shield the addUri RPC from cancellation: the daemon may accept the
# download even when the caller is cancelled while the request is in
# flight. On cancellation, wait for the RPC result so the freshly
# created gid can be removed instead of leaking an untracked
# download that keeps running in the daemon.
add_task = asyncio.ensure_future(
self._rpc_call("aria2.addUri", [[resolved_url], options])
)
try: try:
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options]) gid = await asyncio.shield(add_task)
except asyncio.CancelledError:
leaked_gid: Any = None
try:
leaked_gid = await add_task
except Exception:
leaked_gid = None
if isinstance(leaked_gid, str) and leaked_gid:
logger.info(
"Removing aria2 gid %s accepted while download %s was "
"being cancelled",
leaked_gid,
download_id,
)
try:
await self._rpc_call("aria2.forceRemove", [leaked_gid])
except Exception as exc:
logger.warning(
"Failed to remove leaked aria2 gid %s for download %s: %s",
leaked_gid,
download_id,
exc,
)
raise
except Exception as exc: except Exception as exc:
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
logger.debug("aria2 accepted download %s with gid %s", download_id, gid) logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": save_path,
"status": "downloading",
"url": url,
},
)
return gid return gid
async def _register_transfer( async def _register_transfer(
@@ -372,7 +411,46 @@ class Aria2Downloader:
headers=headers, headers=headers,
) )
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path)) transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
# Register the transfer before any further await: once the daemon
# holds the gid, cancel_download() must be able to find it. An await
# in between would open a window where a concurrent cancel reports
# "Download task not found" and the daemon keeps downloading
# untracked.
self._transfers[download_id] = transfer self._transfers[download_id] = transfer
try:
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": transfer.save_path,
"status": "downloading",
"url": url,
},
)
except asyncio.CancelledError:
# The task was cancelled while persisting state and the
# coordinator's cancel ran before the transfer was registered
# above. Remove the daemon transfer unless it was deliberately
# paused (skip_download preserves paused transfers for resume).
status = None
try:
status = await self.get_status(download_id)
except Exception:
status = None
if status is not None and status.get("status") != "paused":
try:
await self._rpc_call("aria2.forceRemove", [gid])
except Exception as exc:
logger.warning(
"Failed to remove aria2 gid %s for cancelled download %s: %s",
gid,
download_id,
exc,
)
current = self._transfers.get(download_id)
if current is not None and current.gid == gid:
self._transfers.pop(download_id, None)
raise
return transfer return transfer
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]: async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
@@ -662,7 +740,7 @@ class Aria2Downloader:
if location: if location:
return location return location
raise Aria2Error( raise Aria2Error(
"Authenticated Civitai redirect did not include a Location header" "Authenticated redirect did not include a Location header"
) )
if response.status == 200: if response.status == 200:
@@ -670,12 +748,12 @@ class Aria2Downloader:
body = await response.text() body = await response.text()
raise Aria2Error( raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}" f"Failed to resolve authenticated redirect: status={response.status} body={body[:300]}"
) )
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
if is_ssl_cert_verify_error(exc): if is_ssl_cert_verify_error(exc):
logger.error( logger.error(
"SSL certificate verification failed during Civitai redirect " "SSL certificate verification failed during authenticated redirect "
"resolution for %s. This is usually caused by an outdated CA " "resolution for %s. This is usually caused by an outdated CA "
"certificate bundle. Recommended fixes:\n" "certificate bundle. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n" " 1. pip install --upgrade certifi\n"
@@ -683,7 +761,7 @@ class Aria2Downloader:
url, url,
) )
raise Aria2Error( raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}" f"Failed to resolve authenticated redirect: {exc}"
) from exc ) from exc
async def _ensure_process(self) -> None: async def _ensure_process(self) -> None:
+3 -1
View File
@@ -27,6 +27,8 @@ import os
import threading import threading
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from ..utils.sidecar_paths import get_metadata_path
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
from .model_scanner import ModelScanner from .model_scanner import ModelScanner
@@ -41,7 +43,7 @@ def _resolve_autov3(file_path: str) -> str:
safetensors header hash. Returns ``''`` when neither is available. safetensors header hash. Returns ``''`` when neither is available.
""" """
try: try:
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json" metadata_path = get_metadata_path(file_path)
if os.path.exists(metadata_path): if os.path.exists(metadata_path):
with open(metadata_path, "r", encoding="utf-8") as handle: with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle) payload = json.load(handle)
+74 -22
View File
@@ -7,7 +7,7 @@ import logging
import os import os
import time import time
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES, VALID_OTHER_SUB_TYPES
from ..utils.models import BaseModelMetadata from ..utils.models import BaseModelMetadata
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
@@ -21,11 +21,13 @@ from .model_query import (
resolve_sub_type, resolve_sub_type,
) )
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .model_sources import source_group_key
from ..utils.civitai_utils import build_civitai_model_page_url from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from .model_scanner import ReconcileScope
from .model_update_service import ModelUpdateService from .model_update_service import ModelUpdateService
@@ -740,31 +742,30 @@ class BaseModelService(ABC):
return annotated return annotated
@staticmethod @staticmethod
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]: def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None.""" """Return the external-source group key for *item*, or None.
hf_url = item.get("hf_url") if isinstance(item, dict) else None
if not hf_url or not isinstance(hf_url, str): Only sources with a site-native model identity yield a key:
return None ModelScope groups by its published-model id (``ms:{id}``), TensorArt
m = re.match( by its numeric model id (``ta:{id}``); Hugging Face models never
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip() group (see :meth:`ModelSource.group_key`).
) """
if not m: return source_group_key(item)
return None
return f"hf:{m.group(1)}"
@staticmethod @staticmethod
def _extract_group_key(item: Dict[str, Any]) -> Union[int, str, None]: def _extract_group_key(item: Dict[str, Any]) -> Union[int, str, None]:
"""Return the group identity key: CivitAI modelId (int) or HF repo (str). """Return the group identity key.
Preference order: Preference order:
1. CivitAI ``modelId`` (int) 1. CivitAI ``modelId`` (int)
2. HF repo identity ``hf:{owner}/{repo}`` (str) 2. External model source identity, e.g. ``ms:{model_id}``,
``ta:{model_id}`` (str)
3. ``None`` (no known grouping source) 3. ``None`` (no known grouping source)
""" """
mid = BaseModelService._extract_model_id(item) mid = BaseModelService._extract_model_id(item)
if mid is not None: if mid is not None:
return mid return mid
return BaseModelService._extract_hf_group_key(item) return BaseModelService._extract_source_group_key(item)
@staticmethod @staticmethod
def _extract_model_id(item: Dict[str, Any]) -> Optional[int]: def _extract_model_id(item: Dict[str, Any]) -> Optional[int]:
@@ -904,6 +905,11 @@ class BaseModelService(ABC):
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
): ):
continue continue
if (
self.model_type == "other"
and normalized_type not in VALID_OTHER_SUB_TYPES
):
continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
@@ -931,12 +937,33 @@ class BaseModelService(ABC):
return self.scanner.get_hash_by_path(file_path) return self.scanner.get_hash_by_path(file_path)
async def scan_models( async def scan_models(
self, force_refresh: bool = False, rebuild_cache: bool = False self,
): force_refresh: bool = False,
"""Trigger model scanning""" rebuild_cache: bool = False,
return await self.scanner.get_cached_data( scope: Optional["ReconcileScope"] = None,
force_refresh=force_refresh, rebuild_cache=rebuild_cache ) -> Optional[Dict[str, Any]]:
"""Trigger model scanning, optionally restricted to a scope.
Returns the reconcile summary (added / removed / repaired / skipped
roots / kept entries) for incremental scans, ``None`` for a full
rebuild.
"""
await self.scanner.get_cached_data(
force_refresh=force_refresh, rebuild_cache=rebuild_cache, scope=scope
) )
return self.scanner.last_reconcile_summary
def describe_model_roots(self) -> List[Dict[str, Any]]:
"""Describe configured roots (label / reachability / cached count).
Pure read: callers that want a drive which was plugged in after startup to
appear as a normal row call :meth:`refresh_model_roots` first.
"""
return self.scanner.describe_model_roots()
def refresh_model_roots(self) -> List[str]:
"""Admit configured roots that became readable again (append-only)."""
return self.scanner.refresh_model_roots()
async def get_model_info_by_name(self, name: str): async def get_model_info_by_name(self, name: str):
"""Get model information by name""" """Get model information by name"""
@@ -1295,6 +1322,27 @@ class BaseModelService(ABC):
path_for_sorting, path_for_sorting,
) )
@staticmethod
def _relative_path_folder_group_sort_key(
relative_path: str, include_terms: List[str]
) -> tuple:
"""Group paths by folder, then sort by relevance within each group.
Folders are ordered alphabetically (case-insensitive) by their full
folder path, with root-level files (empty folder) first. Within a
folder, paths keep the relevance ordering of
``_relative_path_sort_key``. This keeps same-folder entries together
in the autocomplete dropdown instead of interleaving them by filename.
"""
path_for_sorting = BaseModelService._remove_model_extension(
relative_path.lower()
)
folder = path_for_sorting.rpartition(os.sep)[0]
return (folder,) + BaseModelService._relative_path_sort_key(
relative_path, include_terms
)
async def search_relative_paths( async def search_relative_paths(
self, self,
search_term: str, search_term: str,
@@ -1404,9 +1452,13 @@ class BaseModelService(ABC):
): ):
matching_paths.append(relative_path) matching_paths.append(relative_path)
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically) # Group by folder (root first, then alphabetically) and sort by
# relevance (prefix and earliest hits, then length and alphabetically)
# within each folder group.
matching_paths.sort( matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms) key=lambda relative: self._relative_path_folder_group_sort_key(
relative, include_terms
)
) )
# Apply offset and limit # Apply offset and limit
+21
View File
@@ -20,6 +20,11 @@ from .recipes import (
RecipeDownloadError, RecipeDownloadError,
RecipeNotFoundError, RecipeNotFoundError,
) )
from .recipes.import_info import (
CHANNEL_BATCH_IMPORT_LOCAL,
CHANNEL_BATCH_IMPORT_URL,
build_import_info,
)
class ImportItemType(Enum): class ImportItemType(Enum):
@@ -624,11 +629,27 @@ class BatchImportService:
"loras": loras, "loras": loras,
"gen_params": payload.get("gen_params", {}), "gen_params": payload.get("gen_params", {}),
"source_path": item.source, "source_path": item.source,
# Record why this import ended up with no LoRAs so the
# recipe modal can explain it (collapsed by default).
"import_info": build_import_info(
(
CHANNEL_BATCH_IMPORT_URL
if item.item_type == ImportItemType.URL
else CHANNEL_BATCH_IMPORT_LOCAL
),
payload.get("diagnostics"),
loras,
),
} }
if payload.get("checkpoint"): if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"] metadata["checkpoint"] = payload["checkpoint"]
# A workflow recovered from the source's original rendition
# travels as metadata and is embedded into the stored image.
if payload.get("workflow"):
metadata["workflow"] = payload["workflow"]
nsfw = payload.get("preview_nsfw_level") nsfw = payload.get("preview_nsfw_level")
if isinstance(nsfw, int) and nsfw > 0: if isinstance(nsfw, int) and nsfw > 0:
metadata["preview_nsfw_level"] = nsfw metadata["preview_nsfw_level"] = nsfw
+12 -5
View File
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3 from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_preview_dir, is_centralized
from ..config import config from ..config import config
from .model_scanner import ModelScanner, _is_excluded_dir from .model_scanner import ModelScanner, _is_excluded_dir
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
@@ -61,10 +62,9 @@ class CheckpointScanner(ModelScanner):
return None return None
base_name = os.path.splitext(os.path.basename(file_path))[0] base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image # Find preview image
preview_url = find_preview_file(base_name, dir_path) preview_url = find_preview_file(base_name, get_preview_dir(file_path))
# AutoV3 reads only the safetensors header, so it is cheap even for # AutoV3 reads only the safetensors header, so it is cheap even for
# large checkpoints; record the checked state at creation time ("" = # large checkpoints; record the checked state at creation time ("" =
@@ -322,6 +322,11 @@ class CheckpointScanner(ModelScanner):
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]: async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for checkpoint metadata files with pending hash status.""" """Scan filesystem for checkpoint metadata files with pending hash status."""
# Centralized mode stores sidecars in the mirror tree, not next to the
# models; walk the mirror instead of the model folders.
if is_centralized():
return self._find_pending_models_in_sidecar_mirror()
pending_models = [] pending_models = []
for root_path in self.get_model_roots(): for root_path in self.get_model_roots():
@@ -410,6 +415,10 @@ class CheckpointScanner(ModelScanner):
return None return None
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Resolve sub_type from the configured root that contains the file."""
return self._resolve_sub_type(self._find_root_for_file(file_path))
def adjust_metadata(self, metadata, file_path, root_path): def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type.""" """Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path) sub_type = self._resolve_sub_type(root_path)
@@ -419,9 +428,7 @@ class CheckpointScanner(ModelScanner):
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]: def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Adjust entries loaded from the persisted cache to ensure sub_type is set.""" """Adjust entries loaded from the persisted cache to ensure sub_type is set."""
sub_type = self._resolve_sub_type( sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
self._find_root_for_file(entry.get("file_path"))
)
if sub_type: if sub_type:
entry["sub_type"] = sub_type entry["sub_type"] = sub_type
return entry return entry
+4
View File
@@ -67,6 +67,10 @@ class CheckpointService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True), "civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data), "auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"), "version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""), "hf_url": model_data.get("hf_url", ""),
} }
+212 -2
View File
@@ -20,8 +20,13 @@ from .model_metadata_provider import (
) )
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload from ..utils.civitai_utils import (
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES build_civitai_model_page_url,
civitai_page_host_candidates,
resolve_license_payload,
)
from ..utils.civitai_page_prices import parse_model_page_prices
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +36,24 @@ logger = logging.getLogger(__name__)
_CREATOR_COUNT_CACHE_TTL_SECONDS = 600 _CREATOR_COUNT_CACHE_TTL_SECONDS = 600
_creator_model_count_cache: Dict[str, Tuple[float, Optional[int]]] = {} _creator_model_count_cache: Dict[str, Tuple[float, Optional[int]]] = {}
# How long a page host stays on the skip list after refusing a request outright
# (Cloudflare's challenge, surfaced as 403 "Access forbidden"). Long enough to
# cover a whole update refresh, short enough to recover within a session.
_PAGE_HOST_BLOCK_TTL = 15 * 60
def _is_host_level_refusal(message: str) -> bool:
"""Whether a failed request means "this host refuses us" rather than "this
model is unavailable".
``downloader.make_request`` collapses statuses into prose, and 403 ("Access
forbidden") is the one a Cloudflare challenge produces. A 404 ("Resource not
found") is model-specific — mature pages are hidden from anonymous visitors —
so it must not put the whole host on the skip list.
"""
return "forbidden" in message.lower()
class CivitaiClient: class CivitaiClient:
_instance = None _instance = None
@@ -66,6 +89,10 @@ class CivitaiClient:
str, Tuple[Optional[Dict[str, Any]], Optional[str]] str, Tuple[Optional[Dict[str, Any]], Optional[str]]
] = OrderedDict() ] = OrderedDict()
self._MAX_CACHE_ENTRIES = 500 self._MAX_CACHE_ENTRIES = 500
# Model-page host bookkeeping: which host last worked, and which ones are
# currently refusing us (see get_model_prices).
self._page_host_preference: Optional[str] = None
self._page_host_blocked: Dict[str, float] = {}
def _build_image_info_url(self, image_id: str) -> str: def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true" return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
@@ -180,6 +207,11 @@ class CivitaiClient:
async def get_model_by_hash( async def get_model_by_hash(
self, model_hash: str self, model_hash: str
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
if is_empty_placeholder_hash(model_hash):
# The empty-hash placeholder (SHA256 of an empty byte string)
# matches no real file; CivitAI's by-hash index can contain
# polluted entries for it, so never resolve it.
return None, "Model not found"
try: try:
success, version = await self._make_request( success, version = await self._make_request(
"GET", "GET",
@@ -378,6 +410,138 @@ class CivitaiClient:
logger.error(f"Error fetching model versions in bulk: {exc}") logger.error(f"Error fetching model versions in bulk: {exc}")
return None return None
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
"""Fetch per-version buzz prices for one model from its public page.
CivitAI's public REST API deliberately omits prices, but the model page
embeds the site's own ``model.getById`` result (including
``paidAccess.terms``) in its server-rendered payload. One request covers
every version of the model. Returns ``{version_id: price fields}``, an
empty dict when the page loads but lists no gated version, or None when
the page could not be read or understood — callers keep any stored price.
Several hosts are tried in order, because the hosts are not equivalent:
* ``civitai.red`` serves mature model pages that ``civitai.com`` hides from
anonymous visitors, but it is behind a Cloudflare challenge that refuses
non-browser clients outright (403 for any User-Agent).
* ``civitai.com`` / ``civitai.green`` answer normally for anonymously
visible models, and 404 for the mature ones.
So the user's ``civitai_host`` preference is a starting point, not the only
option. Mature models whose page cannot be read from any host stay
priceless — see the known limitation in
``docs/plans/paid-model-price-tracking.md``.
This is a public anonymous page fetch: no API key and no internal
endpoint, so a failure here must never fail the update check itself.
"""
try:
normalized_id = int(model_id)
except (TypeError, ValueError):
return None
candidates = self._page_host_candidates()
failures: List[str] = []
for host in candidates:
url = build_civitai_model_page_url(normalized_id, host=host)
if not url:
continue
try:
success, result = await self._make_request(
"GET",
url,
use_auth=False,
custom_headers={"Accept": "text/html"},
)
except RateLimitError:
# The shared rate-limit gate already recorded it; skip this model.
raise
except Exception as exc: # pragma: no cover - defensive
failures.append(f"{host}: {exc}")
continue
if not success or not isinstance(result, str):
message = result if isinstance(result, str) else type(result).__name__
failures.append(f"{host}: {message}")
if isinstance(result, str) and _is_host_level_refusal(result):
# A refusal like Cloudflare's "Access forbidden" applies to the
# host, not to this model, so stop paying for it for a while.
self._block_page_host(host)
continue
prices = parse_model_page_prices(result)
if prices is None:
failures.append(f"{host}: no usable price payload")
continue
self._remember_page_host(host)
return prices
logger.warning(
"No price source for model %s; tried %s. Mature models are only served "
"by civitai.red, which challenges non-browser clients.",
model_id,
"; ".join(failures) or "no candidate hosts",
)
return None
def _page_host_candidates(self) -> List[str]:
"""Ordered hosts to try: the last one that worked, then the preference."""
preferred = self._page_host()
ordered = list(civitai_page_host_candidates(preferred))
if self._page_host_preference and self._page_host_preference in ordered:
ordered.remove(self._page_host_preference)
ordered.insert(0, self._page_host_preference)
now = time.time()
usable = [
host
for host in ordered
if now - self._page_host_blocked.get(host, 0.0) >= _PAGE_HOST_BLOCK_TTL
]
# Never return an empty list: a blocked host is still better than no attempt
# once the preference and the memo disagree.
return usable or ordered
def _remember_page_host(self, host: str) -> None:
if self._page_host_preference != host:
logger.info("CivitAI model pages are being read from %s", host)
self._page_host_preference = host
self._page_host_blocked.pop(host, None)
def _block_page_host(self, host: str) -> None:
if host in self._page_host_blocked:
return
# Log once per host per TTL: the failure is host-wide, so repeating it for
# every mature model in the library would be pure noise.
logger.warning(
"CivitAI model pages on %s refused the request (likely a Cloudflare "
"challenge); skipping that host for %d minutes",
host,
_PAGE_HOST_BLOCK_TTL // 60,
)
self._page_host_blocked[host] = time.time()
if self._page_host_preference == host:
self._page_host_preference = None
def _page_host(self) -> Optional[str]:
"""Resolve the page host from the ``civitai_host`` setting."""
try:
from .settings_manager import get_settings_manager
settings = get_settings_manager()
return settings.get("civitai_host") if settings else None
except Exception:
return None
async def get_model_version( async def get_model_version(
self, model_id: int | None = None, version_id: int | None = None self, model_id: int | None = None, version_id: int | None = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
@@ -500,9 +664,55 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by id {version_id}") logger.warning(f"Failed to fetch version by id {version_id}")
return None return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
"""Fetch raw stored file info via the model-versions/mini endpoint.
The public REST API rewrites ``files[].name`` to
``"{model}_{version}"`` for non-LoRA model types, so every
precision variant of a multi-file version shares one name (#1100).
The mini endpoint returns the raw ``ModelFile.name`` in
``fileName``. ``file_id`` is mandatory: without it mini picks a
file via its own primary-file logic, which can disagree with the
REST ``primary`` flag.
Returns the mini payload dict on success, None on any failure.
"""
try:
success, data = await self._make_request(
"GET",
f"{self.base_url}/model-versions/mini/{version_id}",
params={"modelFileId": file_id},
use_auth=True,
)
if success and isinstance(data, dict):
return data
if is_expected_offline_error(data):
return None
logger.debug(
"Mini endpoint lookup failed for version %s file %s: %s",
version_id,
file_id,
data,
)
return None
except RateLimitError:
raise
except Exception as exc:
logger.debug(
"Error fetching mini info for version %s file %s: %s",
version_id,
file_id,
exc,
)
return None
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]: async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
if not model_hash: if not model_hash:
return None return None
if is_empty_placeholder_hash(model_hash):
return None
success, version = await self._make_request( success, version = await self._make_request(
"GET", "GET",
+469 -62
View File
@@ -17,27 +17,46 @@ from dataclasses import dataclass, field
import uuid import uuid
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata from ..utils.models import (
LoraMetadata,
CheckpointMetadata,
EmbeddingMetadata,
OtherModelMetadata,
)
from ..utils.constants import ( from ..utils.constants import (
CARD_PREVIEW_WIDTH, CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS, MAX_FOLDER_NAME_LENGTH,
MAX_PATH_TAG_LENGTH,
MODEL_WEIGHT_FILE_TYPES, MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
) )
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.paid_access import (
is_early_access_deadline_active,
is_gate_active,
is_permanent_paid,
normalize_paid_access,
parse_civitai_timestamp,
)
from ..utils.file_utils import calculate_sha256, calculate_autov3 from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .metadata_service import get_default_metadata_provider, get_metadata_provider from .metadata_service import get_default_metadata_provider, get_metadata_provider
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .errors import DownloadRateLimitError, RateLimitError
from .rate_limit_coordinator import RateLimitCoordinator
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService from .download_queue_service import DownloadQueueService
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
# Download to temporary file first # Download to temporary file first
import tempfile import tempfile
@@ -49,6 +68,15 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.red/api/download/", "https://civitai.red/api/download/",
) )
# Hosts a model download may hit (metadata + file transfer). The pre-flight
# cooldown gate consults the RateLimitCoordinator for these before a download
# occupies a concurrency slot.
DOWNLOAD_PREFLIGHT_HOSTS = ("civitai.com", "civitai.red", "civarchive.com")
# Fallback retry_after when neither the vendor nor the coordinator can supply
# a number (matches the Retry-After parsing default in downloader.py).
DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS = 60
# File types that are never the intended download target even when CivitAI # File types that are never the intended download target even when CivitAI
# marks them primary — configs/archives/workflows are auxiliary artifacts. # marks them primary — configs/archives/workflows are auxiliary artifacts.
@@ -192,11 +220,30 @@ class DownloadManager:
) )
except Aria2Error as exc: except Aria2Error as exc:
logger.error("aria2 download failed for %s: %s", download_url, exc) logger.error("aria2 download failed for %s: %s", download_url, exc)
# Best-effort 429 detection: aria2 reports HTTP status errors
# via its error message (e.g. "status=429") without exposing
# the vendor's Retry-After. Surface the structured rate-limit
# error so the queue contract behaves the same as the python
# backend; the coordinator backoff supplies the wait time.
message = str(exc)
if "429" in message or "rate limit" in message.lower():
host = self._url_host(download_url)
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(host, None)
raise DownloadRateLimitError(
f"Download rate limited (429): {message}",
retry_after=None,
host=host,
) from exc
return False, str(exc) return False, str(exc)
download_kwargs: Dict[str, Any] = { download_kwargs: Dict[str, Any] = {
"progress_callback": progress_callback, "progress_callback": progress_callback,
"use_auth": use_auth, "use_auth": use_auth,
# The model download queue contract requires structured 429
# propagation (reason="rate_limited"), not a plain error string.
"raise_on_rate_limit": True,
} }
if pause_control is not None: if pause_control is not None:
@@ -205,6 +252,88 @@ class DownloadManager:
downloader = await get_downloader() downloader = await get_downloader()
return await downloader.download_file(download_url, save_path, **download_kwargs) return await downloader.download_file(download_url, save_path, **download_kwargs)
@staticmethod
def _url_host(url: str) -> str:
"""Extract the normalized hostname from a URL (fallback: ``unknown``)."""
hostname = urlparse(url).hostname
return hostname.lower() if hostname else "unknown"
async def _preflight_rate_limit_error(self) -> Optional[DownloadRateLimitError]:
"""Fail fast when a download target host is in a rate-limit cooldown.
Consults the RateLimitCoordinator's per-host cooldown state for the
hosts a model download may hit. Runs BEFORE the concurrency semaphore
is acquired so queued items never occupy a slot during a 429 episode.
Deliberately non-blocking: the caller is expected to pace itself (the
companion extension auto-pauses on the structured 429 response).
"""
coordinator = await RateLimitCoordinator.get_instance()
worst_host: Optional[str] = None
worst_remaining = 0.0
for host in DOWNLOAD_PREFLIGHT_HOSTS:
remaining = coordinator.remaining_seconds(host)
if remaining > worst_remaining:
worst_host = host
worst_remaining = remaining
if worst_host is None or worst_remaining <= 0:
return None
retry_after = max(1, int(worst_remaining + 0.5))
return DownloadRateLimitError(
f"Download rate limited: '{worst_host}' is in cooldown, "
f"retry after {retry_after}s",
retry_after=worst_remaining,
host=worst_host,
)
async def _handle_rate_limited_download(
self,
task_id: str,
exc: RateLimitError,
) -> Dict[str, Any]:
"""Build the structured rate-limit result for a failed download.
The queue row goes back to ``queued`` (NOT history) so a later retry
simply starts the download again — this is what lets the companion
extension auto-pause the queue during a 429 episode and resume it
after ``retry_after`` seconds.
"""
retry_after = exc.retry_after
host = getattr(exc, "host", None) or exc.provider
if retry_after is None or retry_after <= 0:
# The coordinator clamps/backoffs via register_rate_limit, so its
# remaining cooldown supplies the number when the vendor didn't.
coordinator = await RateLimitCoordinator.get_instance()
remaining = coordinator.remaining_seconds(host)
if remaining > 0:
retry_after = remaining
if retry_after is None or retry_after <= 0:
retry_after = float(DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS)
retry_after_seconds = max(1, int(retry_after + 0.5))
message = str(exc) or (
f"Download rate limited, retry after {retry_after_seconds}s"
)
if task_id in self._active_downloads:
self._active_downloads[task_id]["status"] = "queued"
self._active_downloads[task_id]["error"] = message
self._active_downloads[task_id]["bytes_per_second"] = 0.0
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.update_status(task_id, "queued", error=message)
except Exception:
logger.warning(
"Failed to re-queue rate-limited download %s", task_id, exc_info=True
)
return {
"success": False,
"reason": "rate_limited",
"retry_after": retry_after_seconds,
"error": message,
}
async def _get_lora_scanner(self): async def _get_lora_scanner(self):
"""Get the lora scanner from registry""" """Get the lora scanner from registry"""
return await ServiceRegistry.get_lora_scanner() return await ServiceRegistry.get_lora_scanner()
@@ -227,12 +356,21 @@ class DownloadManager:
return False return False
async def _get_scanner_for_model_type(self, model_type: str): async def _get_scanner_for_model_type(self, model_type: str):
"""Return the scanner responsible for the given model type.""" """Return the scanner responsible for the given model type.
Every supported type resolves explicitly — an unknown type must never
fall through to the lora scanner (an "other" download would silently
dedupe against the lora library).
"""
if model_type == "checkpoint": if model_type == "checkpoint":
return await self._get_checkpoint_scanner() return await self._get_checkpoint_scanner()
if model_type == "embedding": if model_type == "embedding":
return await ServiceRegistry.get_embedding_scanner() return await ServiceRegistry.get_embedding_scanner()
return await self._get_lora_scanner() if model_type == "other":
return await ServiceRegistry.get_other_scanner()
if model_type == "lora":
return await self._get_lora_scanner()
raise ValueError(f'Unknown model type "{model_type}"')
@staticmethod @staticmethod
def _resolve_target_file( def _resolve_target_file(
@@ -538,6 +676,15 @@ class DownloadManager:
original_callback, snapshot, progress_value original_callback, snapshot, progress_value
) )
# Pre-flight cooldown gate: fail fast (without holding a semaphore
# slot) when a target host is still cooling down from an earlier 429.
preflight_error = await self._preflight_rate_limit_error()
if preflight_error is not None:
logger.info(
"Download %s skipped: %s", task_id, preflight_error
)
return await self._handle_rate_limited_download(task_id, preflight_error)
# Acquire semaphore to limit concurrent downloads # Acquire semaphore to limit concurrent downloads
try: try:
async with self._download_semaphore: async with self._download_semaphore:
@@ -642,6 +789,12 @@ class DownloadManager:
logger.info(f"Download cancelled for task {task_id}") logger.info(f"Download cancelled for task {task_id}")
raise raise
except RateLimitError as e:
# 429 (real vendor response or cooldown gate): re-queue
# instead of completing as failed so a later retry just
# starts the download again.
logger.info(f"Download rate limited for task {task_id}: {e}")
return await self._handle_rate_limited_download(task_id, e)
except Exception as e: except Exception as e:
# Handle other errors # Handle other errors
logger.error( logger.error(
@@ -812,7 +965,7 @@ class DownloadManager:
) )
for file_path in target_files: for file_path in target_files:
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
deleted = await self._delete_file_with_retries(metadata_path) deleted = await self._delete_file_with_retries(metadata_path)
if not deleted and os.path.exists(metadata_path): if not deleted and os.path.exists(metadata_path):
logger.error(f"Error deleting metadata file: {metadata_path}") logger.error(f"Error deleting metadata file: {metadata_path}")
@@ -929,6 +1082,42 @@ class DownloadManager:
return download_urls return download_urls
async def _fetch_raw_file_name(
self,
metadata_provider,
version_id: Optional[int],
file_id: Any,
) -> Optional[str]:
"""Best-effort lookup of the raw stored filename via the CivitAI
model-versions/mini endpoint (#1100). Returns None on any failure so
the caller can fall back to the (possibly rewritten) REST name."""
if version_id is None or file_id is None:
return None
fetch = getattr(metadata_provider, "get_version_file_mini", None)
if fetch is None:
return None
try:
mini_info = await fetch(int(version_id), int(file_id))
except (TypeError, ValueError):
return None
except RateLimitError:
raise
except Exception as exc:
logger.debug(
"Mini endpoint lookup failed for version %s file %s: %s",
version_id,
file_id,
exc,
)
return None
if not isinstance(mini_info, dict):
return None
raw_name = mini_info.get("fileName")
if not isinstance(raw_name, str) or not raw_name.strip():
return None
# Defensive: never let a path component slip into the filename.
return os.path.basename(raw_name.strip()) or None
def _build_metadata_for_resume( def _build_metadata_for_resume(
self, self,
*, *,
@@ -941,6 +1130,8 @@ class DownloadManager:
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path) return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "embedding": if model_type == "embedding":
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path) return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "other":
return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path)
return LoraMetadata.from_civitai_info(version_info, file_info, save_path) return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]: def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
@@ -1401,6 +1592,7 @@ class DownloadManager:
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Check lora scanner first # Check lora scanner first
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
@@ -1425,6 +1617,13 @@ class DownloadManager:
"error": "Model version already exists in embedding library", "error": "Model version already exists in embedding library",
} }
# Check other scanner
if await other_scanner.check_model_version_exists(model_version_id):
return {
"success": False,
"error": "Model version already exists in other library",
}
# Use CivArchive provider directly when source is 'civarchive' # Use CivArchive provider directly when source is 'civarchive'
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai # This prioritizes CivArchive metadata (with mirror availability info) over Civitai
if source == "civarchive": if source == "civarchive":
@@ -1457,12 +1656,30 @@ class DownloadManager:
return {"success": False, "error": "Failed to fetch model metadata"} return {"success": False, "error": "Failed to fetch model metadata"}
model_type_from_info = version_info.get("model", {}).get("type", "").lower() model_type_from_info = version_info.get("model", {}).get("type", "").lower()
if model_type_from_info == "checkpoint": # CivitAI ModelType has no model-level diffusion variant: DiT
# models are uploaded as "Checkpoint" or "UNet". Both go through
# the checkpoint branch so the standard diffusion routing chain
# (file type -> baseModel lists -> unknown default) applies.
if model_type_from_info in ("checkpoint", "unet"):
model_type = "checkpoint" model_type = "checkpoint"
elif model_type_from_info in VALID_LORA_TYPES: elif model_type_from_info in VALID_LORA_TYPES:
model_type = "lora" model_type = "lora"
elif model_type_from_info == "textualinversion": elif model_type_from_info == "textualinversion":
model_type = "embedding" model_type = "embedding"
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
if not get_settings_manager().is_other_models_enabled():
return {
"success": False,
"error": (
"Other Models management is disabled. Enable it in "
"Settings > Library before downloading VAE, upscaler, "
"text encoder or CLIP files."
),
# Machine-readable failure code consumed by the companion
# browser extension (docs/other-models-support.md C4).
"reason": "other_models_disabled",
}
model_type = "other"
else: else:
return { return {
"success": False, "success": False,
@@ -1584,27 +1801,16 @@ class DownloadManager:
} }
# Check if this checkpoint should be treated as a diffusion model # Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model", # (shared with the download routing endpoint so the UI location
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS # step and the actual download agree on the target roots).
is_diffusion_model = False is_diffusion_model = is_diffusion_model_download(
if model_type == "checkpoint": model_type,
# Check file types first (more direct signal from CivitAI) file_types=(f.get("type", "") for f in version_info.get("files", [])),
version_files = version_info.get("files", []) base_model=base_model_value,
for f in version_files: unknown_base_model_default=get_settings_manager().get(
f_type = f.get("type", "") "unknown_base_model_routing", "diffusion_model"
if f_type in ("UNet", "Diffusion Model"): ),
is_diffusion_model = True )
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# Existence check after the metadata fetch (#1058): # Existence check after the metadata fetch (#1058):
# - An explicit file selection only blocks when THIS file is # - An explicit file selection only blocks when THIS file is
@@ -1663,6 +1869,13 @@ class DownloadManager:
"success": False, "success": False,
"error": "Model version already exists in embedding library", "error": "Model version already exists in embedding library",
} }
elif model_type == "other":
other_scanner = await ServiceRegistry.get_other_scanner()
if await other_scanner.check_model_version_exists(version_id):
return {
"success": False,
"error": "Model version already exists in other library",
}
# Handle use_default_paths # Handle use_default_paths
if use_default_paths: if use_default_paths:
@@ -1702,6 +1915,60 @@ class DownloadManager:
"error": "Default embedding root path not set in settings", "error": "Default embedding root path not set in settings",
} }
save_dir = default_path save_dir = default_path
elif model_type == "other":
other_sub_type = resolve_other_download_sub_type(
model_type_from_info,
file_types=(
f.get("type", "")
for f in version_info.get("files", [])
if isinstance(f, dict)
),
selected_file_type=(
target_file.get("type") if explicit_file else None
),
)
default_other_roots = (
settings_manager.get("default_other_roots") or {}
)
if other_sub_type and not settings_manager.is_other_sub_type_enabled(
other_sub_type
):
return {
"success": False,
"error": (
f"Other-model sub-type '{other_sub_type}' is "
f"disabled in settings. Please pick a destination "
f"folder explicitly instead of using default paths."
),
"reason": "other_sub_type_disabled",
}
default_path = (
default_other_roots.get(other_sub_type)
if other_sub_type
else None
)
if not isinstance(default_path, str) or not default_path:
if other_sub_type:
detail = (
f"No default root configured for other-model "
f"sub-type '{other_sub_type}'"
)
reason = "other_no_default_root"
else:
detail = (
"Could not determine the other-model sub-type "
"from the model metadata"
)
reason = "other_sub_type_undecidable"
return {
"success": False,
"error": (
f"{detail}. Please pick a destination folder "
f"explicitly instead of using default paths."
),
"reason": reason,
}
save_dir = default_path
# Calculate relative path using template # Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type) relative_path = self._calculate_relative_path(version_info, model_type)
@@ -1725,40 +1992,32 @@ class DownloadManager:
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
# Check if this is a paid or early access model # Check if this is a paid or early access model
paid_access = version_info.get("paidAccess") # CivitAI reports a non-null paidAccess only for an ACTIVE gate, so
if isinstance(paid_access, str): # {"permanent": false, "endsAt": null} (a timed gate whose end is not
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text # recorded yet) still counts as gated here.
try: paid_access = normalize_paid_access(version_info.get("paidAccess"))
parsed = json.loads(paid_access) legacy_ea_ends_at = version_info.get("earlyAccessEndsAt")
paid_access = parsed if isinstance(parsed, dict) else None gate_active = is_gate_active(paid_access) or is_early_access_deadline_active(
except (TypeError, ValueError): legacy_ea_ends_at
paid_access = None )
if not isinstance(paid_access, dict): if gate_active:
paid_access = None permanent_paid = is_permanent_paid(paid_access)
# An empty DTO ({"permanent": false, "endsAt": null}) is not a gate
if paid_access and not paid_access.get("permanent") and not paid_access.get("endsAt"):
paid_access = None
if version_info.get("earlyAccessEndsAt") or paid_access:
permanent_paid = bool(paid_access.get("permanent")) if paid_access else False
if permanent_paid: if permanent_paid:
early_access_msg = ( early_access_msg = (
"This model requires payment. Please ensure you have " "This model requires payment. Please ensure you have "
"purchased access and are logged in to Civitai." "purchased access and are logged in to Civitai."
) )
else: else:
early_access_date = version_info.get("earlyAccessEndsAt") early_access_date = legacy_ea_ends_at
if not early_access_date and paid_access: if not early_access_date and paid_access:
early_access_date = paid_access.get("endsAt") early_access_date = paid_access.get("endsAt")
if not early_access_date: if not early_access_date:
early_access_date = "" early_access_date = ""
# Convert to a readable date if possible # Convert to a readable date if possible
try: try:
from datetime import datetime formatted_date = parse_civitai_timestamp(
early_access_date
date_obj = datetime.fromisoformat( ).strftime("%Y-%m-%d")
early_access_date.replace("Z", "+00:00")
)
formatted_date = date_obj.strftime("%Y-%m-%d")
early_access_msg = ( early_access_msg = (
f"This model requires payment (until {formatted_date}). " f"This model requires payment (until {formatted_date}). "
) )
@@ -1858,6 +2117,24 @@ class DownloadManager:
if not download_urls: if not download_urls:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
# The public REST API rewrites files[].name to
# "{model}_{version}" for non-LoRA model types, so every
# precision variant of a multi-file version shares one name and
# lands on disk with a random short-hash suffix. The mini
# endpoint returns the raw stored filename (#1100). CivArchive
# already serves raw names.
if source != "civarchive":
raw_file_name = await self._fetch_raw_file_name(
metadata_provider, resolved_version_id, file_info.get("id")
)
if raw_file_name and raw_file_name != file_info.get("name"):
logger.info(
"[download] Using raw stored filename '%s' instead of REST name '%s'",
raw_file_name,
file_info.get("name"),
)
file_info = {**file_info, "name": raw_file_name}
# 3. Prepare download # 3. Prepare download
file_name = file_info.get("name", "") file_name = file_info.get("name", "")
if not file_name: if not file_name:
@@ -1880,6 +2157,11 @@ class DownloadManager:
version_info, file_info, save_path version_info, file_info, save_path
) )
logger.info(f"Creating EmbeddingMetadata for {file_name}") logger.info(f"Creating EmbeddingMetadata for {file_name}")
elif model_type == "other":
metadata = OtherModelMetadata.from_civitai_info(
version_info, file_info, save_path
)
logger.info(f"Creating OtherModelMetadata for {file_name}")
else: else:
return { return {
"success": False, "success": False,
@@ -1958,6 +2240,10 @@ class DownloadManager:
return result return result
except RateLimitError:
# Structured 429 propagation must reach _download_with_semaphore
# unmodified so the queue row is re-queued instead of failed.
raise
except Exception as e: except Exception as e:
logger.error(f"Error in download_from_civitai: {e}", exc_info=True) logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
# Check if this might be an early access error # Check if this might be an early access error
@@ -2092,6 +2378,8 @@ class DownloadManager:
scanner = await self._get_checkpoint_scanner() scanner = await self._get_checkpoint_scanner()
elif model_type == "embedding": elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner() scanner = await ServiceRegistry.get_embedding_scanner()
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
except Exception as exc: except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc) logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
@@ -2178,16 +2466,26 @@ class DownloadManager:
if not first_tag: if not first_tag:
first_tag = "no tags" # Default if no tags available first_tag = "no tags" # Default if no tags available
# Tags come straight from CivitAI, so sanitize the value before it
# becomes a path segment and cap its length (#1119).
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
# Format the template with available data # Format the template with available data
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace("{first_tag}", first_tag) formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace("{author}", author) formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace( formatted_path = formatted_path.replace(
"{model_name}", sanitize_folder_name(model_info.get("name", "")) "{model_name}",
sanitize_folder_name(
model_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
),
) )
formatted_path = formatted_path.replace( formatted_path = formatted_path.replace(
"{version_name}", sanitize_folder_name(version_info.get("name", "")) "{version_name}",
sanitize_folder_name(
version_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
),
) )
if model_type == "embedding": if model_type == "embedding":
@@ -2286,7 +2584,7 @@ class DownloadManager:
return {"success": False, "error": save_path} return {"success": False, "error": save_path}
part_path = save_path + ".part" part_path = save_path + ".part"
metadata_path = os.path.splitext(save_path)[0] + ".metadata.json" metadata_path = get_metadata_path(save_path)
pause_control = self._pause_events.get(download_id) if download_id else None pause_control = self._pause_events.get(download_id) if download_id else None
@@ -2303,6 +2601,10 @@ class DownloadManager:
# Download preview image if available # Download preview image if available
images = version_info.get("images", []) images = version_info.get("images", [])
if images: if images:
# Centralized preview mirrors may not exist yet (unlike the
# model's own directory in alongside mode).
os.makedirs(get_preview_dir(save_path), exist_ok=True)
if progress_callback: if progress_callback:
await progress_callback( await progress_callback(
1 1
@@ -2342,7 +2644,10 @@ class DownloadManager:
if media_type == "video": if media_type == "video":
preview_ext = _extension_from_url(preview_url, ".mp4") preview_ext = _extension_from_url(preview_url, ".mp4")
preview_path = os.path.splitext(save_path)[0] + preview_ext preview_path = os.path.join(
get_preview_dir(save_path),
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
)
rewritten_url, rewritten = rewrite_preview_url( rewritten_url, rewritten = rewrite_preview_url(
preview_url, media_type="video" preview_url, media_type="video"
) )
@@ -2369,7 +2674,10 @@ class DownloadManager:
) )
if rewritten and rewritten_url: if rewritten and rewritten_url:
preview_ext = _extension_from_url(preview_url, ".png") preview_ext = _extension_from_url(preview_url, ".png")
preview_path = os.path.splitext(save_path)[0] + preview_ext preview_path = os.path.join(
get_preview_dir(save_path),
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
)
success, _ = await downloader.download_file( success, _ = await downloader.download_file(
rewritten_url, preview_path, use_auth=False rewritten_url, preview_path, use_auth=False
) )
@@ -2396,8 +2704,9 @@ class DownloadManager:
temp_file_handle.write( temp_file_handle.write(
content if isinstance(content, bytes) else content.encode("utf-8") content if isinstance(content, bytes) else content.encode("utf-8")
) )
preview_path = ( preview_path = os.path.join(
os.path.splitext(save_path)[0] + ".webp" get_preview_dir(save_path),
os.path.splitext(os.path.basename(save_path))[0] + ".webp",
) )
optimized_data, _ = ExifUtils.optimize_image( optimized_data, _ = ExifUtils.optimize_image(
@@ -2588,6 +2897,9 @@ class DownloadManager:
elif model_type == "embedding": elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner() scanner = await ServiceRegistry.get_embedding_scanner()
logger.info(f"Updating embedding cache for {actual_file_paths[0]}") logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
logger.info(f"Updating other-model cache for {actual_file_paths[0]}")
adjust_cached_entry = ( adjust_cached_entry = (
getattr(scanner, "adjust_cached_entry", None) getattr(scanner, "adjust_cached_entry", None)
@@ -2595,6 +2907,7 @@ class DownloadManager:
else None else None
) )
downloaded_metadata: List[Dict[str, Any]] = []
for index, entry in enumerate(metadata_entries): for index, entry in enumerate(metadata_entries):
file_path_for_adjust = getattr( file_path_for_adjust = getattr(
entry, "file_path", actual_file_paths[index] entry, "file_path", actual_file_paths[index]
@@ -2623,9 +2936,7 @@ class DownloadManager:
entry = cast(Any, adjusted_entry) entry = cast(Any, adjusted_entry)
metadata_entries[index] = entry metadata_entries[index] = entry
metadata_file_path = ( metadata_file_path = get_metadata_path(entry.file_path)
os.path.splitext(entry.file_path)[0] + ".metadata.json"
)
metadata_files_for_cleanup.append(metadata_file_path) metadata_files_for_cleanup.append(metadata_file_path)
await MetadataManager.save_metadata(entry.file_path, entry) await MetadataManager.save_metadata(entry.file_path, entry)
@@ -2637,6 +2948,15 @@ class DownloadManager:
if scanner is not None: if scanner is not None:
await scanner.add_model_to_cache(metadata_dict, relative_path) await scanner.add_model_to_cache(metadata_dict, relative_path)
downloaded_metadata.append(metadata_dict)
await self._apply_download_filename_template(
scanner=scanner,
model_type=model_type,
downloaded_metadata=downloaded_metadata,
download_id=download_id,
)
if transfer_backend == "aria2" and download_id: if transfer_backend == "aria2" and download_id:
await self._aria2_state_store.remove(download_id) await self._aria2_state_store.remove(download_id)
@@ -2646,6 +2966,10 @@ class DownloadManager:
return {"success": True} return {"success": True}
except RateLimitError:
# Structured 429 propagation must reach _download_with_semaphore
# unmodified so the queue row is re-queued instead of failed.
raise
except Exception as e: except Exception as e:
logger.error(f"Error in _execute_download: {e}", exc_info=True) logger.error(f"Error in _execute_download: {e}", exc_info=True)
cleanup_targets = { cleanup_targets = {
@@ -2676,8 +3000,85 @@ class DownloadManager:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def _apply_download_filename_template(
self,
*,
scanner,
model_type: str,
downloaded_metadata: List[Dict[str, Any]],
download_id: Optional[str],
) -> None:
"""Rename freshly downloaded models according to the filename template.
Best-effort post-download step: any failure (including name conflicts)
is logged and skipped so a successful download is never turned into a
failure by a rename problem.
"""
try:
if scanner is None or not downloaded_metadata:
return
template = get_settings_manager().get_download_filename_template(
model_type
)
if not template:
return
lifecycle_service = ModelLifecycleService(
scanner=scanner,
metadata_manager=MetadataManager,
metadata_loader=load_local_metadata,
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
)
for metadata_dict in downloaded_metadata:
file_path = metadata_dict.get("file_path")
if not isinstance(file_path, str) or not file_path:
continue
new_stem = calculate_filename_for_model(metadata_dict, model_type)
if not new_stem:
continue
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
continue
try:
result = await lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
except ValueError as exc:
logger.warning(
"Keeping original filename for %s: %s", file_path, exc
)
continue
new_file_path = result.get("new_file_path")
if download_id and isinstance(new_file_path, str):
info = self._active_downloads.get(download_id)
if info is None:
continue
if info.get("file_path") == file_path:
info["file_path"] = new_file_path
extracted = info.get("extracted_paths")
if isinstance(extracted, list):
info["extracted_paths"] = [
new_file_path if path == file_path else path
for path in extracted
]
except Exception as exc: # Rename phase must never fail the download
logger.warning(
"Filename template rename failed for %s download: %s",
model_type,
exc,
exc_info=True,
)
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]: def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
if model_type == "checkpoint": if model_type in ("checkpoint", "other"):
return { return {
".ckpt", ".ckpt",
".pt", ".pt",
@@ -2798,7 +3199,11 @@ class DownloadManager:
extension = os.path.splitext(preview_path)[1] or ".webp" extension = os.path.splitext(preview_path)[1] or ".webp"
targets = [ targets = [
os.path.splitext(entry.file_path)[0] + extension for entry in entries os.path.join(
get_preview_dir(entry.file_path),
os.path.splitext(os.path.basename(entry.file_path))[0] + extension,
)
for entry in entries
] ]
if not targets: if not targets:
@@ -2806,10 +3211,12 @@ class DownloadManager:
first_target = targets[0] first_target = targets[0]
if preview_path != first_target: if preview_path != first_target:
os.makedirs(os.path.dirname(first_target), exist_ok=True)
os.replace(preview_path, first_target) os.replace(preview_path, first_target)
source_path = first_target source_path = first_target
for target in targets[1:]: for target in targets[1:]:
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copyfile(source_path, target) shutil.copyfile(source_path, target)
return targets return targets
+123
View File
@@ -0,0 +1,123 @@
"""Shared download routing logic.
Decides whether a download initiated from the checkpoint library should be
routed to the unet/diffusion-model roots instead of the checkpoint roots.
Used by both the download manager (at download time) and the download
routing HTTP endpoint (when the user picks a location in the UI), so the
two can never disagree.
"""
from __future__ import annotations
import logging
from typing import Iterable, Optional
from ..utils.constants import (
CHECKPOINT_BASE_MODELS,
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
DIFFUSION_MODEL_BASE_MODELS,
)
logger = logging.getLogger(__name__)
# File types reported by the CivitAI API that indicate a raw diffusion
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
# Allowed values for the "unknown_base_model_routing" setting / the
# unknown_base_model_default parameter below.
ROUTING_DIFFUSION_MODEL = "diffusion_model"
ROUTING_CHECKPOINT = "checkpoint"
def is_diffusion_model_download(
model_type: str,
file_types: Iterable[str] = (),
base_model: str = "",
unknown_base_model_default: str = ROUTING_DIFFUSION_MODEL,
) -> bool:
"""Return True when a download should be routed to the unet roots.
Only applies to downloads initiated from the checkpoint library.
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
direct signal from CivitAI), (2) baseModel is a known diffusion model,
(3) baseModel is a known full checkpoint -> not diffusion, (4) unknown
or empty baseModel -> the ``unknown_base_model_default`` setting, which
defaults to diffusion because the set of true checkpoint families is
closed while new DiT base models appear all the time.
"""
if model_type != "checkpoint":
return False
for file_type in file_types:
if file_type in DIFFUSION_FILE_TYPES:
logger.info(
"File type '%s' detected, routing checkpoint to unet folder",
file_type,
)
return True
if base_model in DIFFUSION_MODEL_BASE_MODELS:
logger.info(
"baseModel '%s' is a known diffusion model, routing to unet folder",
base_model,
)
return True
if base_model in CHECKPOINT_BASE_MODELS:
return False
is_diffusion = unknown_base_model_default != ROUTING_CHECKPOINT
logger.info(
"baseModel '%s' is unknown, routing to %s folder (unknown_base_model_routing)",
base_model,
"unet" if is_diffusion else "checkpoint",
)
return is_diffusion
def resolve_other_download_sub_type(
civitai_model_type: str,
file_types: Iterable[str] = (),
selected_file_type: Optional[str] = None,
) -> Optional[str]:
"""Resolve the "other"-page sub_type for a download.
Fixed priority (locked design, docs/plans/other-models-page.md §9.2):
1. Explicit user file pick — when the picked file's type maps, it wins
even when model.type maps to something else.
2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE.
3. file.type fallback — only when model.type maps to nothing. Must NOT
override a mapped model.type: checkpoint models routinely bundle
VAE/Text Encoder component files.
4. Still undecidable -> None (caller must ask the user for a folder).
"""
if selected_file_type:
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type)
if mapped:
logger.info(
"Explicit file pick type '%s' routes other download to '%s'",
selected_file_type,
mapped,
)
return mapped
normalized_model_type = (civitai_model_type or "").strip().lower()
mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type)
if mapped:
return mapped
for file_type in file_types:
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type)
if mapped:
logger.info(
"model.type '%s' unmapped; file type '%s' routes other download to '%s'",
civitai_model_type,
file_type,
mapped,
)
return mapped
return None
+33 -1
View File
@@ -31,7 +31,7 @@ from .connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE, OFFLINE_FRIENDLY_MESSAGE,
ConnectivityGuard, ConnectivityGuard,
) )
from .errors import RateLimitError from .errors import DownloadRateLimitError, RateLimitError
from .rate_limit_coordinator import RateLimitCoordinator from .rate_limit_coordinator import RateLimitCoordinator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -434,6 +434,7 @@ class Downloader:
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
allow_resume: bool = True, allow_resume: bool = True,
pause_event: Optional[DownloadStreamControl] = None, pause_event: Optional[DownloadStreamControl] = None,
raise_on_rate_limit: bool = False,
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
Download a file with resumable downloads and retry mechanism Download a file with resumable downloads and retry mechanism
@@ -446,6 +447,11 @@ class Downloader:
custom_headers: Additional headers to include in request custom_headers: Additional headers to include in request
allow_resume: Whether to support resumable downloads allow_resume: Whether to support resumable downloads
pause_event: Optional stream control used to pause/resume and request reconnects pause_event: Optional stream control used to pause/resume and request reconnects
raise_on_rate_limit: When True, a 429 response raises
``DownloadRateLimitError`` instead of returning a plain error
string, so callers (the model download manager) can build the
structured rate-limit result required by the download queue
contract. Defaults to the legacy tuple behavior.
Returns: Returns:
Tuple[bool, str]: (success, save_path or error message) Tuple[bool, str]: (success, save_path or error message)
@@ -610,6 +616,12 @@ class Downloader:
logger.warning( logger.warning(
f"Rate limited (429) for {url}, retry_after={retry_after}" f"Rate limited (429) for {url}, retry_after={retry_after}"
) )
if raise_on_rate_limit:
raise DownloadRateLimitError(
f"Download rate limited (429), retry after {retry_after}s",
retry_after=retry_after,
host=self._guard_destination(url),
)
return False, f"Download rate limited (429), retry after {retry_after}s" return False, f"Download rate limited (429), retry after {retry_after}s"
else: else:
logger.error( logger.error(
@@ -902,6 +914,11 @@ class Downloader:
f"Network error after {self.max_retries + 1} attempts: {str(e)}", f"Network error after {self.max_retries + 1} attempts: {str(e)}",
) )
except DownloadRateLimitError:
# 429s are never retried in-band; the structured error must
# reach the caller (download manager) unmodified.
raise
except Exception as e: except Exception as e:
logger.error(f"Unexpected download error: {e}") logger.error(f"Unexpected download error: {e}")
return False, str(e) return False, str(e)
@@ -931,6 +948,7 @@ class Downloader:
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
return_headers: bool = False, return_headers: bool = False,
raise_on_rate_limit: bool = False,
) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]: ) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]:
""" """
Download a file to memory (for small files like preview images) Download a file to memory (for small files like preview images)
@@ -940,6 +958,10 @@ class Downloader:
use_auth: Whether to include authentication headers use_auth: Whether to include authentication headers
custom_headers: Additional headers to include in request custom_headers: Additional headers to include in request
return_headers: Whether to return response headers along with content return_headers: Whether to return response headers along with content
raise_on_rate_limit: When True, a 429 response raises
``DownloadRateLimitError`` instead of returning a plain error
string (see ``download_file``). Defaults to the legacy tuple
behavior.
Returns: Returns:
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested) Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
@@ -1002,11 +1024,21 @@ class Downloader:
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss", "Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
url, retry_after, url, retry_after,
) )
if raise_on_rate_limit:
raise DownloadRateLimitError(
f"Rate limited (429), retry after {retry_after}s",
retry_after=retry_after,
host=destination,
)
return False, f"Rate limited (429), retry after {retry_after}s", None return False, f"Rate limited (429), retry after {retry_after}s", None
else: else:
error_msg = f"Download failed with status {response.status}" error_msg = f"Download failed with status {response.status}"
return False, error_msg, None return False, error_msg, None
except DownloadRateLimitError:
# Structured rate-limit errors must reach the caller unmodified.
raise
except Exception as e: except Exception as e:
if guard.is_network_unreachable_error(e): if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination) guard.register_network_failure(e, destination)
+4
View File
@@ -67,6 +67,10 @@ class EmbeddingService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True), "civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data), "auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"), "version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""), "hf_url": model_data.get("hf_url", ""),
} }
+19
View File
@@ -20,6 +20,25 @@ class RateLimitError(RuntimeError):
self.provider = provider self.provider = provider
class DownloadRateLimitError(RateLimitError):
"""Raised when a file download is rejected with HTTP 429.
Carries the vendor's ``Retry-After`` hint (when present) and the target
host so the download manager can build the structured rate-limit result
the download queue contract expects.
"""
def __init__(
self,
message: str,
*,
retry_after: Optional[float] = None,
host: Optional[str] = None,
) -> None:
super().__init__(message, retry_after=retry_after)
self.host = host
class ResourceNotFoundError(RuntimeError): class ResourceNotFoundError(RuntimeError):
"""Raised when a remote resource is permanently missing.""" """Raised when a remote resource is permanently missing."""
+150 -83
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import aiohttp import aiohttp
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
# ``{provider_id: {model_id: max_output_tokens}}``. # ``{provider_id: {model_id: max_output_tokens}}``.
_model_output_limits: Dict[str, Dict[str, int]] = {} _model_output_limits: Dict[str, Dict[str, int]] = {}
# Monotonic timestamp of the last failed catalog fetch (None = no failure
# yet). Failed fetches are negatively cached: further calls return the
# empty fallback without hitting the network until the cooldown elapses,
# so users on broken networks don't stall on every settings-modal open.
_catalog_last_failure: Optional[float] = None
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
# Serializes catalog fetches so concurrent callers don't duplicate requests.
_catalog_lock = asyncio.Lock()
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30) _CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cloudflare serves brotli when the client advertises it, and brotli is a
# required dependency here — a corrupted br stream can crash the native
# decoder with a Windows access violation (issue #1099). Request gzip
# instead; zlib decompression is not affected and corrupt gzip data only
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
# exception handlers below already catch.
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
async def _load_model_catalog() -> Dict[str, List[str]]: async def _load_model_catalog() -> Dict[str, List[str]]:
"""Fetch and parse the model catalog. """Fetch and parse the model catalog.
@@ -46,61 +65,85 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
value has a ``models`` sub-dict keyed by model ID. The result is cached value has a ``models`` sub-dict keyed by model ID. The result is cached
in memory after the first successful fetch. in memory after the first successful fetch.
Subsequent calls return the cached data immediately. Subsequent calls return the cached data immediately.
Failed fetches are negatively cached: further calls return an empty
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
has elapsed, so a broken network does not stall every settings-modal
open. Concurrent callers are serialized behind :data:`_catalog_lock`
so only one request is ever in flight.
""" """
global _catalog_cache, _model_output_limits global _catalog_cache, _model_output_limits, _catalog_last_failure
if _catalog_cache is not None: if _catalog_cache is not None:
return _catalog_cache return _catalog_cache
try: async with _catalog_lock:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session: # Re-check under the lock: another caller may have fetched (or
async with session.get(_MODEL_CATALOG_URL) as resp: # failed) while we were waiting.
if resp.status != 200: if _catalog_cache is not None:
logger.warning("Model catalog returned HTTP %s", resp.status) return _catalog_cache
return _catalog_cache or {} if (
data = await resp.json() _catalog_last_failure is not None
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc: and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
logger.warning("Failed to fetch model catalog: %s", exc) ):
return _catalog_cache or {} logger.debug(
"Skipping model catalog fetch: last attempt failed %.0fs ago",
time.monotonic() - _catalog_last_failure,
)
return {}
if not isinstance(data, dict): try:
logger.warning("Model catalog is not a dict, got %s", type(data).__name__) async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
return _catalog_cache or {} async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
_catalog_last_failure = time.monotonic()
return {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {} if not isinstance(data, dict):
output_limits: Dict[str, Dict[str, int]] = {} logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
for provider_id, provider_info in data.items(): _catalog_last_failure = time.monotonic()
if not isinstance(provider_info, dict): return {}
continue
models_dict = provider_info.get("models") result: Dict[str, List[str]] = {}
if not isinstance(models_dict, dict): output_limits: Dict[str, Dict[str, int]] = {}
continue for provider_id, provider_info in data.items():
model_ids: List[str] = [] if not isinstance(provider_info, dict):
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
continue continue
model_ids.append(mid) models_dict = provider_info.get("models")
if isinstance(model_info, dict): if not isinstance(models_dict, dict):
limit = model_info.get("limit") continue
if isinstance(limit, dict): model_ids: List[str] = []
output = limit.get("output") provider_limits: Dict[str, int] = {}
if isinstance(output, (int, float)) and output > 0: for mid, model_info in models_dict.items():
provider_limits[mid] = int(output) if not isinstance(mid, str):
if model_ids: continue
result[provider_id] = model_ids model_ids.append(mid)
if provider_limits: if isinstance(model_info, dict):
output_limits[provider_id] = provider_limits limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids:
result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
_catalog_cache = result _catalog_cache = result
_model_output_limits = output_limits _model_output_limits = output_limits
logger.debug( logger.debug(
"Loaded model catalog: %d providers, %d total models " "Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)", "(%d providers have output limits)",
len(result), len(result),
sum(len(m) for m in result.values()), sum(len(m) for m in result.values()),
len(output_limits), len(output_limits),
) )
return result return result
def _get_model_max_output(provider: str, model: str) -> Optional[int]: def _get_model_max_output(provider: str, model: str) -> Optional[int]:
@@ -126,12 +169,12 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
url = f"{api_base.rstrip('/')}/models" url = f"{api_base.rstrip('/')}/models"
try: try:
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session: async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
async with session.get(url) as resp: async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200: if resp.status != 200:
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base) logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
return [] return []
data = await resp.json() data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc: except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.debug("Ollama not reachable at %s: %s", api_base, exc) logger.debug("Ollama not reachable at %s: %s", api_base, exc)
return [] return []
@@ -224,6 +267,16 @@ _PROVIDER_DEFAULTS: Dict[str, str] = {
# Request timeout for LLM calls (seconds) # Request timeout for LLM calls (seconds)
_LLM_TIMEOUT = aiohttp.ClientTimeout(total=120) _LLM_TIMEOUT = aiohttp.ClientTimeout(total=120)
# Providers that do NOT implement ``response_format: {"type": "json_schema"}``
# and reject it with HTTP 400. For these the weaker, widely supported
# ``json_object`` mode is used instead (the prompt already specifies the
# expected JSON shape, and ``_try_salvage_json`` repairs imperfect output).
# DeepSeek answers a json_schema request with
# ``{"error":{"message":"This response_format type is unavailable now"}}``.
# LM Studio and some other local OpenAI-compatible servers reject
# ``json_object`` but accept ``json_schema``, so they are not listed here.
_JSON_OBJECT_ONLY_PROVIDERS = frozenset({"deepseek"})
class LLMService: class LLMService:
"""Centralized LLM API client. """Centralized LLM API client.
@@ -571,47 +624,61 @@ class LLMService:
if effective_max is None: if effective_max is None:
effective_max = 4096 effective_max = 4096
# Use json_schema (not json_object) for broader provider compatibility: # Structured-output format. ``json_schema`` is preferred because LM
# LM Studio and some other OpenAI-compatible servers reject # Studio and other local OpenAI-compatible servers reject
# json_object but accept json_schema. {"type": "object"} is # ``json_object`` but accept ``json_schema``; ``{"type": "object"}``
# functionally equivalent — it accepts any JSON object without # accepts any JSON object without constraining specific fields, so the
# constraining specific fields. # two modes are functionally equivalent here. Providers known to
response_format = { # reject json_schema (see _JSON_OBJECT_ONLY_PROVIDERS) get
# ``json_object`` instead.
schema_format: Dict[str, Any] = {
"type": "json_schema", "type": "json_schema",
"json_schema": { "json_schema": {
"name": "metadata", "name": "metadata",
"schema": {"type": "object"}, "schema": {"type": "object"},
}, },
} }
json_object_format: Dict[str, Any] = {"type": "json_object"}
try: if self._get_config()["provider"] in _JSON_OBJECT_ONLY_PROVIDERS:
result = await self.chat_completion( format_chain: List[Optional[Dict[str, Any]]] = [
messages=messages, json_object_format,
model=model, None,
temperature=temperature, ]
response_format=response_format, else:
max_tokens=effective_max, format_chain = [schema_format, json_object_format, None]
)
except LLMResponseError as e: result: Optional[Dict[str, Any]] = None
# Only fall back when the provider rejects the response_format for index, fmt in enumerate(format_chain):
# type value (e.g. "'response_format.type' must be..."). Avoid try:
# catching unrelated 400 errors whose body happens to mention result = await self.chat_completion(
# "response_format" (e.g. "model does not support messages=messages,
# response_format restrictions on this endpoint"). model=model,
if "'response_format.type'" not in str(e).lower(): temperature=temperature,
raise response_format=fmt,
logger.info( max_tokens=effective_max,
"Provider rejected response_format, retrying without it. " )
"Falling back to prompt-only JSON mode. Error: %s", break
e, except LLMResponseError as e:
) message = str(e).lower()
result = await self.chat_completion( if index + 1 >= len(format_chain):
messages=messages, raise
model=model, # Only downgrade when the failure is about ``response_format``.
temperature=temperature, # Everything else (auth, unknown model, rate limits) must
response_format=None, # surface unchanged. Matching on the bare parameter name also
max_tokens=effective_max, # covers variants such as DeepSeek's "This response_format
) # type is unavailable now" without swallowing unrelated 400s.
if "response_format" not in message:
raise
logger.info(
"Provider rejected response_format=%s, retrying with %s. "
"Error: %s",
(fmt or {}).get("type", "none"),
(format_chain[index + 1] or {}).get("type", "none"),
e,
)
assert result is not None # non-empty chain always sets or raises
content = result.get("content", "") or "" content = result.get("content", "") or ""
if not content: if not content:
+15 -5
View File
@@ -79,6 +79,10 @@ class LoraService(BaseModelService):
), ),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data), "auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"), "version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""), "hf_url": model_data.get("hf_url", ""),
} }
@@ -712,12 +716,18 @@ class LoraService(BaseModelService):
), ),
) )
# Return minimal data needed for cycling # Return minimal data needed for cycling. usage_tips is only included
return [ # when non-empty so widget consumers (recommended strength range cues)
{ # can build their lookup without inflating the payload.
result = []
for lora in available_loras:
entry = {
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"], "file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]), "model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""), "folder": lora.get("folder", ""),
} }
for lora in available_loras usage_tips = lora.get("usage_tips")
] if usage_tips:
entry["usage_tips"] = usage_tips
result.append(entry)
return result
+29 -3
View File
@@ -10,6 +10,7 @@ from .model_metadata_provider import (
SQLiteModelMetadataProvider, SQLiteModelMetadataProvider,
CivitaiModelMetadataProvider, CivitaiModelMetadataProvider,
CivArchiveModelMetadataProvider, CivArchiveModelMetadataProvider,
OpenModelDBModelMetadataProvider,
FallbackMetadataProvider, FallbackMetadataProvider,
RateLimitRetryingProvider, RateLimitRetryingProvider,
) )
@@ -22,12 +23,18 @@ logger = logging.getLogger(__name__)
_PROVIDER_DISPLAY_NAMES = { _PROVIDER_DISPLAY_NAMES = {
"civitai_api": "CivitAI", "civitai_api": "CivitAI",
"civarchive_api": "CivArchive", "civarchive_api": "CivArchive",
"openmodeldb_api": "OpenModelDB",
"sqlite": "Archive DB", "sqlite": "Archive DB",
} }
# Preset fallback chains. civitai_api is always first (richest metadata).
# openmodeldb_api sits right after it: its lookups are local index hits over a
# cached bulk dump (no rate-limit budget spent), and it covers upscalers that
# CivArchive only has when they once existed on CivitAI. Providers that are not
# registered (disabled/unavailable) are skipped, so presets degrade gracefully.
_PRESET_PROVIDER_ORDERS = { _PRESET_PROVIDER_ORDERS = {
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"], "civitai_archive_sqlite": ["civitai_api", "openmodeldb_api", "civarchive_api", "sqlite"],
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"], "civitai_sqlite_archive": ["civitai_api", "openmodeldb_api", "sqlite", "civarchive_api"],
} }
async def initialize_metadata_providers(): async def initialize_metadata_providers():
@@ -42,6 +49,7 @@ async def initialize_metadata_providers():
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False) enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True) enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite') provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
providers = [] providers = []
@@ -92,6 +100,22 @@ async def initialize_metadata_providers():
else: else:
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'") logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
# Register the OpenModelDB provider when enabled. It only covers upscaler
# models (hash-matched against its catalogue dump), so it complements
# rather than replaces the CivitAI-family providers; disabling it avoids
# the one-time bulk dump download entirely.
if enable_openmodeldb_api:
try:
openmodeldb_client = await ServiceRegistry.get_openmodeldb_client()
openmodeldb_provider = OpenModelDBModelMetadataProvider(openmodeldb_client)
provider_manager.register_provider('openmodeldb_api', openmodeldb_provider)
providers.append(('openmodeldb_api', openmodeldb_provider))
logger.debug("OpenModelDB metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize OpenModelDB metadata provider: {e}")
else:
logger.debug("OpenModelDB metadata provider disabled by setting 'enable_openmodeldb_api'")
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS). # Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
# civitai_api is always first (better metadata); the remaining providers # civitai_api is always first (better metadata); the remaining providers
# are arranged by the configured preset. Providers that are not # are arranged by the configured preset. Providers that are not
@@ -135,6 +159,7 @@ async def update_metadata_providers():
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False) enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True) enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite') provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
# Reinitialize all providers with new settings # Reinitialize all providers with new settings
@@ -153,9 +178,10 @@ async def update_metadata_providers():
) )
logger.info( logger.info(
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s", "Updated metadata providers: archive_db=%s, civarchive_api=%s, openmodeldb_api=%s, chain=%s",
enable_archive_db, enable_archive_db,
enable_civarchive_api, enable_civarchive_api,
enable_openmodeldb_api,
chain, chain,
) )
return provider_manager return provider_manager
+121 -19
View File
@@ -12,12 +12,73 @@ from ..services.settings_manager import SettingsManager
from ..utils.civitai_utils import resolve_license_payload from ..utils.civitai_utils import resolve_license_payload
from ..utils.model_utils import determine_base_model from ..utils.model_utils import determine_base_model
from ..utils.models import autov3_from_civitai_files from ..utils.models import autov3_from_civitai_files
from ..utils.sidecar_paths import get_metadata_path
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
from .errors import RateLimitError from .errors import RateLimitError
from .model_metadata_provider import _LOCAL_PROVIDER_LABELS
from .model_sources import get_source_platform, has_external_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Providers restricted to specific model sub_types, keyed by their
# registration label. Providers not listed apply to every model type.
# OpenModelDB only indexes upscalers, so it is never consulted for other model
# types — that keeps its one-time bulk-catalogue download from being paid by
# users who manage no upscalers at all.
_PROVIDER_SUB_TYPE_RESTRICTIONS: Dict[str, frozenset] = {
"openmodeldb_api": frozenset({"upscaler"}),
}
#: External-source platforms that have their own hash-lookup metadata
#: provider. A model downloaded from one of these is refreshed against the
#: source's own catalogue first (upgrading the download-time card to the full
#: payload) before CivitAI is consulted at all.
_EXTERNAL_SOURCE_METADATA_PROVIDERS: Dict[str, str] = {
"openmodeldb": "openmodeldb_api",
}
def _restricted_providers_for_sub_type(sub_type: Optional[str]) -> list:
"""Return the restricted provider labels that apply to ``sub_type``."""
return [
name
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
if sub_type in allowed
]
def _inapplicable_providers_for_sub_type(sub_type: Optional[str]) -> set:
"""Return the restricted provider labels that do NOT apply to ``sub_type``."""
return {
name
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
if sub_type not in allowed
}
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
"""Concatenate two word lists, dropping duplicates without reordering.
Trigger word order is meaningful: the sequence stored in
``civitai.trainedWords`` is the order used when building prompts, and users
can reorder it in the UI. A plain ``set`` union used to shuffle that order on
every metadata refresh, so existing words are kept first (in their saved
order) and newly discovered ones are appended.
"""
merged: list[str] = []
seen: set[str] = set()
for word in list(existing) + list(new):
if word in seen:
continue
seen.add(word)
merged.append(word)
return merged
class MetadataProviderProtocol(Protocol): class MetadataProviderProtocol(Protocol):
"""Subset of metadata provider interface consumed by the sync service.""" """Subset of metadata provider interface consumed by the sync service."""
@@ -114,9 +175,10 @@ class MetadataSyncService:
) )
if "trainedWords" in existing_civitai: if "trainedWords" in existing_civitai:
existing_trained = existing_civitai.get("trainedWords", []) existing_trained = existing_civitai.get("trainedWords", []) or []
new_trained = civitai_metadata.get("trainedWords", []) new_trained = civitai_metadata.get("trainedWords", []) or []
merged_trained = list(set(existing_trained + new_trained)) # Order preserving merge: the saved order drives prompt order.
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
merged_civitai["trainedWords"] = merged_trained merged_civitai["trainedWords"] = merged_trained
local_metadata["civitai"] = merged_civitai local_metadata["civitai"] = merged_civitai
@@ -192,7 +254,7 @@ class MetadataSyncService:
logger.error(error) logger.error(error)
return False, error return False, error
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
enable_archive = self._settings.get("enable_metadata_archive_db", False) enable_archive = self._settings.get("enable_metadata_archive_db", False)
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source") previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
@@ -201,6 +263,18 @@ class MetadataSyncService:
sqlite_attempted = False sqlite_attempted = False
if model_data.get("civitai_deleted") is True: if model_data.get("civitai_deleted") is True:
# Sub_type-restricted providers (e.g. OpenModelDB for
# upscalers) stay reachable for deleted models: their
# catalogues grow independently of CivitAI, so a model deleted
# from CivitAI may still gain metadata there later.
for restricted_name in _restricted_providers_for_sub_type(
model_data.get("sub_type")
):
try:
provider_attempts.append((restricted_name, await self._get_provider(restricted_name)))
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug("Unable to resolve %s provider: %s", restricted_name, exc)
if previous_source in (None, "civarchive"): if previous_source in (None, "civarchive"):
try: try:
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api"))) provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
@@ -222,21 +296,47 @@ class MetadataSyncService:
error_msg = "CivitAI model is deleted and no archive provider is available" error_msg = "CivitAI model is deleted and no archive provider is available"
return False, error_msg return False, error_msg
else: else:
is_hf_source = bool(model_data.get("hf_url")) is_hf_source = has_external_source(model_data)
if is_hf_source: if is_hf_source:
# HF-sourced model: only check CivitAI API directly. # External-source model (Hugging Face / ModelScope /
# CivArchive is almost guaranteed to have no record, and # TensorArt / OpenModelDB): a source with its own
# hitting it wastes rate-limit budget. # hash-lookup provider (OpenModelDB) is consulted first,
# then CivitAI API directly. CivArchive is almost
# guaranteed to have no record, and hitting it wastes
# rate-limit budget.
# Use a distinct provider name ("civitai_api" not None) so # Use a distinct provider name ("civitai_api" not None) so
# downstream code does NOT interpret a "Model not found" # downstream code does NOT interpret a "Model not found"
# response as civitai_api_not_found — which would mark the # response as civitai_api_not_found — which would mark the
# model civitai_deleted=True when it was never on CivitAI. # model civitai_deleted=True when it was never on CivitAI.
try: source_provider = _EXTERNAL_SOURCE_METADATA_PROVIDERS.get(
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api"))) get_source_platform(model_data)
except Exception as exc: # pragma: no cover - provider resolution fault )
logger.debug("Unable to resolve civitai_api provider: %s", exc) provider_names = (
[source_provider, "civitai_api"]
if source_provider
else ["civitai_api"]
)
for provider_name in provider_names:
try:
provider_attempts.append(
(provider_name, await self._get_provider(provider_name))
)
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug(
"Unable to resolve %s provider: %s", provider_name, exc
)
if not provider_attempts: if not provider_attempts:
provider_attempts.append((None, await self._get_default_provider())) default_provider = await self._get_default_provider()
# Drop sub_type-restricted providers that cannot apply to
# this model (e.g. OpenModelDB only indexes upscalers), so
# their cold-start cost is never paid pointlessly.
inapplicable = _inapplicable_providers_for_sub_type(
model_data.get("sub_type")
)
excluding = getattr(default_provider, "excluding", None)
if inapplicable and callable(excluding):
default_provider = excluding(inapplicable)
provider_attempts.append((None, default_provider))
civitai_metadata: Optional[Dict[str, Any]] = None civitai_metadata: Optional[Dict[str, Any]] = None
metadata_provider: Optional[MetadataProviderProtocol] = None metadata_provider: Optional[MetadataProviderProtocol] = None
@@ -247,10 +347,11 @@ class MetadataSyncService:
skip_network_providers = False skip_network_providers = False
for provider_name, provider in provider_attempts: for provider_name, provider in provider_attempts:
if skip_network_providers and provider_name != "sqlite": if skip_network_providers and provider_name not in _LOCAL_PROVIDER_LABELS:
# A network provider was already rate-limited; failing # A network provider was already rate-limited; failing
# over to another network provider just spreads the flood # over to another network provider just spreads the flood
# (#1085). The local sqlite archive stays as last resort. # (#1085). Local lookups (sqlite archive, the cached
# OpenModelDB index) stay available as a last resort.
continue continue
try: try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256) civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
@@ -360,6 +461,7 @@ class MetadataSyncService:
readable_source = { readable_source = {
"civitai_api": "CivitAI API", "civitai_api": "CivitAI API",
"civarchive": "CivArchive API", "civarchive": "CivArchive API",
"openmodeldb": "OpenModelDB",
"archive_db": "Archive Database", "archive_db": "Archive Database",
}.get(source, source) }.get(source, source)
@@ -460,7 +562,7 @@ class MetadataSyncService:
+ (f" with version: {model_version_id}" if model_version_id else "") + (f" with version: {model_version_id}" if model_version_id else "")
) )
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
await self.update_model_metadata( await self.update_model_metadata(
metadata_path, metadata_path,
metadata, metadata,
@@ -480,7 +582,7 @@ class MetadataSyncService:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Apply metadata updates and persist to disk and cache.""" """Apply metadata updates and persist to disk and cache."""
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
metadata = await metadata_loader(metadata_path) metadata = await metadata_loader(metadata_path)
for key, value in updates.items(): for key, value in updates.items():
@@ -529,7 +631,7 @@ class MetadataSyncService:
} }
expected_hash: Optional[str] = None expected_hash: Optional[str] = None
first_metadata_path = os.path.splitext(file_paths[0])[0] + ".metadata.json" first_metadata_path = get_metadata_path(file_paths[0])
first_metadata = await metadata_loader(first_metadata_path) first_metadata = await metadata_loader(first_metadata_path)
if first_metadata and "sha256" in first_metadata: if first_metadata and "sha256" in first_metadata:
expected_hash = first_metadata["sha256"].lower() expected_hash = first_metadata["sha256"].lower()
@@ -540,7 +642,7 @@ class MetadataSyncService:
try: try:
actual_hash = await hash_calculator(path) actual_hash = await hash_calculator(path)
metadata_path = os.path.splitext(path)[0] + ".metadata.json" metadata_path = get_metadata_path(path)
metadata = await metadata_loader(metadata_path) metadata = await metadata_loader(metadata_path)
stored_hash = metadata.get("sha256", "").lower() stored_hash = metadata.get("sha256", "").lower()
+5
View File
@@ -33,6 +33,11 @@ class ModelCache:
raw_data: List[Dict[str, Any]] raw_data: List[Dict[str, Any]]
folders: List[str] folders: List[str]
# Every directory under the model roots (including empty ones), as
# recorded by the last scan/hydration. ``None`` means "never recorded"
# (e.g. a persisted snapshot predating this field) and triggers a
# background filesystem backfill in the scanner.
all_folders: Optional[List[str]] = None
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict) version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict) model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
# Multi-valued companion to version_index: every local file entry of a # Multi-valued companion to version_index: every local file entry of a
+547 -5
View File
@@ -2,17 +2,57 @@ import asyncio
import fnmatch import fnmatch
import os import os
import logging import logging
import shutil
from typing import Any, Dict, List, Optional, Sequence, Set from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``.
Absolute paths (POSIX, drive-letter or UNC) and paths that climb out of the
library root are refused: both mean the caller is confused about which space
it is working in, and guessing would be worse than an error. Shared by the
folder operations and the scoped scan endpoint so both reject the same input.
"""
raw = str(folder or "").strip()
if not raw:
raise ValueError("Folder path is required")
normalized = raw.replace("\\", "/")
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized)
if (
normalized == ".."
or normalized.startswith("../")
or normalized.startswith(".." + os.sep)
):
raise ValueError("Folder path must stay inside the library root")
return normalized
def _normalize_match_path(path: Any) -> str:
"""Normalize a path for set membership tests.
Business paths only — symlinks are never resolved here, matching how the
scanner stores ``excluded_models``. The forward-slash form keeps Windows
comparisons working with the scanner's normalized entries.
"""
return os.path.normpath(os.path.abspath(str(path))).replace(os.sep, "/")
class ProgressCallback(ABC): class ProgressCallback(ABC):
"""Abstract callback interface for progress reporting""" """Abstract callback interface for progress reporting"""
@@ -41,10 +81,22 @@ class AutoOrganizeResult:
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary""" """Convert result to dictionary"""
if self.operation_type == 'filename_template':
message = (
f'Filename template applied: {self.success_count} renamed, '
f'{self.skipped_count} skipped, {self.failure_count} failed '
f'out of {self.total} total'
)
else:
message = (
f'Auto-organize {self.operation_type} completed: '
f'{self.success_count} moved, {self.skipped_count} skipped, '
f'{self.failure_count} failed out of {self.total} total'
)
result: Dict[str, Any] = { result: Dict[str, Any] = {
'success': self.status != 'error', 'success': self.status != 'error',
'status': self.status, 'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total', 'message': message,
'summary': { 'summary': {
'total': self.total, 'total': self.total,
'success': self.success_count, 'success': self.success_count,
@@ -473,17 +525,507 @@ class ModelFileService:
class ModelMoveService: class ModelMoveService:
"""Service for handling individual model moves""" """Service for handling individual model moves"""
def __init__(self, scanner, model_type: str): def __init__(self, scanner, model_type: str):
"""Initialize the service """Initialize the service
Args: Args:
scanner: Model scanner instance scanner: Model scanner instance
model_type: Type of model (e.g., 'lora', 'checkpoint') model_type: Type of model (e.g., 'lora', 'checkpoint')
""" """
self.scanner = scanner self.scanner = scanner
self.model_type = model_type self.model_type = model_type
async def create_folder(self, folder_path: str) -> Dict[str, Any]:
"""Create a directory inside the model library roots.
Args:
folder_path: Absolute path of the directory to create (business
path — symlinks are not resolved)
Returns:
Dictionary with success flag, the created path and the
library-relative folder name used by folder trees.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
already_exists = os.path.isdir(absolute_path)
os.makedirs(absolute_path, exist_ok=True)
relative_folder = self._calculate_relative_folder(absolute_path)
if relative_folder:
await self.scanner.add_known_folder(relative_folder)
return {
"success": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
"created": not already_exists,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error creating folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _calculate_relative_folder(self, absolute_path: str) -> str:
"""Return the library-relative folder for an absolute directory path."""
normalized = os.path.abspath(absolute_path)
for root in self.scanner.get_model_roots():
abs_root = os.path.abspath(root)
try:
rel = os.path.relpath(normalized, abs_root)
except ValueError:
continue
if rel == ".":
return ""
if not rel.startswith(".."):
return rel.replace(os.sep, "/")
return ""
def resolve_folder(self, folder: str) -> Dict[str, Any]:
"""Map a library-relative folder onto the directories it stands for.
The unified folder tree merges every model root into a single
relative-path namespace, so a tree node does not carry the root it came
from: the same relative folder can exist under several roots, or under
one that is not the default root. Folder operations take an absolute
business path, so a caller holding only the rendered node must ask which
directories it maps to instead of assuming a root — assuming one is how
deleting a folder that sits right there fails with "Folder no longer
exists", or, when a same-named folder exists in another root, silently
acts on that other directory.
Args:
folder: Library-relative folder path (``a/b``; backslashes are
accepted). Absolute paths are rejected — a caller that already
holds an absolute business path does not need this.
Returns:
Dictionary with the success flag, the normalized relative folder and
``candidates``: one entry per model root that holds the directory on
disk (``folder_path``/``root``/``is_symlink``), in scanner root
order. An empty list means the directory exists under no root.
"""
try:
relative = self._normalize_relative_folder(folder)
except ValueError as exc:
return {"success": False, "error": str(exc)}
candidates: List[Dict[str, Any]] = []
for root in self.scanner.get_model_roots():
abs_root = os.path.abspath(root)
candidate = os.path.abspath(os.path.join(abs_root, relative))
# normpath already collapsed any "." / ".." segment; the
# containment check is belt-and-braces, not traversal defence.
if candidate != abs_root and not candidate.startswith(abs_root + os.sep):
continue
if not os.path.isdir(candidate):
continue
candidates.append(
{
"folder_path": candidate.replace(os.sep, "/"),
"root": abs_root.replace(os.sep, "/"),
"is_symlink": os.path.islink(candidate),
}
)
return {
"success": True,
"folder": relative.replace(os.sep, "/"),
"candidates": candidates,
}
@staticmethod
def _normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``."""
return normalize_relative_folder(folder)
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
Only directories whose subtree holds no model weight files can be
removed: a folder-level cascade would bypass the per-model lifecycle
bookkeeping (metadata sidecars, previews, cache entries, pending-delete
staging and recipe references), so it is deliberately refused. Leftover
non-model files (stray previews, sidecars, ``.bak`` files) are reported
in the manifest before they are removed.
Args:
folder_path: Absolute path of the directory to remove (business
path — symlinks are not resolved)
dry_run: When true, only report what would be removed
Returns:
Dictionary with the success flag plus a removal manifest
(``model_count``/``excluded_model_count``/``file_count``/
``dir_count``/``symlink_count``/``total_bytes``/``restorable``)
on success.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
# shutil.rmtree refuses symlinked roots, and silently deleting
# the link (leaving the real directory behind) is a separate
# decision we do not make here.
return {
"success": False,
"error": "Symlinked folders cannot be deleted",
}
if not os.path.isdir(absolute_path):
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be deleted",
}
manifest = self._collect_folder_manifest(absolute_path)
if manifest["pending_delete_job"]:
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
"manifest": manifest,
}
if manifest["model_count"] > 0:
model_count = manifest["model_count"]
excluded_count = manifest["excluded_model_count"]
# Excluded models are hidden from the library lists but are
# still real weight files, so they block the cascade just like
# any other model. Naming them is what makes the refusal
# actionable: the folder looks empty in the sidebar precisely
# because everything in it is excluded.
if excluded_count == model_count:
error = (
f"Folder still contains {model_count} model file(s), "
"all excluded from the library; un-exclude or delete "
"them first"
)
elif excluded_count:
error = (
f"Folder still contains {model_count} model file(s), "
f"{excluded_count} of them excluded from the library; "
"delete or move them first"
)
else:
error = (
f"Folder still contains {model_count} model "
"file(s); delete or move them first"
)
return {
"success": False,
"code": "not_empty",
"error": error,
"manifest": manifest,
}
relative_folder = self._calculate_relative_folder(absolute_path)
if dry_run:
return {
"success": True,
"dry_run": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
shutil.rmtree(absolute_path)
# Centralized mode: prune the folder's mirror subtree when it no
# longer holds any sidecar files (per-model deletes already
# removed their sidecars, so only empty directories are expected;
# a non-empty mirror keeps its orphan sidecars).
if is_centralized():
mirror_dir = resolve_centralized_dir_for_dir(absolute_path)
if mirror_dir and os.path.isdir(mirror_dir):
for root, _dirs, files in os.walk(mirror_dir, topdown=False):
if files:
continue
try:
os.rmdir(root)
except OSError: # pragma: no cover - best-effort cleanup
pass
await self._forget_folder(relative_folder, absolute_path)
return {
"success": True,
"dry_run": False,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error deleting folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _is_model_root(self, absolute_path: str) -> bool:
"""Return True when the path *is* one of the configured library roots."""
normalized = os.path.normpath(absolute_path)
for root in self.scanner.get_model_roots():
if os.path.normpath(os.path.abspath(root)) == normalized:
return True
return False
@staticmethod
def _is_model_file(file_name: str) -> bool:
"""Return True when the file name carries a model weight extension."""
return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS
def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]:
"""Describe everything a recursive delete of *absolute_path* removes.
Walking is intentional: the scanner cache can be stale, and a model file
that appeared on disk since the last scan must still block the delete.
Symbolic links are never followed (``os.walk`` default) and are counted
separately — ``shutil.rmtree`` unlinks them without touching their
targets.
``excluded_model_count`` splits the subset of ``model_count`` that the
library hides behind the ``exclude`` flag: those files still block the
delete, yet they are invisible to the model lists (and therefore to the
folder tree, which derives "empty" from them).
"""
model_count = 0
excluded_model_count = 0
file_count = 0
dir_count = 0
symlink_count = 0
total_bytes = 0
pending_delete_job = False
excluded_paths = self._excluded_model_paths()
for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
pending_delete_job = True
for name in dirnames:
if os.path.islink(os.path.join(dirpath, name)):
symlink_count += 1
else:
dir_count += 1
for name in filenames:
full_path = os.path.join(dirpath, name)
if os.path.islink(full_path):
symlink_count += 1
continue
if self._is_model_file(name):
model_count += 1
if _normalize_match_path(full_path) in excluded_paths:
excluded_model_count += 1
else:
file_count += 1
try:
total_bytes += os.path.getsize(full_path)
except OSError: # pragma: no cover - defensive
pass
return {
"model_count": model_count,
"excluded_model_count": excluded_model_count,
"file_count": file_count,
"dir_count": dir_count,
"symlink_count": symlink_count,
"total_bytes": total_bytes,
"pending_delete_job": pending_delete_job,
# A truly empty directory is the only case an "undo" can restore by
# simply recreating it; a folder holding stray files is gone for good.
"restorable": (
model_count == 0
and file_count == 0
and dir_count == 0
and symlink_count == 0
),
}
def _excluded_model_paths(self) -> Set[str]:
"""Absolute paths of the models the library hides behind ``exclude``.
Best-effort: scanner stand-ins that do not expose the accessor simply
report no excluded models.
"""
get_excluded = getattr(self.scanner, "get_excluded_models", None)
if not callable(get_excluded):
return set()
try:
paths = get_excluded() or []
except Exception: # pragma: no cover - defensive
return set()
return {_normalize_match_path(path) for path in paths if path}
async def _forget_folder(
self, relative_folder: str, absolute_path: Optional[str] = None
) -> None:
"""Drop a removed directory from the scanner's folder/cache records.
``absolute_path`` is what the caller actually deleted: the scanner needs
it to purge the right model cards, because the same relative folder can
exist under several roots.
"""
if not relative_folder:
return
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
if callable(remove_known_folder):
await remove_known_folder(relative_folder, absolute_path)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
Unlike :meth:`delete_folder` this works on folders that hold models.
A rename keeps every file, so no per-model lifecycle step is bypassed:
the directory is renamed on disk and the affected folder, cache, hash
index and metadata-sidecar records are re-keyed onto the new prefix by
the scanner.
Args:
folder_path: Absolute path of the directory to rename (business
path — symlinks are not resolved)
new_name: New leaf name; a single path segment, not a path
Returns:
Dictionary with the success flag, the previous/next library-relative
folder names and whether the directory actually moved.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
new_name = str(new_name or "").strip()
if not new_name:
return {"success": False, "error": "New folder name is required"}
if new_name in (".", "..") or any(
char in new_name for char in '/\\:*?"<>|'
):
return {"success": False, "error": "Invalid characters in folder name"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
return {
"success": False,
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be renamed",
}
previous_relative = self._calculate_relative_folder(absolute_path)
target = os.path.join(os.path.dirname(absolute_path), new_name)
if os.path.normpath(target) == os.path.normpath(absolute_path):
return {
"success": True,
"renamed": False,
"folder": previous_relative,
"previous_folder": previous_relative,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if os.path.exists(target):
return {
"success": False,
"code": "target_exists",
"error": f"A folder named \"{new_name}\" already exists here",
}
# A staging manifest records absolute original/staged paths, so
# moving a folder that holds one would break its undo and purge.
if self._has_pending_delete_job(absolute_path):
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
}
os.rename(absolute_path, target)
new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target
)
return {
"success": True,
"renamed": True,
"folder": new_relative,
"previous_folder": previous_relative,
"folder_path": target.replace(os.sep, "/"),
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error renaming folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
@staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree."""
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
return True
return False
async def _rename_folder_records(
self,
previous_relative: str,
new_relative: str,
previous_path: str,
new_path: str,
) -> None:
"""Hand the rename to the scanner so folder/cache records follow it."""
if not previous_relative or not new_relative:
return
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
if callable(rename_known_folder):
await rename_known_folder(
previous_relative,
new_relative,
previous_path=previous_path,
new_path=new_path,
)
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file """Move a single model file
+9 -1
View File
@@ -1,6 +1,8 @@
from typing import Dict, Optional, Set, List from typing import Dict, Optional, Set, List
import os import os
from ..utils.constants import is_empty_placeholder_hash
class ModelHashIndex: class ModelHashIndex:
"""Index for looking up models by hash or filename""" """Index for looking up models by hash or filename"""
@@ -81,6 +83,8 @@ class ModelHashIndex:
# mapping. First-time registrations stay O(1). # mapping. First-time registrations stay O(1).
if autov3: if autov3:
autov3 = autov3.lower() autov3 = autov3.lower()
if is_empty_placeholder_hash(autov3):
autov3 = None
if is_re_registration and (existing_hash != sha256 or autov3): if is_re_registration and (existing_hash != sha256 or autov3):
stale_autov3_keys = [ stale_autov3_keys = [
key for key, mapped_path in self._autov3_to_path.items() key for key, mapped_path in self._autov3_to_path.items()
@@ -93,7 +97,7 @@ class ModelHashIndex:
def add_autov3(self, autov3: str, file_path: str) -> None: def add_autov3(self, autov3: str, file_path: str) -> None:
"""Add or update an AutoV3-only index entry (used when only AutoV3 is known)""" """Add or update an AutoV3-only index entry (used when only AutoV3 is known)"""
if not autov3: if not autov3 or is_empty_placeholder_hash(autov3):
return return
autov3 = autov3.lower() autov3 = autov3.lower()
self._autov3_to_path[autov3] = file_path self._autov3_to_path[autov3] = file_path
@@ -250,6 +254,8 @@ class ModelHashIndex:
def has_hash(self, hash_value: str) -> bool: def has_hash(self, hash_value: str) -> bool:
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)""" """Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
if is_empty_placeholder_hash(hash_value):
return False
normalized = hash_value.lower() normalized = hash_value.lower()
if normalized in self._hash_to_path: if normalized in self._hash_to_path:
return True return True
@@ -261,6 +267,8 @@ class ModelHashIndex:
def get_path(self, hash_value: str) -> Optional[str]: def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)""" """Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
if is_empty_placeholder_hash(hash_value):
return None
normalized = hash_value.lower() normalized = hash_value.lower()
path = self._hash_to_path.get(normalized) path = self._hash_to_path.get(normalized)
if path is not None: if path is not None:
+172 -36
View File
@@ -2,14 +2,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import logging import logging
import os import os
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..services.pending_delete_service import get_pending_delete_service from ..services.pending_delete_service import get_pending_delete_service
from ..utils.constants import PREVIEW_EXTENSIONS from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,19 +21,45 @@ if TYPE_CHECKING:
from ..services.model_update_service import ModelUpdateService from ..services.model_update_service import ModelUpdateService
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
"""Load a metadata sidecar JSON, returning an empty dict when missing.
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
(download manager, use cases) that do not hold a sync-service instance.
"""
if not os.path.exists(metadata_path):
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
return {}
return payload if isinstance(payload, dict) else {}
async def delete_model_artifacts( async def delete_model_artifacts(
target_dir: str, file_name: str, main_extension: str | None = None target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]: ) -> List[str]:
"""Delete the primary model artefacts within ``target_dir``.""" """Delete the primary model artefacts within ``target_dir``.
Sidecars and previews are taken from the model's sidecar directory — the
model's own directory in alongside mode, the centralized mirror otherwise.
"""
main_extension = ".safetensors" if main_extension is None else main_extension main_extension = ".safetensors" if main_extension is None else main_extension
main_file = f"{file_name}{main_extension}" if main_extension else file_name main_file = f"{file_name}{main_extension}" if main_extension else file_name
patterns = [main_file, f"{file_name}.metadata.json"] model_path = os.path.join(target_dir, main_file)
sidecar_dir = get_sidecar_dir(model_path)
patterns = [os.path.basename(get_metadata_path(model_path))]
for ext in PREVIEW_EXTENSIONS: for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{file_name}{ext}") patterns.append(f"{file_name}{ext}")
deleted: List[str] = [] deleted: List[str] = []
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/") main_path = model_path.replace(os.sep, "/")
if os.path.exists(main_path): if os.path.exists(main_path):
os.remove(main_path) os.remove(main_path)
@@ -37,8 +67,8 @@ async def delete_model_artifacts(
else: else:
logger.warning("Model file not found: %s", main_file) logger.warning("Model file not found: %s", main_file)
for pattern in patterns[1:]: for pattern in patterns:
path = os.path.join(target_dir, pattern) path = os.path.join(sidecar_dir, pattern)
if os.path.exists(path): if os.path.exists(path):
try: try:
os.remove(path) os.remove(path)
@@ -79,6 +109,36 @@ def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "pat
) )
class BulkRenameContext:
"""Per-session state threaded through ``rename_model`` calls of a bulk rename.
Holds the lazily built recipe hash index so a bulk rename loop pays the
O(recipes) index build at most once (on the first recipe-touching rename)
instead of rescanning every recipe per renamed file. Also tracks whether
any recipe was re-pointed so the session finalizes recipe maintenance only
when needed.
"""
def __init__(self, recipe_scanner: Any) -> None:
self._recipe_scanner = recipe_scanner
self._recipe_hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None
self.recipes_touched = False
@property
def recipe_scanner(self) -> Any:
return self._recipe_scanner
async def get_recipe_hash_index(self) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""Return the lora-hash → recipes index, building it on first use."""
if self._recipe_scanner is None:
return None
if self._recipe_hash_index is None:
self._recipe_hash_index = (
await self._recipe_scanner.build_lora_hash_index()
)
return self._recipe_hash_index
class ModelLifecycleService: class ModelLifecycleService:
"""Co-ordinate destructive and mutating model operations.""" """Co-ordinate destructive and mutating model operations."""
@@ -239,7 +299,7 @@ class ModelLifecycleService:
_require_path_in_library_roots(file_path, self._scanner, label="File path") _require_path_in_library_roots(file_path, self._scanner, label="File path")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
metadata = await self._metadata_loader(metadata_path) metadata = await self._metadata_loader(metadata_path)
metadata["exclude"] = True metadata["exclude"] = True
@@ -294,7 +354,7 @@ class ModelLifecycleService:
if not os.path.exists(file_path): if not os.path.exists(file_path):
raise ValueError("Model file does not exist") raise ValueError("Model file does not exist")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = get_metadata_path(file_path)
metadata_payload = await self._metadata_loader(metadata_path) metadata_payload = await self._metadata_loader(metadata_path)
metadata_payload["exclude"] = False metadata_payload["exclude"] = False
@@ -337,10 +397,45 @@ class ModelLifecycleService:
return await self._scanner.bulk_delete_models(file_paths) return await self._scanner.bulk_delete_models(file_paths)
@asynccontextmanager
async def bulk_rename_session(self) -> AsyncIterator[BulkRenameContext]:
"""Context for bulk rename loops (filename-template "Apply to Library").
While active, the per-file ``update_single_model_cache`` resort/persist
chain and the per-file recipe folder-metadata refresh/resort are
deferred; both run exactly once when the outermost session exits — see
``ModelScanner.defer_cache_persist`` and
``RecipeScanner.finalize_bulk_filename_updates``. The finalize steps run
even on cancellation or mid-loop errors, because files are already
renamed on disk and the caches must not be left diverging.
Yields a :class:`BulkRenameContext` to pass as ``bulk_context`` into
each ``rename_model`` call of the loop.
"""
recipe_scanner = await self._recipe_scanner_factory()
context = BulkRenameContext(recipe_scanner)
async with self._scanner.defer_cache_persist():
try:
yield context
finally:
if recipe_scanner is not None and context.recipes_touched:
try:
await recipe_scanner.finalize_bulk_filename_updates()
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error finalizing bulk recipe updates: %s", exc
)
async def rename_model( async def rename_model(
self, *, file_path: str, new_file_name: str self, *, file_path: str, new_file_name: str, bulk_context: Optional[BulkRenameContext] = None
) -> Dict[str, object]: ) -> Dict[str, object]:
"""Rename a model and its companion artefacts.""" """Rename a model and its companion artefacts.
When ``bulk_context`` is given (bulk rename loop), the recipe
re-pointing uses the session's prebuilt hash index and defers recipe
maintenance to the session finalize; the scanner cache persist is
likewise deferred by the surrounding ``bulk_rename_session``.
"""
if not file_path or not new_file_name: if not file_path or not new_file_name:
raise ValueError("File path and new file name are required") raise ValueError("File path and new file name are required")
@@ -363,21 +458,26 @@ class ModelLifecycleService:
if os.path.exists(new_file_path): if os.path.exists(new_file_path):
raise ValueError("A file with this name already exists") raise ValueError("A file with this name already exists")
patterns = [ metadata_filename = os.path.basename(get_metadata_path(file_path))
f"{old_file_name}{old_extension}", # Sidecars/previews live in the sidecar dir (the model's own dir in
f"{old_file_name}.metadata.json", # alongside mode, the centralized mirror otherwise); the model file
f"{old_file_name}.metadata.json.bak", # itself always stays in target_dir.
sidecar_dir = get_sidecar_dir(file_path)
patterns: List[tuple[str, str]] = [
(target_dir, f"{old_file_name}{old_extension}"),
(sidecar_dir, metadata_filename),
(sidecar_dir, f"{metadata_filename}.bak"),
] ]
for ext in PREVIEW_EXTENSIONS: for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}") patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
existing_files: List[tuple[str, str]] = [] existing_files: List[tuple[str, str]] = []
for pattern in patterns: for pattern_dir, pattern in patterns:
path = os.path.join(target_dir, pattern) path = os.path.join(pattern_dir, pattern)
if os.path.exists(path): if os.path.exists(path):
existing_files.append((path, pattern)) existing_files.append((path, pattern))
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json") metadata_path = get_metadata_path(file_path)
metadata: Optional[Dict[str, object]] = None metadata: Optional[Dict[str, object]] = None
hash_value: Optional[str] = None hash_value: Optional[str] = None
@@ -386,31 +486,25 @@ class ModelLifecycleService:
raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None
hash_value = raw_hash if isinstance(raw_hash, str) else None hash_value = raw_hash if isinstance(raw_hash, str) else None
renamed_files: List[str] = []
new_metadata_path: Optional[str] = None
new_preview: Optional[str] = None new_preview: Optional[str] = None
for old_path, pattern in existing_files: renamed_files, new_metadata_path = await asyncio.to_thread(
ext = self._get_multipart_ext(pattern) self._rename_companion_files, existing_files, new_file_name
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace( )
os.sep, "/"
)
os.rename(old_path, new_path)
renamed_files.append(new_path)
if ext == ".metadata.json":
new_metadata_path = new_path
if metadata and new_metadata_path: if metadata and new_metadata_path:
metadata["file_name"] = new_file_name metadata["file_name"] = new_file_name
metadata["file_path"] = new_file_path metadata["file_path"] = new_file_path
# Preserve the pre-rename stem so the original download filename
# stays recoverable after template-driven renames.
metadata.setdefault("original_file_name", old_file_name)
if metadata.get("preview_url"): if metadata.get("preview_url"):
old_preview = str(metadata["preview_url"]) old_preview = str(metadata["preview_url"])
ext = self._get_multipart_ext(old_preview) ext = self._get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace( new_preview = os.path.join(
os.sep, "/" get_preview_dir(new_file_path), f"{new_file_name}{ext}"
) ).replace(os.sep, "/")
metadata["preview_url"] = new_preview metadata["preview_url"] = new_preview
await self._metadata_manager.save_metadata(new_file_path, metadata) await self._metadata_manager.save_metadata(new_file_path, metadata)
@@ -421,12 +515,26 @@ class ModelLifecycleService:
) )
if hash_value and getattr(self._scanner, "model_type", "") == "lora": if hash_value and getattr(self._scanner, "model_type", "") == "lora":
recipe_scanner = await self._recipe_scanner_factory() if bulk_context is not None:
recipe_scanner = bulk_context.recipe_scanner
hash_index = await bulk_context.get_recipe_hash_index()
defer_maintenance = True
else:
recipe_scanner = await self._recipe_scanner_factory()
hash_index = None
defer_maintenance = False
if recipe_scanner: if recipe_scanner:
try: try:
await recipe_scanner.update_lora_filename_by_hash( file_count, cache_count = (
hash_value, new_file_name await recipe_scanner.update_lora_filename_by_hash(
hash_value,
new_file_name,
hash_index=hash_index,
defer_maintenance=defer_maintenance,
)
) )
if bulk_context is not None and (file_count or cache_count):
bulk_context.recipes_touched = True
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error( logger.error(
"Error updating recipe references for %s: %s", "Error updating recipe references for %s: %s",
@@ -442,6 +550,34 @@ class ModelLifecycleService:
"reload_required": False, "reload_required": False,
} }
def _rename_companion_files(
self,
existing_files: List[tuple[str, str]],
new_file_name: str,
) -> tuple[List[str], Optional[str]]:
"""Rename all companion files, off the event loop thread.
Runs the blocking ``os.rename`` sequence for one model in a worker
thread so a single file's HDD I/O does not stall the event loop.
Never parallelized across files: one model's renames stay sequential
and the helper holds no locks.
"""
renamed_files: List[str] = []
new_metadata_path: Optional[str] = None
for old_path, pattern in existing_files:
ext = self._get_multipart_ext(pattern)
new_path = os.path.join(
os.path.dirname(old_path), f"{new_file_name}{ext}"
).replace(os.sep, "/")
os.rename(old_path, new_path)
renamed_files.append(new_path)
if ext == ".metadata.json":
new_metadata_path = new_path
return renamed_files, new_metadata_path
@staticmethod @staticmethod
def _get_multipart_ext(filename: str) -> str: def _get_multipart_ext(filename: str) -> str:
"""Return the extension for files with compound suffixes.""" """Return the extension for files with compound suffixes."""
+170 -1
View File
@@ -112,7 +112,10 @@ class _RateLimitRetryHelper:
# Labels of providers that are free to consult even while a network provider # Labels of providers that are free to consult even while a network provider
# is rate-limited (local lookups, no vendor cost). # is rate-limited (local lookups, no vendor cost).
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite"}) # "openmodeldb_api" qualifies because its lookups hit a local index built from
# a cached bulk dump; the underlying site is a static host (GitHub Pages), so
# even a cold cache refresh is a single cheap GET against a different vendor.
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite", "openmodeldb_api"})
class ModelMetadataProvider(ABC): class ModelMetadataProvider(ABC):
@@ -169,6 +172,28 @@ class ModelMetadataProvider(ABC):
"""Published model count for the user; None when unsupported.""" """Published model count for the user; None when unsupported."""
return None return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
"""Fetch raw stored file info via CivitAI's model-versions/mini endpoint.
Only the CivitAI provider implements this (#1100); other providers
already serve raw file names (CivArchive) or cannot resolve this
lookup (SQLite), so the default is None.
"""
return None
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
"""Fetch per-version buzz prices for a model, when the provider has them.
CivitAI publishes prices only inside its public model page payload;
providers that cannot read it (CivArchive, SQLite, OpenModelDB) keep the
default of None, which callers treat as "no price information".
"""
return None
class CivitaiModelMetadataProvider(ModelMetadataProvider): class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata""" """Provider that uses Civitai API for metadata"""
@@ -203,6 +228,16 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]: async def get_creator_model_count(self, username: str) -> Optional[int]:
return await self.client.get_creator_model_count(username) return await self.client.get_creator_model_count(username)
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
return await self.client.get_version_file_mini(version_id, file_id)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
return await self.client.get_model_prices(model_id)
class CivArchiveModelMetadataProvider(ModelMetadataProvider): class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive API for metadata""" """Provider that uses CivArchive API for metadata"""
@@ -464,6 +499,36 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
except json.JSONDecodeError: except json.JSONDecodeError:
return None return None
class OpenModelDBModelMetadataProvider(ModelMetadataProvider):
"""Provider that serves upscaler metadata from the OpenModelDB catalogue.
Only hash lookups are supported: OpenModelDB has no per-model or version
API, so the remaining provider surface intentionally returns None and lets
the fallback chain continue to the next provider.
"""
def __init__(self, openmodeldb_client):
self.client = openmodeldb_client
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
return await self.client.get_model_by_hash(model_hash)
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
"""Not supported: OpenModelDB models have no version history API."""
return None
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Not supported: OpenModelDB models have no version history API."""
return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Not supported: OpenModelDB models have no version history API."""
return None, "Model not found"
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Not supported by the OpenModelDB provider."""
return None
class FallbackMetadataProvider(ModelMetadataProvider): class FallbackMetadataProvider(ModelMetadataProvider):
"""Try providers in order, return first successful result. """Try providers in order, return first successful result.
@@ -700,9 +765,94 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue continue
return None return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result = await self._call_with_rate_limit(
label,
provider.get_version_file_mini,
version_id,
file_id,
)
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug(
"Provider %s failed for get_version_file_mini: %s", label, e
)
continue
return None
def _iter_providers(self): def _iter_providers(self):
return zip(self.providers, self._provider_labels) return zip(self.providers, self._provider_labels)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result = await self._call_with_rate_limit(
label,
provider.get_model_prices,
model_id,
)
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_prices: %s", label, e)
continue
return None
def excluding(self, labels: "frozenset[str] | set[str]") -> "FallbackMetadataProvider":
"""Return a copy of this chain without the providers named in *labels*.
Used by the metadata sync service to skip providers that cannot apply
to a given model (e.g. OpenModelDB only indexes upscalers), so their
cold-start cost (a bulk dump download) is never paid pointlessly.
"""
kept = [
(label, provider)
for provider, label in self._iter_providers()
if label not in labels
]
if len(kept) == len(self.providers):
return self
if not kept:
# Never produce an empty chain; the caller still needs a provider
# that can at least report "Model not found".
return self
return FallbackMetadataProvider(
kept,
rate_limit_retry_limit=self._rate_limit_retry_limit,
rate_limit_base_delay=self._rate_limit_base_delay,
rate_limit_max_delay=self._rate_limit_max_delay,
rate_limit_jitter_ratio=self._rate_limit_jitter_ratio,
)
async def _call_with_rate_limit(self, label: str, func, *args, **kwargs): async def _call_with_rate_limit(self, label: str, func, *args, **kwargs):
return await self._rate_limit_helper.run(label, func, *args, **kwargs) return await self._rate_limit_helper.run(label, func, *args, **kwargs)
@@ -791,6 +941,25 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]: async def get_creator_model_count(self, username: str) -> Optional[int]:
return await self._provider.get_creator_model_count(username) return await self._provider.get_creator_model_count(username)
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_version_file_mini,
version_id,
file_id,
)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_model_prices,
model_id,
)
class ModelMetadataProviderManager: class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers""" """Manager for selecting and using model metadata providers"""
+20 -8
View File
@@ -432,7 +432,6 @@ class SearchStrategy:
"tags": False, "tags": False,
"recursive": True, "recursive": True,
"creator": False, "creator": False,
"hash": False,
} }
def __init__( def __init__(
@@ -495,13 +494,14 @@ class SearchStrategy:
results.append(item) results.append(item)
continue continue
# Hash search is always exact (never fuzzy): match the full # Hash/id search is always exact (never fuzzy) and always on: it
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash. # matches the full sha256, its autov2 prefix (first 10 chars),
if options.get("hash", False): # the autov3 hash, or the Civitai model/version ids. Exact-match
hash_query = search_lower.strip() # semantics mean it adds no noise to ordinary keyword searches.
if hash_query and self._matches_hash(item, hash_query): hash_query = search_lower.strip()
results.append(item) if hash_query and self._matches_hash(item, hash_query):
continue results.append(item)
continue
return results return results
@@ -515,6 +515,18 @@ class SearchStrategy:
autov3 = item.get("autov3") autov3 = item.get("autov3")
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower(): if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
return True return True
civitai = item.get("civitai")
if isinstance(civitai, dict):
# A model card corresponds to one Civitai version: `modelId` is
# the model id (may match several cards when the library holds
# multiple versions), `id` is the version id (unique per card).
for key in ("modelId", "model_id", "id"):
value = civitai.get(key)
if value is None:
continue
value_str = str(value).strip()
if value_str and value_str != "0" and hash_query == value_str:
return True
return False return False
def _matches( def _matches(
+2178 -210
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -118,19 +118,24 @@ class ModelServiceFactory:
def register_default_model_types(): def register_default_model_types():
"""Register the default model types (LoRA, Checkpoint, and Embedding)""" """Register the default model types (LoRA, Checkpoint, Embedding, and Other)"""
from ..services.lora_service import LoraService from ..services.lora_service import LoraService
from ..services.checkpoint_service import CheckpointService from ..services.checkpoint_service import CheckpointService
from ..services.embedding_service import EmbeddingService from ..services.embedding_service import EmbeddingService
from ..services.other_model_service import OtherModelService
from ..routes.lora_routes import LoraRoutes from ..routes.lora_routes import LoraRoutes
from ..routes.checkpoint_routes import CheckpointRoutes from ..routes.checkpoint_routes import CheckpointRoutes
from ..routes.embedding_routes import EmbeddingRoutes from ..routes.embedding_routes import EmbeddingRoutes
from ..routes.other_routes import OtherRoutes
# Register LoRA model type # Register LoRA model type
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes) ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
# Register Checkpoint model type # Register Checkpoint model type
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes) ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
# Register Embedding model type # Register Embedding model type
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes) ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
# Register Other model type (VAE, upscaler, text encoder, ...)
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
+88
View File
@@ -0,0 +1,88 @@
"""External model-source providers (Hugging Face, ModelScope, TensorArt, OpenModelDB).
This package is the single abstraction over "a site that hosts models and
a model card". See :mod:`py.services.model_sources.base` for the provider
protocol and :mod:`py.services.model_sources.registry` for the lookup and
metadata-normalisation helpers used across the codebase.
"""
from __future__ import annotations
from .base import (
GROUP_PREFIXES,
HTTP_TIMEOUT,
ModelCardContext,
ModelSource,
ModelSourceCache,
ModelSourceError,
SourceRef,
USER_AGENT,
clean_source_url,
fetch_json,
fetch_text,
filter_weight_files,
is_valid_source_id,
)
from .huggingface import HuggingFaceSource
from .hydration import (
hydrate_from_source,
load_model_card,
resolve_site_base_model,
)
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .openmodeldb import OpenModelDBSource
from .registry import (
LEGACY_HF_URL_FIELD,
SOURCE_PLATFORM_FIELD,
SOURCE_URL_FIELD,
detect_source,
downloadable_sources,
get_download_source,
get_source,
get_source_platform,
has_external_source,
list_sources,
normalize_metadata_source,
resolve_source_ref,
source_group_key,
source_label,
)
from .tensorart import TensorArtSource
__all__ = [
"GROUP_PREFIXES",
"HTTP_TIMEOUT",
"LEGACY_HF_URL_FIELD",
"ModelCardContext",
"ModelSource",
"ModelSourceCache",
"ModelSourceError",
"HuggingFaceSource",
"ModelScopeIntlSource",
"ModelScopeSource",
"OpenModelDBSource",
"SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD",
"SourceRef",
"TensorArtSource",
"USER_AGENT",
"clean_source_url",
"detect_source",
"downloadable_sources",
"fetch_json",
"fetch_text",
"filter_weight_files",
"get_download_source",
"get_source",
"get_source_platform",
"has_external_source",
"hydrate_from_source",
"is_valid_source_id",
"list_sources",
"load_model_card",
"normalize_metadata_source",
"resolve_site_base_model",
"resolve_source_ref",
"source_group_key",
"source_label",
]
+526
View File
@@ -0,0 +1,526 @@
"""Base types for the external model-source provider abstraction.
A *model source* is a third-party site that hosts model files and a model
card (README) describing them — Hugging Face, ModelScope, TensorArt, and
whatever gets added later. Everything the rest of the codebase needs to
know about such a site is expressed by :class:`ModelSource`:
* how to recognise one of its URLs (:meth:`ModelSource.parse`)
* the canonical page URL for a source id (:meth:`ModelSource.canonical_url`)
* how to fetch the model card (:meth:`ModelSource.fetch_model_card`)
* how to fetch the extras that live *outside* the README
(:meth:`ModelSource.fetch_model_card_context`)
* how to turn repository-relative asset paths into absolute URLs
(:meth:`ModelSource.asset_base_url`)
* which capabilities the site actually supports
(``supports_enrichment`` / ``supports_download``)
Keeping this in one place means the agent pipeline, the scanners, and the
HTTP handlers never need site-specific branching.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, Mapping, Optional
import aiohttp
from ...utils.constants import MODEL_FILE_EXTENSIONS
logger = logging.getLogger(__name__)
#: Shared HTTP timeout for model-card fetches.
HTTP_TIMEOUT = 30
#: User agent used for all model-source HTTP requests.
USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
#: Platform → short prefix used when building version-group keys.
#: ``huggingface`` keeps the historical ``hf:`` prefix for backward
#: compatibility with already-cached group keys.
GROUP_PREFIXES: dict[str, str] = {
"huggingface": "hf",
"modelscope": "ms",
"modelscope-ai": "msai",
"tensorart": "ta",
"openmodeldb": "omdb",
}
@dataclass(frozen=True)
class SourceRef:
"""A parsed reference to a model hosted on an external site."""
platform: str
"""Canonical platform id, e.g. ``"huggingface"``."""
source_id: str
"""Site-specific identity, e.g. ``"user/repo"`` or ``"827823520299086029"``."""
url: str
"""Canonical URL of the model page."""
@dataclass
class ModelCardContext:
"""Site-specific extras that accompany a model's README model card.
A model card is not always just ``README.md``. ModelScope, for example,
keeps the author's summary, the site-curated tags, and the per-file
example images in its model-detail API rather than in the repository.
Sources with no such extras return an empty context (the default), so
every field here must be treated as optional by callers.
"""
description: str = ""
"""Author-written summary shown on the model page, outside the README."""
model_name: str = ""
"""Site-published display name for the repository.
Sites publish this next to the repository id (ModelScope's ``Name``).
It is what a CivitAI download would store as the model's name, so the
card never has to fall back to the local filename.
"""
model_name_localized: str = ""
"""Site-published localized name (ModelScope's ``ChineseName``)."""
version_name: str = ""
"""Site-published label for the requested file's version.
Resolved per file, like :attr:`example_images`: a repository publishes
one label per checkpoint (ModelScope's ``modelVersion.showName``).
"""
license: str = ""
"""License the site records for the repository."""
model_type: str = ""
"""Site-reported model type, e.g. ModelScope's ``AigcType`` (``LoRA``)."""
base_model: str = ""
"""Base model as reported by the site (possibly a site-local id)."""
base_model_aliases: list[str] = field(default_factory=list)
"""Other names the site uses for the same base model.
Sites often publish both a link-style id (``krea/Krea-2-Turbo``) and an
internal architecture enum (``KREA_2``). The enum usually normalises
cleanly onto this system's canonical vocabulary, so it is the better
resolution hint for :mod:`py.services.agent.base_model_resolver`.
"""
official_tags: list[str] = field(default_factory=list)
"""Content tags curated by the site itself."""
example_images: list[str] = field(default_factory=list)
"""Absolute URLs of example images for the requested model file."""
trigger_words: list[str] = field(default_factory=list)
"""Trigger words the site records for the requested model file."""
source_model_id: str = ""
"""Site-native id of the *published model* the requested file belongs to.
Sites whose repository is not a model identity publish a separate,
stable id per model (ModelScope's ``modelVersion.modelId`` — identical
across every version of one published model, different between the
models of a collection repository). It is the version-grouping key,
persisted on the sidecar as ``source_model_id``.
"""
source_version_id: str = ""
"""Site-native id of the published version the requested file belongs to
(ModelScope's ``modelVersion.id``), persisted as ``source_version_id``."""
def is_empty(self) -> bool:
"""Return ``True`` when the site contributed nothing extra."""
return not any(
(
self.description,
self.model_name,
self.model_name_localized,
self.version_name,
self.license,
self.model_type,
self.base_model,
self.base_model_aliases,
self.official_tags,
self.example_images,
self.trigger_words,
self.source_model_id,
self.source_version_id,
)
)
class ModelSourceError(Exception):
"""Raised when a model source cannot satisfy a request.
Carries the HTTP status the API handler should answer with, so the
handlers stay free of per-site error mapping.
"""
def __init__(self, message: str, status: int = 502) -> None:
super().__init__(message)
self.status = status
class ModelSourceCache:
"""Per-run memo shared between the agent pipeline and a model source.
A collection repository publishes many model files under a single source
id, so enriching each file re-fetches the same README and the same
repository metadata. One cache is created per enrichment run and thrown
away afterwards: nothing is retained across runs (a model card can change
at any time), and download URLs are never routed through it.
"""
def __init__(self) -> None:
#: Provider-agnostic: ``"<platform>:<source_id>"`` → raw README text.
self.readmes: Dict[str, str] = {}
#: Provider-owned scratch space. Keys must be namespaced by the
#: provider (``(platform, kind, source_id)``) so two providers can
#: never collide. Only successful results should be stored, so a
#: transient failure is still retried for the next file.
self.provider: Dict[Any, Any] = {}
#: Repository ids are always exactly ``owner/name``. Components may contain
#: dots (``black-forest-labs/FLUX.1-dev``) but must not be empty, ``.`` / ``..``,
#: or start with a dot - the id is used as a path segment on disk.
_SOURCE_ID_COMPONENT = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
def is_valid_source_id(source_id: str) -> bool:
"""Return ``True`` when *source_id* is a safe ``owner/name`` repository id."""
if not source_id or not isinstance(source_id, str) or source_id.count("/") != 1:
return False
owner, name = source_id.split("/", 1)
return all(
part and part not in (".", "..") and _SOURCE_ID_COMPONENT.match(part)
for part in (owner, name)
)
async def fetch_text(
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
) -> str:
"""Fetch *url* and return its body as text, or ``""`` on any failure.
Network problems are expected (offline installs, rate limits, dead
repos) and must never bubble up into the pipeline, so every error is
logged at debug level and normalised to an empty string.
"""
try:
request_headers = {"User-Agent": USER_AGENT}
if headers:
request_headers.update(headers)
async with aiohttp.ClientSession(
headers=request_headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as session:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.text()
logger.debug("Fetch %s returned HTTP %s", url, resp.status)
except Exception as exc: # pragma: no cover - network dependent
logger.debug("Failed to fetch %s: %s", url, exc)
return ""
async def fetch_json(
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
) -> tuple[int, Any]:
"""Fetch *url* and return ``(status, parsed_body)``.
Unlike :func:`fetch_text` this reports the status, because callers such as
the file-listing endpoints need to distinguish "repo not found" (404) from
a transport failure. ``parsed_body`` is ``None`` when the response is not
JSON or the request failed outright (status ``0``).
"""
try:
request_headers = {"User-Agent": USER_AGENT}
if headers:
request_headers.update(headers)
async with aiohttp.ClientSession(
headers=request_headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as session:
async with session.get(url) as resp:
if resp.status != 200:
return resp.status, None
try:
return resp.status, await resp.json(content_type=None)
except Exception:
return resp.status, None
except Exception as exc: # pragma: no cover - network dependent
logger.debug("Failed to fetch %s: %s", url, exc)
return 0, None
class ModelSource:
"""Description and I/O for one external model hosting site."""
#: Canonical platform id stored in metadata.
platform: str = ""
#: Human-readable name used in UI copy and prompts.
label: str = ""
#: Whether the agent skill can fetch a model card and run AI extraction.
supports_enrichment: bool = False
#: Whether models can be downloaded directly from this site.
supports_download: bool = False
#: Branch used when the caller does not pass an explicit revision.
default_revision: str = ""
#: Sub-directory the "use default paths" template places downloads in.
default_subdir: str = ""
#: Source id used to build the example URL shown in UI copy and error
#: messages. ``owner/name`` suits repository sites; sites with a
#: different identity shape override it with a real example.
example_source_id: str = "user/repo"
#: Lenient pattern used to recognise URLs already stored in metadata.
#: Captures the site-specific source id in group ``id``.
url_pattern: re.Pattern[str] | None = None
#: Strict pattern used to validate user input. Must match the whole URL.
strict_url_pattern: re.Pattern[str] | None = None
# ------------------------------------------------------------------
# Parsing
# ------------------------------------------------------------------
def parse(self, url: str, *, strict: bool = False) -> Optional[str]:
"""Return the source id contained in *url*, or ``None``.
With ``strict=True`` the URL must match this site's canonical shape
exactly (used when validating what a user pasted); with
``strict=False`` sub-paths such as ``/resolve/main/file.bin`` are
tolerated (used when normalising already-stored values).
"""
if not url or not isinstance(url, str):
return None
candidate = url.strip()
if not candidate:
return None
pattern = self.strict_url_pattern if strict else self.url_pattern
if pattern is None:
return None
match = pattern.match(candidate)
return match.group("id") if match else None
def ref(self, url: str, *, strict: bool = False) -> Optional[SourceRef]:
"""Return a :class:`SourceRef` for *url*, or ``None`` if not ours."""
source_id = self.parse(url, strict=strict)
if not source_id:
return None
return SourceRef(
platform=self.platform,
source_id=source_id,
url=self.canonical_url(source_id),
)
# ------------------------------------------------------------------
# URLs and content
# ------------------------------------------------------------------
def canonical_url(self, source_id: str) -> str:
"""Return the canonical model-page URL for *source_id*."""
raise NotImplementedError
def is_valid_source_id(self, source_id: str) -> bool:
"""Return ``True`` when *source_id* is a safe id on this site.
Defaults to the ``owner/name`` repository rule; sites whose ids are
not repositories (OpenModelDB's flat model ids) override it.
"""
return is_valid_source_id(source_id)
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
"""Path segments appended to the model root by "use default paths".
Defaults to ``<default_subdir>/<owner>/<repo>`` so downloads from
repository sites stay namespaced by author. Sites without an
owner/repo split override it.
"""
owner, repo_name = source_id.split("/", 1)
return (self.default_subdir, owner, repo_name)
def asset_base_url(self, source_id: str, revision: str = "") -> str:
"""Base URL used to resolve repository-relative asset paths."""
return ""
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
"""Return the version-group key for the model described by *item*.
The default groups by source id (``{prefix}:{owner}/{repo}``), which
is only correct when the source id already identifies a single
published model. Sources whose repository hosts many unrelated
models override this: they either derive the key from a site-native
model identity recorded in *item* (ModelScope's ``source_model_id``)
or return ``None`` when the platform has no reliable model identity
at all (Hugging Face), leaving the model ungrouped.
"""
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
return f"{prefix}:{ref.source_id}"
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch the raw model card (README) markdown for *source_id*."""
return ""
async def fetch_model_card_context(
self,
source_id: str,
filename: str = "",
*,
sha256: str = "",
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Return the card extras the site keeps outside the README.
*filename* is the model file's basename (no directory) and *sha256*
its content hash; between them they select the right entry when a
repository holds several models. A site that records per-file hashes
should prefer *sha256*, because it is the only identifier that
survives the user renaming the weights.
*cache* is an optional per-run memo (see :class:`ModelSourceCache`)
that lets a provider avoid re-fetching repository-wide data for every
file in a collection repository.
Sites whose model card is fully described by :meth:`fetch_model_card`
need no override and inherit this empty context.
Implementations must never raise: enrichment treats a missing
context as "the site had nothing extra to say".
"""
return ModelCardContext()
# ------------------------------------------------------------------
# Download support
# ------------------------------------------------------------------
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict[str, Any]]:
"""List downloadable weight files in *source_id*.
Returns ``[{"filename": <repo-relative path>, "size": <bytes>}]``,
largest first, filtered to :data:`MODEL_FILE_EXTENSIONS`. Sites
without download support return an empty list.
Raises :class:`ModelSourceError` when the repository cannot be read,
so the handler can surface "not found" separately from a transport
failure.
"""
return []
def auth_headers(self) -> Dict[str, str]:
"""Extra request headers this site needs for API and file downloads.
Empty by default; sites with gated/private content (Hugging Face)
override it to attach the user's access token when one is configured.
"""
return {}
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
"""Return the direct (redirecting) download URL for one file."""
raise ModelSourceError(
f"{self.label or self.platform} does not support downloads", status=400
)
async def resolve_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
"""Resolve the download URL for one file, allowing async lookups.
Defaults to the synchronous :meth:`file_download_url`; sites whose
download URL is not derivable from the id alone (OpenModelDB stores
the URL inside its catalogue entry) override this to look it up.
"""
return self.file_download_url(source_id, filename, revision)
def resolve_revision(self, revision: str = "") -> str:
"""Return *revision*, falling back to this site's default branch."""
return revision or self.default_revision
def page_url_for_file(self, source_id: str, filename: str) -> str:
"""Return the human-facing page for *filename* inside *source_id*."""
return self.canonical_url(source_id)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<ModelSource {self.platform}>"
def clean_source_url(url: Any) -> str:
"""Normalise a stored source URL value into a stripped string."""
if not isinstance(url, str):
return ""
return url.strip()
def filter_weight_files(entries: Iterable[tuple[str, int]]) -> list[dict[str, Any]]:
"""Keep model-weight files from ``(path, size)`` pairs, largest first.
Every site lists a lot more than weights (READMEs, configs, tokenizers,
…); the download picker only ever wants the files ComfyUI can load, which
is exactly :data:`MODEL_FILE_EXTENSIONS`.
"""
files = [
{"filename": path, "size": int(size or 0)}
for path, size in entries
if path and os.path.splitext(path)[1].lower() in MODEL_FILE_EXTENSIONS
]
files.sort(key=lambda entry: entry["size"], reverse=True)
return files
__all__ = [
"GROUP_PREFIXES",
"HTTP_TIMEOUT",
"ModelCardContext",
"ModelSource",
"ModelSourceCache",
"ModelSourceError",
"SourceRef",
"USER_AGENT",
"clean_source_url",
"fetch_json",
"fetch_text",
"filter_weight_files",
"is_valid_source_id",
]
+153
View File
@@ -0,0 +1,153 @@
"""Hugging Face model source."""
from __future__ import annotations
import logging
import re
from typing import Any, Mapping, Optional
from .base import (
ModelSource,
ModelSourceError,
SourceRef,
fetch_json,
fetch_text,
filter_weight_files,
)
logger = logging.getLogger(__name__)
#: Lenient — used to normalise URLs already stored in metadata; tolerates
#: sub-paths such as ``/resolve/main/model.safetensors``.
_URL_PATTERN = re.compile(
r"https?://(?:www\.)?huggingface\.co/(?P<id>[^/?#\s]+/[^/?#\s]+)"
)
#: Strict — validates what the user pasted into the "link model" dialog.
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?huggingface\.co/(?P<id>[^/?#\s]+/[^/?#\s]+)/?$"
)
def _hf_token() -> str:
"""Return the configured Hugging Face access token, or ``""``."""
try:
from ..settings_manager import get_settings_manager
token = get_settings_manager().get("huggingface_api_key", "")
except Exception: # pragma: no cover - settings must never break downloads
return ""
return token.strip() if isinstance(token, str) else ""
class HuggingFaceSource(ModelSource):
"""Hugging Face Hub (``huggingface.co``)."""
platform = "huggingface"
label = "Hugging Face"
supports_enrichment = True
supports_download = True
default_revision = "main"
default_subdir = "huggingface"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
def canonical_url(self, source_id: str) -> str:
return f"https://huggingface.co/{source_id}"
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
"""Hugging Face models never auto-group.
A repository is not a model identity — collection repos host many
unrelated models — and the Hub exposes no site-native published-model
id, so there is no reliable key to group by.
"""
return None
def asset_base_url(self, source_id: str, revision: str = "") -> str:
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
def auth_headers(self) -> dict[str, str]:
"""Bearer header for gated/private repositories, when a token is set."""
token = _hf_token()
return {"Authorization": f"Bearer {token}"} if token else {}
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
headers = self.auth_headers()
for branch in ("main", "master"):
text = await fetch_text(
f"https://huggingface.co/{source_id}/raw/{branch}/README.md",
headers=headers,
)
if text:
return text
return ""
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict]:
"""List weight files via the Hub tree API.
The tree endpoint (rather than the model-info endpoint) is used
because it reports accurate sizes for LFS-tracked files.
"""
revision = self.resolve_revision(revision)
status, payload = await fetch_json(
f"https://huggingface.co/api/models/{source_id}/tree/{revision}",
headers=self.auth_headers(),
)
if status == 404:
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
if status in (401, 403):
if _hf_token():
raise ModelSourceError(
f"Access to '{source_id}' was denied (HTTP {status}). For a gated "
"repository you must accept its terms on the Hugging Face page, "
"and the configured token needs read permission for it.",
status=403,
)
raise ModelSourceError(
f"'{source_id}' requires a Hugging Face access token (gated or "
"private repository). Configure one in Settings → Hugging Face "
"Access Token, and accept the repository's terms on its page.",
status=401,
)
if status != 200 or not isinstance(payload, list):
raise ModelSourceError(
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
)
entries = []
for entry in payload:
if not isinstance(entry, dict):
continue
path = entry.get("path", "")
size = entry.get("size", 0) or 0
if not size and isinstance(entry.get("lfs"), dict):
size = entry["lfs"].get("size", 0) or 0
entries.append((path, size))
return filter_weight_files(entries)
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
return (
f"https://huggingface.co/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}"
)
def page_url_for_file(self, source_id: str, filename: str) -> str:
return (
f"https://huggingface.co/{source_id}/blob/{self.default_revision}/{filename}"
)
__all__ = ["HuggingFaceSource"]
+235
View File
@@ -0,0 +1,235 @@
"""Deterministic metadata hydration for freshly downloaded source models.
A CivitAI download writes a fully-populated metadata sidecar as part of the
download itself: the name, the description, the tags, the trigger words and
the example images all arrive with the file. A download from an external
model source (ModelScope, Hugging Face) has the same information behind a
public API, but historically landed as a bare filename plus a source URL that
the user had to enrich by hand ("Enrich Metadata with AI").
This module closes that gap without involving an LLM. It fetches the linked
site's model card, hands it to the same :class:`~py.services.agent.post_processor.PostProcessor`
the AI skill uses, and writes the result. Everything it applies is data the
site published, so it is safe to run automatically on every download and to
treat as a fallback for the gaps the LLM would otherwise fill.
Nothing here may break a download: every failure is logged and normalised to
"the site had nothing to contribute".
"""
from __future__ import annotations
import logging
import os
import time
from typing import TYPE_CHECKING, Optional
from .base import ModelCardContext, ModelSourceCache
from .registry import get_source, resolve_source_ref
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSource, SourceRef
logger = logging.getLogger(__name__)
#: How long a fetched repository payload stays usable. A download batch walks
#: a repository's files one HTTP request at a time, and the README plus the
#: detail payload describe the *repository*, not the file, so re-fetching them
#: per file would be pure waste. They expire so an edited model card is still
#: picked up by the next batch.
SHARED_CACHE_TTL = 300.0
#: Upper bound on memoised repositories; a long-running server must not grow
#: without limit.
SHARED_CACHE_MAX_ENTRIES = 32
#: ``"<platform>:<source_id>"`` → ``(expiry, memo)``.
_shared_caches: dict[str, tuple[float, ModelSourceCache]] = {}
def shared_source_cache(platform: str, source_id: str) -> ModelSourceCache:
"""Return a short-lived per-repository memo for download-time hydration."""
now = time.monotonic()
key = f"{platform}:{source_id}"
entry = _shared_caches.get(key)
if entry is not None and entry[0] > now:
return entry[1]
for expired in [k for k, (expiry, _) in _shared_caches.items() if expiry <= now]:
_shared_caches.pop(expired, None)
if len(_shared_caches) >= SHARED_CACHE_MAX_ENTRIES:
oldest = min(_shared_caches, key=lambda k: _shared_caches[k][0])
_shared_caches.pop(oldest, None)
cache = ModelSourceCache()
_shared_caches[key] = (now + SHARED_CACHE_TTL, cache)
return cache
def reset_shared_caches() -> None:
"""Drop every memoised repository — used by tests."""
_shared_caches.clear()
async def load_model_card(
source: "ModelSource",
source_id: str,
cache: Optional[ModelSourceCache] = None,
) -> str:
"""Return *source_id*'s README, reusing *cache* when one is supplied.
Only successful reads are memoised, leaving a transient failure to be
retried for the next file of the same repository.
"""
key = f"{source.platform}:{source_id}"
if cache is not None:
cached = cache.readmes.get(key)
if cached is not None:
return cached
readme = await source.fetch_model_card(source_id)
if cache is not None and readme:
cache.readmes[key] = readme
return readme or ""
async def resolve_site_base_model(context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``.
Sites name base models in their own vocabulary (ModelScope publishes both
``krea/Krea-2-Turbo`` and the ``KREA_2_TURBO`` enum). The resolver is
strict and only ever returns a name the canonical vocabulary already
contains, so an uncertain hint yields ``""`` rather than a plausible-looking
wrong value.
"""
hints = [*context.base_model_aliases, context.base_model]
if not any(hints):
return ""
# Imported lazily: pulling in the agent package at module scope would make
# the model-source package import itself while it is still initialising.
try:
from ...metadata_ops import list_base_models
from ..agent.base_model_resolver import resolve_base_model
known_names = await list_base_models()
except Exception as exc:
logger.warning("Could not resolve a site base model: %s", exc)
return ""
return resolve_base_model(hints, known_names)
async def hydrate_from_source(
file_path: str,
*,
ref: "SourceRef",
cache: Optional[ModelSourceCache] = None,
) -> list[str]:
"""Apply the linked site's published metadata to a downloaded model.
This is the deterministic counterpart of the ``enrich_hf_metadata`` skill:
it produces the same populated model card a CivitAI download produces,
without an LLM and without user action.
Args:
file_path: The just-downloaded model file, whose sidecar already
carries the SHA256 used to match the right file in a collection
repository.
ref: The source the file came from.
cache: Optional per-call memo; defaults to a short-lived shared one so
a batch over one repository fetches its card only once.
Returns:
The names of the metadata fields that changed. Never raises — a site
that is down, or an API that changed shape, must not fail a download.
"""
try:
source = get_source(ref.platform)
if source is None or not source.supports_enrichment:
return []
from ...metadata_ops import read_metadata
metadata = await read_metadata(file_path)
if not metadata:
logger.debug("No metadata to hydrate for %s", file_path)
return []
# Only a model that is actually linked to this repository may be
# updated. The download path writes those fields just before calling
# us; a file that merely shares a name with the requested one must not
# be given another model's card.
linked = resolve_source_ref(metadata)
if linked is None or (linked.platform, linked.source_id) != (
ref.platform,
ref.source_id,
):
logger.debug(
"Not hydrating %s: linked to %s, not %s",
file_path, linked.url if linked else "no model source", ref.url,
)
return []
memo = cache if cache is not None else shared_source_cache(
ref.platform, ref.source_id
)
readme = await load_model_card(source, ref.source_id, memo)
context = await source.fetch_model_card_context(
ref.source_id,
os.path.basename(file_path),
sha256=(metadata.get("sha256") or "").strip(),
cache=memo,
)
if context.is_empty() and not readme:
logger.debug(
"No published metadata for %s on %s", ref.source_id, ref.platform
)
return []
resolved_base_model = await resolve_site_base_model(context)
from ..agent.post_processor import PostProcessor
result = await PostProcessor().process(
skill_name="enrich_hf_metadata",
model_path=file_path,
llm_output={},
metadata=metadata,
readme_content=readme,
source_context=context,
resolved_base_model=resolved_base_model,
metadata_source=f"source:{ref.platform}",
)
if not result.get("success", True):
logger.debug(
"Hydration reported failure for %s: %s",
file_path, result.get("errors"),
)
return []
updated = list(result.get("updated_fields") or [])
logger.info(
"Hydrated %s from %s (%s): %s",
file_path, source.label or ref.platform, ref.source_id,
", ".join(updated) or "nothing to change",
)
return updated
except Exception as exc: # pragma: no cover - defensive by design
logger.warning("Source hydration failed for %s: %s", file_path, exc)
return []
__all__ = [
"SHARED_CACHE_MAX_ENTRIES",
"SHARED_CACHE_TTL",
"hydrate_from_source",
"load_model_card",
"reset_shared_caches",
"resolve_site_base_model",
"shared_source_cache",
]
+691
View File
@@ -0,0 +1,691 @@
"""ModelScope (魔搭社区) model sources.
ModelScope exposes the same "model card as README.md" convention as
Hugging Face, including a YAML frontmatter block that often carries
``base_model:`` and ``trigger_words:``. Four public endpoints are used,
none of which requires an API key for public models:
* ``/models/{owner}/{name}/resolve/{revision}/README.md`` — raw model card
* ``/api/v1/models/{owner}/{name}/repo?Revision=..&FilePath=README.md`` —
the same content through the API, used as a fallback when the resolve
URL is unavailable.
* ``/api/v1/models/{owner}/{name}`` — the model-detail payload behind the
model page. It carries the repository's display name (``Name`` /
``ChineseName``), the author's summary (``Description``), the license, the
AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
per published version, the model filenames
(``MuseInfo.versions[].stats.fileList``) together with that version's label
(``modelVersion.showName``), example images (``coverImages``) and trigger
words. See :meth:`ModelScopeSource.fetch_model_card_context`.
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
listing backing the download picker. It reports real sizes for LFS
files (not the pointer size), so no extra HEAD request is needed.
Downloads go through ``/models/{owner}/{name}/resolve/{revision}/{path}``,
which redirects to a CDN URL carrying a time-limited ``auth_key``.
Requesting the resolve URL fresh on every attempt (which the shared
downloader does, including for resumable Range requests) keeps that key
valid; the CDN URL must never be cached.
The README and the detail payload both describe the whole repository rather
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
again for every checkpoint of a collection repository.
Two deployments are served by this module. ``modelscope.cn`` (with
``modelscope.com`` as a redirect alias) and ``modelscope.ai`` are *separate
catalogues*, not mirrors, so they are registered as distinct sources:
:class:`ModelScopeSource` and :class:`ModelScopeIntlSource`. Every URL either
class builds is derived from its ``base_url``.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Optional
from .base import (
GROUP_PREFIXES,
ModelCardContext,
ModelSource,
ModelSourceError,
SourceRef,
clean_source_url,
fetch_json,
fetch_text,
filter_weight_files,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSourceCache
logger = logging.getLogger(__name__)
#: ModelScope runs two independent catalogues. ``modelscope.com`` is a
#: redirect alias of the mainland site, but ``modelscope.ai`` is the
#: *international* deployment with its own repository catalogue — a repository
#: published on one is routinely absent from the other (``referall13/EM1``
#: exists only on ``.ai``, ``jj3550945163/Krea-2-LORA`` only on ``.cn``). The
#: host therefore decides which site, API and CDN a model belongs to, and the
#: two deployments are registered as separate sources rather than folded into
#: one id.
_MAINLAND_HOSTS = r"modelscope\.(?:cn|com)"
_INTERNATIONAL_HOSTS = r"modelscope\.ai"
#: Trailing view segments the site appends to a model URL; accepted verbatim
#: when the user pastes a browser tab URL.
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
"""Build the lenient and strict model-URL patterns for *hosts*."""
body = rf"https?://(?:www\.)?(?:{hosts})/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
return re.compile(body), re.compile(rf"{body}/?{_VIEW_SEGMENTS}/?$")
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
#: for repos imported from Hugging Face.
_REVISIONS = ("master", "main")
class ModelScopeSource(ModelSource):
"""ModelScope's mainland site (``modelscope.cn``).
``modelscope.com`` is accepted as an alias of it. The international
deployment is :class:`ModelScopeIntlSource`; everything below is written in
terms of ``base_url`` so both share one implementation.
"""
platform = "modelscope"
label = "ModelScope"
supports_enrichment = True
supports_download = True
default_revision = "master"
default_subdir = "modelscope"
#: Origin every outgoing URL is built from.
base_url = "https://modelscope.cn"
url_pattern, strict_url_pattern = _url_patterns(_MAINLAND_HOSTS)
def canonical_url(self, source_id: str) -> str:
return f"{self.base_url}/models/{source_id}"
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
"""Group by ModelScope's published-model id, never by repository.
A collection repository hosts many unrelated published models, so
the repo id is not a version-group identity. Only models whose
metadata carries the site-native ``source_model_id`` (recorded at
enrichment time from ``MuseInfo.versions[].modelVersion.modelId``)
group together; unenriched models stay standalone.
"""
model_id = clean_source_url(item.get("source_model_id"))
if not model_id:
return None
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
return f"{prefix}:{model_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str:
return (
f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}"
)
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch the model card, preferring the raw resolve URL."""
for revision in _REVISIONS:
text = await fetch_text(
f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
)
if text:
return text
# Fallback: the repo API proxies the same file and is reachable in
# environments where the CDN resolve host is blocked.
for revision in _REVISIONS:
text = await fetch_text(
f"{self.base_url}/api/v1/models/"
f"{source_id}/repo?Revision={revision}&FilePath=README.md"
)
if text:
return text
return ""
async def fetch_model_card_context(
self,
source_id: str,
filename: str = "",
*,
sha256: str = "",
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Read the model-detail API that backs the ModelScope model page.
ModelScope splits a model card in two: ``README.md`` holds the
long-form content, while the author's summary, the site-curated tags,
and the per-file example images live only here. AIGC repositories
frequently ship an auto-generated README ("the contributor provided
no further description") and put everything useful in ``Description``,
so enrichment that reads only the README comes back nearly empty.
The wanted file is identified by its sha256 when the caller knows it
and by *filename* otherwise; see :func:`_matching_versions`. The
images and trigger words returned belong to that exact
``.safetensors`` — essential for collection repositories, where every
checkpoint has its own sample image.
The detail payload describes the whole repository and is therefore
shared across every file in it, so it is read through *cache* when the
caller supplies one; only the per-file selection is redone.
"""
data = await self._fetch_detail(source_id, cache=cache)
if data is None:
return ModelCardContext()
return _build_card_context(data, filename, sha256)
async def _fetch_detail(
self,
source_id: str,
*,
cache: Optional["ModelSourceCache"] = None,
) -> Optional[dict[str, Any]]:
"""Fetch (or reuse) the model-detail payload for *source_id*."""
cache_key = (self.platform, "detail", source_id)
if cache is not None and cache_key in cache.provider:
return cache.provider[cache_key]
status, payload = await fetch_json(
f"{self.base_url}/api/v1/models/{source_id}"
)
if status != 200 or not isinstance(payload, dict):
logger.debug(
"ModelScope detail API returned HTTP %s for %s", status, source_id
)
return None
data = payload.get("Data")
if not isinstance(data, dict):
return None
if cache is not None:
cache.provider[cache_key] = data
return data
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict]:
"""List weight files via the repo files API.
``master`` is the only branch name the API accepts — even repos
imported from Hugging Face are addressed as ``master`` (``main``
returns 404) — so no fallback probing is done here.
"""
revision = self.resolve_revision(revision)
status, payload = await fetch_json(
f"{self.base_url}/api/v1/models/"
f"{source_id}/repo/files?Revision={revision}"
)
if status == 404:
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
if status != 200 or not isinstance(payload, dict):
raise ModelSourceError(
f"ModelScope API error while listing '{source_id}' (HTTP {status})"
)
entries = []
for entry in (payload.get("Data") or {}).get("Files") or []:
if not isinstance(entry, dict) or entry.get("Type") != "blob":
continue
entries.append((entry.get("Path", ""), entry.get("Size", 0) or 0))
return filter_weight_files(entries)
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
return (
f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}"
)
def page_url_for_file(self, source_id: str, filename: str) -> str:
return (
f"{self.base_url}/models/{source_id}/file/view/"
f"{self.default_revision}/{filename}"
)
class ModelScopeIntlSource(ModelScopeSource):
"""ModelScope's international site (``modelscope.ai``).
A separate catalogue rather than a mirror, so it is registered under its
own platform id: the two deployments must not share a version group, a
"use default paths" directory, or a stored ``source_url``. The detail API,
the file listing, the resolve URLs and the CDN redirect all behave exactly
like the mainland site, which is why every URL here is derived from
:attr:`base_url` instead of being duplicated.
"""
platform = "modelscope-ai"
label = "ModelScope (International)"
default_subdir = "modelscope-ai"
base_url = "https://www.modelscope.ai"
url_pattern, strict_url_pattern = _url_patterns(_INTERNATIONAL_HOSTS)
__all__ = ["ModelScopeIntlSource", "ModelScopeSource"]
# ---------------------------------------------------------------------------
# Model-detail API parsing helpers
# ---------------------------------------------------------------------------
#: Trigger-word values that mean "the author left this blank".
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
#: Repository tags that only restate what the model *is* (its library, task or
#: framework) rather than what it depicts. ModelScope mixes both into the
#: plain ``Tags`` list, and a card tagged "lora" or "text-to-image" is noise.
_GENERIC_TAGS = frozenset(
{
"any-to-any",
"checkpoint",
"controlnet",
"diffusers",
"embedding",
"image-text-to-text",
"image-to-image",
"image-to-video",
"lora",
"lycoris",
"onnx",
"pytorch",
"safetensors",
"tensorflow",
"text-to-image",
"text-to-speech",
"text-to-video",
"textual-inversion",
"vae",
}
)
def _clean_text(value: Any) -> str:
"""Return a stripped string for *value*, or ``""`` for anything else."""
return value.strip() if isinstance(value, str) else ""
def _first_string(value: Any) -> str:
"""Return the first non-empty string in a list, or ``""``."""
if isinstance(value, list):
for item in value:
text = _clean_text(item)
if text:
return text
return ""
def _build_card_context(
data: dict[str, Any], filename: str, sha256: str = ""
) -> ModelCardContext:
"""Turn a model-detail payload into a :class:`ModelCardContext`.
Separated from the HTTP fetch so the repository-wide payload can be cached
across the files of a collection repository while the per-file selection
is still redone for each one.
"""
context = ModelCardContext(
description=_clean_text(data.get("Description")),
model_name=_clean_text(data.get("Name")),
model_name_localized=_clean_text(data.get("ChineseName")),
license=_clean_text(data.get("License")),
model_type=_clean_text(data.get("AigcType")),
base_model=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data),
)
versions = _matching_versions(
data.get("MuseInfo"),
filename,
digests=_file_digests(data),
sha256=sha256,
)
if versions:
context.version_name = _version_label(versions)
context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions)
context.source_model_id, context.source_version_id = _version_identity(
versions
)
return context
def _version_identity(versions: list[dict[str, Any]]) -> tuple[str, str]:
"""Return the site-native ``(model id, version id)`` of the first match.
``modelVersion.modelId`` is identical across every version of one
published model and differs between the models of a collection
repository, which makes it the version-grouping identity;
``modelVersion.id`` identifies the version itself. Both are ints in
the payload and are stored as strings.
"""
for version in versions:
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
continue
model_id = model_version.get("modelId")
version_id = model_version.get("id")
if model_id is None and version_id is None:
continue
return (
str(model_id) if model_id is not None else "",
str(version_id) if version_id is not None else "",
)
return "", ""
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
"""Return the site's own names for the base model.
ModelScope publishes a link-style id (``krea/Krea-2-Turbo``) plus its
internal architecture enums (``VisionFoundation: KREA_2``,
``SubVisionFoundation: KREA_2_TURBO``). The enums are the better
resolution hint because they normalise onto this system's canonical
vocabulary, so they come first; the owner prefix is also stripped from
the link-style ids.
"""
aliases: list[str] = []
for key in ("VisionFoundation", "SubVisionFoundation"):
value = _clean_text(data.get(key))
if value and value not in aliases:
aliases.append(value)
base_models = data.get("BaseModel")
if isinstance(base_models, list):
for item in base_models:
text = _clean_text(item)
leaf = text.rsplit("/", 1)[-1] if text else ""
if leaf and leaf not in aliases:
aliases.append(leaf)
return aliases
def _official_tags(data: dict[str, Any]) -> list[str]:
"""Return the content tags the site publishes for the repository.
``OfficialTags`` is ModelScope's curated content vocabulary and is
preferred whenever it is populated. Plenty of AIGC repositories leave it
empty and carry only the plain ``Tags`` list, which mixes content tags with
framework and task categories; those categories are dropped so a card is
not handed "lora" and "text-to-image" as if they described the model.
"""
curated = _dedupe(_tag_values(data.get("OfficialTags")))
if curated:
return curated
generic = set(_GENERIC_TAGS)
for value in (
data.get("AigcType"),
data.get("Libraries"),
data.get("Frameworks"),
):
for item in value if isinstance(value, list) else [value]:
text = _clean_text(item).lower()
if text:
generic.add(text)
return _dedupe(
tag for tag in _tag_values(data.get("Tags")) if tag.lower() not in generic
)
def _tag_values(value: Any) -> list[str]:
"""Return the tag strings from either shape ModelScope publishes.
``OfficialTags`` is a list of ``{"Tag": ..., "ChineseName": ...}`` dicts
carrying an English value; the plain ``Tags`` list is already strings.
"""
if not isinstance(value, list):
return []
tags: list[str] = []
for entry in value:
tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
if tag:
tags.append(tag)
return tags
def _dedupe(values: Iterable[str]) -> list[str]:
"""Drop empties and repeats, keeping the first spelling seen."""
unique: list[str] = []
for value in values:
if value and value not in unique:
unique.append(value)
return unique
def _version_files(version: dict[str, Any]) -> list[str]:
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
The listing normally sits in ``stats.fileList``; some payloads only
carry the same field as a JSON-encoded string under
``modelVersion.stats``, so both shapes are accepted.
"""
stats = version.get("stats")
files = stats.get("fileList") if isinstance(stats, dict) else None
if not isinstance(files, list):
model_version = version.get("modelVersion")
raw = model_version.get("stats") if isinstance(model_version, dict) else None
if isinstance(raw, str) and raw.strip():
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, TypeError):
decoded = None
if isinstance(decoded, dict):
files = decoded.get("fileList")
if not isinstance(files, list):
return []
return [item for item in files if isinstance(item, str) and item]
def _version_show_name(version: dict[str, Any]) -> str:
"""Return the human-facing version label (e.g. ``c1-st1000``)."""
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
return ""
return _clean_text(model_version.get("showName")).lower()
def _version_label(versions: list[dict[str, Any]]) -> str:
"""Return the first published version label, preserving its spelling.
Unlike :func:`_version_show_name` this is for display, so the label is
not lowercased.
"""
for version in versions:
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
continue
label = _clean_text(model_version.get("showName"))
if label:
return label
return ""
def _file_digests(data: dict[str, Any]) -> dict[str, str]:
"""Return ``basename -> sha256`` for every published weight file.
``ModelInfos`` groups the repository's files by kind (``safetensor``,
…) and records a real sha256 for each, which is what makes it possible to
recognise a file the user has renamed.
"""
digests: dict[str, str] = {}
model_infos = data.get("ModelInfos")
if not isinstance(model_infos, dict):
return digests
for info in model_infos.values():
files = info.get("files") if isinstance(info, dict) else None
if not isinstance(files, list):
continue
for entry in files:
if not isinstance(entry, dict):
continue
name = _clean_text(entry.get("name"))
digest = _clean_text(entry.get("sha256"))
if name and digest:
digests.setdefault(os.path.basename(name).lower(), digest.lower())
return digests
def _matching_versions(
muse_info: Any,
filename: str,
*,
digests: dict[str, str] | None = None,
sha256: str = "",
) -> list[dict[str, Any]]:
"""Return the ``versions`` entries that publish the wanted model file.
Strategies, in order:
1. **sha256** — the file's content hash, looked up through
:func:`_file_digests`. This is the only strategy that survives the
user renaming the weights, which is common once a model is filed away.
2. **Exact basename** against each version's ``stats.fileList``.
3. **``showName`` inside the file stem**, which absorbs the naming drift
ModelScope sometimes applies to uploaded weights.
A known-but-unmatched hash falls through to the filename strategies
rather than giving up, in case the local file was re-encoded. All matches
are returned so a file re-published across several versions contributes
all of its example images. With no *filename* and no *sha256*, only an
unambiguous single-version repository is used, because a per-file image
must never be attributed to the wrong file.
"""
if not isinstance(muse_info, dict):
return []
versions = muse_info.get("versions")
if not isinstance(versions, list):
return []
entries = [entry for entry in versions if isinstance(entry, dict)]
if not entries:
return []
target_hash = (sha256 or "").strip().lower()
if target_hash:
known = digests or {}
by_hash: list[dict[str, Any]] = []
for version in entries:
for path in _version_files(version):
if known.get(os.path.basename(path).lower()) == target_hash:
by_hash.append(version)
break
if by_hash:
return by_hash
if not filename:
return entries if len(entries) == 1 else []
target = os.path.basename(filename).strip().lower()
if not target:
return []
stem = os.path.splitext(target)[0]
exact: list[dict[str, Any]] = []
fuzzy: list[dict[str, Any]] = []
for version in entries:
files = {os.path.basename(path).lower() for path in _version_files(version)}
if target in files:
exact.append(version)
continue
show_name = _version_show_name(version)
if show_name and show_name in stem:
fuzzy.append(version)
return exact or fuzzy
def _cover_image_urls(versions: list[dict[str, Any]]) -> list[str]:
"""Collect the example-image URLs published by the given versions."""
urls: list[str] = []
for version in versions:
covers = version.get("coverImages")
if not isinstance(covers, list):
continue
for cover in covers:
if not isinstance(cover, dict):
continue
url = _clean_text(cover.get("url"))
if url and url not in urls:
urls.append(url)
return urls
def _version_trigger_words(versions: list[dict[str, Any]]) -> list[str]:
"""Return the first non-empty trigger-word list across *versions*."""
for version in versions:
model_version = version.get("modelVersion")
raw = (
model_version.get("triggerWords")
if isinstance(model_version, dict)
else None
)
words = _parse_trigger_words(raw)
if words:
return words
return []
def _parse_trigger_words(raw: Any) -> list[str]:
"""Decode ModelScope's JSON-encoded trigger-word string list."""
if isinstance(raw, list):
candidates = raw
elif isinstance(raw, str) and raw.strip():
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(decoded, list):
return []
candidates = decoded
else:
return []
words: list[str] = []
for item in candidates:
word = _clean_text(item)
if not word or word.lower() in _EMPTY_TRIGGER_VALUES:
continue
if word not in words:
words.append(word)
return words
+210
View File
@@ -0,0 +1,210 @@
"""OpenModelDB model source (upscaler catalogue).
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
models. Unlike the repository-based sources (Hugging Face, ModelScope) a
model id here is a flat token (``4x-UltraSharp``) that *is* the published
model identity: there is no owner/repo split, no revision, and no README.
Everything the source needs — the resource download URLs, sizes, sha256
hashes, tags and example images — comes from the site's bulk JSON dumps via
:class:`~py.services.openmodeldb_client.OpenModelDBClient`, which caches the
catalogue on disk, so every method below is a local lookup once warmed.
Only PyTorch resources (``.pth`` / ``.safetensors``) are listed for download:
``.onnx`` is not a loadable weight format for the supported model types (see
:data:`py.utils.constants.MODEL_FILE_EXTENSIONS`). Resources can carry
mirror URLs; only the primary URL is ever used (see
:meth:`OpenModelDBClient.primary_url`).
"""
from __future__ import annotations
import logging
import os
import re
from typing import Any, Optional
from urllib.parse import urlparse
from .base import (
ModelCardContext,
ModelSource,
ModelSourceCache,
ModelSourceError,
filter_weight_files,
)
from ..openmodeldb_client import OPENMODELDB_SITE_BASE, OpenModelDBClient
logger = logging.getLogger(__name__)
#: Model ids are flat tokens (``4x-UltraSharp``), usable as a path segment.
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
_URL_PATTERN = re.compile(
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)"
)
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)/?$"
)
#: Resource platforms whose files ComfyUI can load.
_DOWNLOADABLE_PLATFORMS = frozenset({"pytorch"})
class OpenModelDBSource(ModelSource):
"""OpenModelDB (``openmodeldb.info``)."""
platform = "openmodeldb"
label = "OpenModelDB"
supports_enrichment = True
supports_download = True
default_revision = ""
default_subdir = "openmodeldb"
example_source_id = "4x-UltraSharp"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
def canonical_url(self, source_id: str) -> str:
return f"{OPENMODELDB_SITE_BASE}/models/{source_id}"
def is_valid_source_id(self, source_id: str) -> bool:
"""OpenModelDB ids are flat tokens, not ``owner/name`` repositories."""
return bool(isinstance(source_id, str) and _SOURCE_ID.match(source_id))
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
"""Flat catalogue: there is no owner/repo split to mirror on disk."""
return (self.default_subdir,)
async def fetch_model_card_context(
self,
source_id: str,
filename: str = "",
*,
sha256: str = "",
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Build the card extras from the cached catalogue entry.
OpenModelDB has no README; the catalogue entry itself carries the
description, license, tags and example images, so the context is the
whole card. The catalogue is bulk-loaded and disk-cached, so no
per-run memo is needed.
"""
try:
client = await OpenModelDBClient.get_instance()
found = await client.get_model_entry(source_id)
except Exception as exc: # never break enrichment on a lookup fault
logger.debug("OpenModelDB context lookup failed for %s: %s", source_id, exc)
return ModelCardContext()
if found is None:
return ModelCardContext()
entry = found[1]
scale = entry.get("scale")
arch_name = client._resolve_architecture_name(entry)
# e.g. "ESRGAN 4x" — closest thing upscalers have to a base model,
# recorded as a hint rather than a canonical base-model name.
base_hint = (
f"{arch_name} {scale}x".strip()
if arch_name and isinstance(scale, (int, float))
else arch_name
)
description = entry.get("description")
return ModelCardContext(
description=description if isinstance(description, str) else "",
model_name=entry.get("name") or source_id,
license=entry.get("license") or "",
model_type="Upscaler",
base_model_aliases=[base_hint] if base_hint else [],
official_tags=client._resolve_tags(entry),
example_images=client.example_image_urls(entry),
source_model_id=source_id,
)
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict[str, Any]]:
"""List the entry's directly downloadable PyTorch resources.
Resources whose only mirrors are HTML-gateway hosts (mediafire,
mega.nz, drive.google.com) are skipped: they serve a web page, not
the file bytes. When every resource is mirror-only this raises a
manual-download hint instead of returning an empty list, which the
download dialog would otherwise misreport as "no model files".
"""
client = await OpenModelDBClient.get_instance()
entry = await self._require_entry(client, source_id)
saw_mirror_only = False
entries = []
for resource in entry.get("resources") or []:
if not isinstance(resource, dict):
continue
if str(resource.get("platform") or "").lower() not in _DOWNLOADABLE_PLATFORMS:
continue
url = client.direct_url(resource)
if not url:
saw_mirror_only = True
continue
size = resource.get("size")
entries.append(
(
client.resource_filename(source_id, resource),
size if isinstance(size, (int, float)) else 0,
)
)
if not entries and saw_mirror_only:
raise ModelSourceError(
f"None of this model's mirrors support direct download; "
f"download it manually from {self.canonical_url(source_id)}",
status=400,
)
return filter_weight_files(entries)
async def resolve_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
"""Resolve the direct download URL of one resource by filename."""
client = await OpenModelDBClient.get_instance()
entry = await self._require_entry(client, source_id)
resource = client.find_resource_by_filename(
source_id, entry, os.path.basename(filename)
)
if resource is None:
raise ModelSourceError(
f"'{filename}' is not a downloadable resource of '{source_id}'",
status=404,
)
url = client.direct_url(resource)
if not url:
host = urlparse(client.primary_url(resource)).netloc or "this mirror"
raise ModelSourceError(
f"This mirror ({host}) requires manual download from "
f"{self.canonical_url(source_id)}",
status=400,
)
return url
async def _require_entry(
self, client: OpenModelDBClient, source_id: str
) -> dict[str, Any]:
"""Return the catalogue entry, raising a mapped error otherwise."""
if not await client.catalogue_ready():
raise ModelSourceError("OpenModelDB catalogue unavailable", status=502)
found = await client.get_model_entry(source_id)
if found is None:
raise ModelSourceError(
f"Model '{source_id}' not found on OpenModelDB", status=404
)
return found[1]
__all__ = ["OpenModelDBSource"]
+235
View File
@@ -0,0 +1,235 @@
"""Registry and metadata helpers for external model sources.
The registry is the single place the rest of the codebase asks "which site
is this URL from?", "what is this model's source?", and "can we enrich it?".
Import from :mod:`py.services.model_sources` rather than this module
directly.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .openmodeldb import OpenModelDBSource
from .tensorart import TensorArtSource
logger = logging.getLogger(__name__)
#: Order matters only for disambiguation; the URL patterns are disjoint.
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
_SOURCES: tuple[ModelSource, ...] = (
HuggingFaceSource(),
ModelScopeSource(),
ModelScopeIntlSource(),
TensorArtSource(),
OpenModelDBSource(),
)
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
#: Metadata keys that carry the canonical external-source identity.
SOURCE_PLATFORM_FIELD = "source_platform"
SOURCE_URL_FIELD = "source_url"
#: Legacy field kept as a read/write alias for Hugging Face models so that
#: older sidecars, cached rows, and third-party consumers keep working.
LEGACY_HF_URL_FIELD = "hf_url"
def list_sources() -> list[ModelSource]:
"""Return every known model source."""
return list(_SOURCES)
def get_source(platform: Optional[str]) -> Optional[ModelSource]:
"""Return the source registered for *platform*, or ``None``."""
if not platform or not isinstance(platform, str):
return None
return _BY_PLATFORM.get(platform.strip().lower())
def source_label(platform: Optional[str], default: str = "") -> str:
"""Return the human-readable label for *platform*."""
source = get_source(platform)
return source.label if source else default
def downloadable_sources() -> list[ModelSource]:
"""Return the sources whose repositories can be downloaded directly."""
return [source for source in _SOURCES if source.supports_download]
def get_download_source(platform: Optional[str]) -> Optional[ModelSource]:
"""Return the source for *platform*, but only when it supports downloads."""
source = get_source(platform)
if source is None or not source.supports_download:
return None
return source
def detect_source(url: Optional[str], *, strict: bool = False) -> Optional[SourceRef]:
"""Return the :class:`SourceRef` for *url*, or ``None`` if unsupported."""
if not url or not isinstance(url, str):
return None
for source in _SOURCES:
ref = source.ref(url, strict=strict)
if ref is not None:
return ref
return None
def resolve_source_ref(metadata: Mapping[str, Any]) -> Optional[SourceRef]:
"""Return the source reference described by a model's metadata.
Handles all three storage states found in the wild:
1. ``source_url`` + ``source_platform`` (current format)
2. ``hf_url`` only (legacy Hugging Face storage)
3. ``hf_url`` plus a newer ``source_url`` (both written by older builds)
"""
if not isinstance(metadata, Mapping):
return None
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
source = get_source(platform)
if url:
if source is not None:
ref = source.ref(url)
if ref is not None:
return ref
ref = detect_source(url)
if ref is not None:
return ref
# Unknown platform but a URL is present: keep it addressable.
return SourceRef(platform=platform or "unknown", source_id="", url=url)
if legacy:
return detect_source(legacy)
return None
def normalize_metadata_source(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""Normalise the external-source fields on *metadata* in place.
Guarantees that ``source_url``/``source_platform`` are present and
consistent, and that ``hf_url`` mirrors ``source_url`` for Hugging Face
models (never for other platforms, so a stale alias can't make a
ModelScope model look like a Hugging Face one).
Returns the same dict for convenient chaining.
"""
if not isinstance(metadata, dict):
return metadata
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
source = get_source(platform)
ref: Optional[SourceRef] = None
if url:
ref = source.ref(url) if source is not None else None
if ref is None:
ref = detect_source(url)
elif legacy:
ref = detect_source(legacy)
if ref is not None and ref.source_id:
platform = ref.platform
url = ref.url or url
if platform:
metadata[SOURCE_PLATFORM_FIELD] = platform
else:
metadata.setdefault(SOURCE_PLATFORM_FIELD, "")
metadata[SOURCE_URL_FIELD] = url
# Keep the legacy alias in sync, but only for Hugging Face.
if url and platform == "huggingface":
metadata[LEGACY_HF_URL_FIELD] = url
elif LEGACY_HF_URL_FIELD in metadata and platform and platform != "huggingface":
metadata[LEGACY_HF_URL_FIELD] = ""
elif legacy and not url:
metadata[LEGACY_HF_URL_FIELD] = legacy
return metadata
def has_external_source(item: Mapping[str, Any]) -> bool:
"""Return ``True`` when *item* is linked to any external model site."""
if not isinstance(item, Mapping):
return False
return bool(
clean_source_url(item.get(SOURCE_URL_FIELD))
or clean_source_url(item.get(LEGACY_HF_URL_FIELD))
)
def get_source_platform(item: Mapping[str, Any]) -> str:
"""Return the platform id stored on *item* (may be empty)."""
if not isinstance(item, Mapping):
return ""
platform = clean_source_url(item.get(SOURCE_PLATFORM_FIELD)).lower()
if platform:
return platform
ref = resolve_source_ref(item)
return ref.platform if ref else ""
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
"""Return the version-group key for *item*, or ``None``.
Only sources with a site-native model identity yield a key: TensorArt
groups by its numeric model id (``ta:<id>``) and ModelScope by the
published-model id recorded at enrichment time (``ms:<id>`` /
``msai:<id>``). Hugging Face yields no key at all — a repository is
not a model identity — and unenriched ModelScope models stay
standalone rather than collapsing a whole collection repository into
one group.
"""
ref = resolve_source_ref(item)
if ref is None or not ref.source_id:
return None
source = get_source(ref.platform)
if source is None:
return None
return source.group_key(ref, item)
__all__ = [
"GROUP_PREFIXES",
"LEGACY_HF_URL_FIELD",
"SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD",
"detect_source",
"downloadable_sources",
"get_download_source",
"get_source",
"get_source_platform",
"has_external_source",
"list_sources",
"normalize_metadata_source",
"resolve_source_ref",
"source_group_key",
"source_label",
]
+57
View File
@@ -0,0 +1,57 @@
"""TensorArt model source (link / provenance only).
TensorArt support is intentionally limited to *linking* a model to its
TensorArt page. Automatic metadata extraction is not possible without a
user session:
* ``tensor.art`` sits behind a Cloudflare managed challenge, so plain
HTTP clients (aiohttp, requests, curl) receive ``403 "Just a moment..."``.
* Its internal API (``ap-east-1.tensorart.cloud`` / ``cn.tensorart.net``)
answers every ``/v1/model/*`` route with
``{"code":100002,"message":"invalid authorization header"}``.
* The official TAMS API requires an AccessKey/SecretKey pair and request
signatures, which is a poor fit for a "paste a URL" workflow.
``supports_enrichment`` is therefore ``False``: the agent pipeline skips
these models with an explicit reason instead of failing silently, and the
UI keeps showing the "View on TensorArt" link. ``tusi.cn`` is TensorArt's
Chinese mirror and is accepted as the same platform.
"""
from __future__ import annotations
import re
from .base import ModelSource
_DOMAINS = r"(?:tensor\.art|tusi\.cn)"
_URL_PATTERN = re.compile(
rf"https?://(?:www\.)?{_DOMAINS}/models/(?P<id>\d+)"
)
_STRICT_URL_PATTERN = re.compile(
rf"https?://(?:www\.)?{_DOMAINS}/models/(?P<id>\d+)(?:/[^/?#\s]+)?/?$"
)
class TensorArtSource(ModelSource):
"""TensorArt (``tensor.art``)."""
platform = "tensorart"
label = "TensorArt"
supports_enrichment = False
supports_download = False
example_source_id = "827823520299086029"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
def canonical_url(self, source_id: str) -> str:
return f"https://tensor.art/models/{source_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str:
# Unreachable today: enrichment is disabled for this platform.
return f"https://tensor.art/models/{source_id}"
__all__ = ["TensorArtSource"]

Some files were not shown because too many files have changed in this diff Show More