Compare commits

...

139 Commits

Author SHA1 Message Date
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
366 changed files with 51656 additions and 6896 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)
+1
View File
@@ -15,6 +15,7 @@ node_modules/
coverage/
.coverage
model_cache/
recipe_cache/
# agent / dev tooling
.opencode/
+42 -1
View File
@@ -166,10 +166,14 @@ The system runs in two modes:
### 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.
- Request handlers in `py/routes/handlers/` implement route logic
- 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
@@ -186,6 +190,17 @@ The system runs in two modes:
- `py/config.py` manages folder paths for models and handles symlink mappings
- 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
@@ -215,6 +230,26 @@ The system runs in two modes:
- 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`
### 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
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
@@ -226,6 +261,12 @@ The system runs in two modes:
## Important Notes
- ALWAYS use English for comments (per copilot-instructions.md)
- **`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`
- Symlinks require normalized paths.
**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
+326 -283
View File
File diff suppressed because it is too large Load Diff
+124 -8
View File
@@ -62,27 +62,143 @@ Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM
### 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 |
`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.
**What it does**:
1. Reads the model's `.metadata.json` to get the `hf_url`
2. Fetches the README.md from the HuggingFace repository
3. Sends the README + local metadata to the LLM for structured extraction
1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
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 + site-provided extras + local metadata to the LLM for structured extraction
4. Writes extracted fields to `.metadata.json`:
- `base_model` — only if current value is empty
- `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
- `civitai.images` — example images
- `metadata_source` — audit trail: `agent:enrich_hf_metadata`
- `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
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
### 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
### 1. Create the skill directory
@@ -129,7 +245,7 @@ Use `{{variable}}` placeholders that will be replaced with data from the `prepar
```markdown
You are an expert assistant...
Model URL: {{hf_url}}
Model URL: {{source_url}}
README content:
{{readme_content}}
+225 -5
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
`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, 2025 leaf keys; all locales share the exact
same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -13,6 +13,70 @@ 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
> (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.
>
> **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").
---
@@ -137,7 +201,7 @@ and must be normalized. `en` = keep the English word as-is.
| 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 |
| 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" |
@@ -193,7 +257,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`) |
| Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence |
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
| recipe counter | 件 or 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
### ko
@@ -222,6 +286,161 @@ and must be normalized. `en` = keep the English word as-is.
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
| 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).
### 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
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{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).
### 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.
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
@@ -312,8 +531,9 @@ blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipe
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)`),
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
(`CivitAI → CivArchive → Archive DB`), and the external playlist title
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
(`CivitAI → CivArchive → Archive DB`), model-type names (`settings.priorityTags.modelTypes.*`,
`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
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
@@ -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.
+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.
+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,92 @@
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
---
## 背景
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
~1400 ms 降到 ~120 ms。
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
因此单独记录,留待 Windows 机器上确认。
---
## 待验证分支(现状)
`py/services/model_scanner.py``_reconcile_cache()` 的 walk 循环内:
```python
# Try case-insensitive match on Windows
if os.name == 'nt':
lower_path = file_path.lower()
matched = False
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
if cached_path.lower() == lower_path:
found_paths.add(cached_path)
matched = True
break
if matched:
continue
```
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
### 为什么可疑
1. **可能不可达**Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
3. **没有测试覆盖**`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
`os.name == 'nt'` 短路,无法覆盖)。
---
## 待办
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
- [ ] **补一个 Windows-only 的回归测试**`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
- [ ] 把验证结论回填到本文件,并同步更新状态行。
---
## 验证方法(Windows
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
2. **看后端日志判据**
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`
且**没有** `Found N new files to process` / `Processing <path>`
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
(缓存路径被当成了新文件 + 旧条目被删)。
3. **跑测试**`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
真实执行 `os.name == 'nt'` 分支)。
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
---
## 已完成(本轮优化,供对照)
同一次清理里已经落地并验证的部分(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`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
47 项、全量后端 2567 项全部通过。
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"reorder": {
"dragHandle": "Zum Neuordnen ziehen"
},
"actions": {
"save": "Speichern",
"cancel": "Abbrechen",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "{type} werden aktualisiert...",
"fullRebuilding": "{type} werden vollständig neu aufgebaut...",
"actionRefresh": "Aktualisierung",
"actionFullRebuild": "Vollständiger Neuaufbau",
"actionRefreshLower": "Aktualisieren",
"actionRebuildLower": "Neuaufbau",
"stages": {
"scan_folders": "Ordner werden gescannt...",
"count_models": "{total} Dateien gefunden",
"process_models": "Modelle werden verarbeitet",
"reconcile_scan": "Änderungen werden geprüft...",
"process_new": "Neue Modelle werden verarbeitet",
"finalizing": "Abschließen..."
},
"eta": {
"lessThanMinute": "Weniger als eine Minute verbleibend",
"minutes": "~{minutes} Min. verbleibend",
"hours": "~{hours} Std. {minutes} Min. verbleibend"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "Massenoperationen",
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken. Wählen Sie mehrere Modelle aus und führen Sie Stapeloperationen durch. Mit <span class=\"onboarding-shortcut\">Strg+A</span> können Sie alle sichtbaren Modelle auswählen."
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken, um mehrere Modelle auszuwählen und Stapeloperationen durchzuführen.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> wählt alle sichtbaren Modelle aus, <span class=\"onboarding-shortcut\">Shift+Click</span> wählt einen Bereich aus.<br>• <span class=\"onboarding-shortcut\">Esc</span> oder ein Klick auf einen leeren Bereich verlässt den Massenmodus."
},
"searchOptions": {
"title": "Suchoptionen",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "Kontextmenü",
"content": "<strong>Rechtsklick</strong> auf eine Modellkarte öffnet ein Kontextmenü mit weiteren Aktionen."
"content": "<strong>Rechtsklick</strong> auf eine beliebige Modellkarte öffnet ein Kontextmenü mit Kartenaktionen wie Verschieben, Löschen oder Bearbeiten von Metadaten."
},
"marqueeSelect": {
"title": "Durch Ziehen auswählen",
"content": "Halten Sie die <strong>linke Maustaste</strong> auf einem leeren Bereich des Rasters gedrückt und ziehen Sie, um einen Auswahlrahmen aufzuziehen, der mehrere Karten gleichzeitig auswählt."
},
"dragToSidebar": {
"title": "Organisieren durch Ziehen",
"content": "Ziehen Sie eine Modellkarte auf einen Ordner in der Seitenleiste, um die Datei dorthin zu verschieben. Dies funktioniert auch mit mehreren ausgewählten Karten im Massenmodus."
},
"contextMenus": {
"title": "Weitere Kontextmenüs",
"content": "<strong>Rechtsklick auf eine ausgewählte Karte</strong> im Massenmodus öffnet die Massenaktionen. <strong>Rechtsklick auf einen leeren Bereich</strong> der Seite öffnet globale Aktionen wie das Prüfen auf Updates und das Verwalten ausgeschlossener Modelle."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "Auf CivitAI anzeigen",
"notAvailableFromCivitai": "Nicht auf CivitAI verfügbar",
"viewOnHuggingFace": "Auf Hugging Face ansehen",
"viewOnSource": "Auf {source} ansehen",
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
"copyLoRASyntax": "LoRA-Syntax kopieren",
"checkpointNameCopied": "Checkpoint-Name kopiert",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren",
"embeddingNameCopied": "Embedding-Syntax kopiert",
"modelNameCopied": "Modellname kopiert",
"sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden"
},
@@ -179,20 +217,10 @@
"none": "Alle {typePlural} verfügen bereits über Lizenzmetadaten",
"error": "Lizenzmetadaten für {typePlural} konnten nicht aktualisiert werden: {message}"
},
"repairRecipes": {
"label": "Rezept-Daten reparieren",
"loading": "Rezept-Daten werden repariert...",
"success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Rezept-Reparatur fehlgeschlagen: {message}"
},
"rematchRecipes": {
"label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
},
@@ -210,6 +238,7 @@
"recipes": "Rezepte",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Andere",
"statistics": "Statistiken"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "Allgemein",
"interface": "Oberfläche",
"library": "Bibliothek"
"library": "Bibliothek",
"organization": "Organisation",
"modelPaths": "Modellpfade"
},
"search": {
"placeholder": "Einstellungen durchsuchen...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes CivitAI-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
"stickyControls": "Aktionsleiste sichtbar halten",
"stickyControlsHelp": "Wenn aktiviert, bleibt die Aktionsleiste (Aktualisieren, Herunterladen usw.) beim Scrollen zusammen mit der Breadcrumb-Navigation oben angeheftet.",
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
"default": "Standard",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultEmbeddingRoot": "Embedding-Stammordner",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultVaeRoot": "VAE-Stammordner",
"defaultVaeRootHelp": "Legen Sie den Standard-VAE-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultUpscalerRoot": "Upscaler-Stammordner",
"defaultUpscalerRootHelp": "Legen Sie den Standard-Upscaler-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultTextEncoderRoot": "Text-Encoder-Stammordner",
"defaultTextEncoderRootHelp": "Legen Sie den Standard-Text-Encoder-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultClipVisionRoot": "CLIP-Vision-Stammordner",
"defaultClipVisionRootHelp": "Legen Sie den Standard-CLIP-Vision-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultControlnetRoot": "ControlNet-Stammordner",
"defaultControlnetRootHelp": "Legen Sie den Standard-ControlNet-Stammordner für Downloads, Importe und Verschiebungen fest",
"enableOtherModels": "Verwaltung weiterer Modelle",
"enableOtherModelsHelp": "Wenn deaktiviert, werden VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Ordner nicht gescannt, die Seite für weitere Modelle bleibt deaktiviert und diese Modelltypen können nicht heruntergeladen werden.",
"otherSubTypes": "Verwaltete Modelltypen",
"otherSubTypesHelp": "Wählen Sie, welche Kategorien weiterer Modelle gescannt und auf der Seite für weitere Modelle angezeigt werden.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Rezepte-Speicherpfad",
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
}
},
"modelPaths": {
"title": "Modellbibliothek-Pfade",
"description": "Stammordner, die LoRA Manager nach Ihren Modellen durchsucht. Dies sind die primären Modellspeicherorte, die im Standalone-Modus aus der settings.json gelesen werden.",
"restartRequired": "Neustart erforderlich, damit die Änderung wirksam wird",
"coreTypes": "Kern-Modelltypen",
"otherTypes": "Weitere Modelltypen",
"otherTypesDisabledHint": "Es sind keine weiteren Modelltypen aktiviert. Aktivieren Sie oben die benötigten Typen, um deren Ordner zu konfigurieren.",
"saveSuccessRestart": "Modellbibliothek-Pfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"pendingRestartNotice": "Pfadänderungen gespeichert. Starten Sie LoRA Manager neu, damit sie wirksam werden.",
"pendingRestartBannerTitle": "Neustart erforderlich, um Pfadänderungen anzuwenden",
"pendingRestartBannerMessage": "Die Modellbibliothek-Pfade wurden aktualisiert. Starten Sie den LoRA Manager-Server neu, um die neuen Ordner zu scannen.",
"folderKeys": {
"loras": "LoRA-Pfade",
"checkpoints": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embeddings": "Embedding-Pfade",
"vae": "VAE-Pfade",
"upscale_models": "Upscaler-Pfade",
"text_encoders": "Text-Encoder-Pfade",
"clip": "CLIP-Pfade (Legacy)",
"clip_vision": "CLIP-Vision-Pfade",
"controlnet": "ControlNet-Pfade"
}
},
"directoryPicker": {
"title": "Ordner durchsuchen",
"selectFolder": "Diesen Ordner auswählen",
"goUp": "Nach oben",
"pathPlaceholder": "Pfad eingeben...",
"go": "Los",
"emptyFolder": "Keine Unterordner",
"loadError": "Verzeichnis konnte nicht geladen werden"
},
"pathValidation": {
"valid": "Pfad ist gültig",
"pathNotFound": "Pfad existiert nicht",
"notADirectory": "Kein Verzeichnis",
"notReadable": "Pfad ist nicht lesbar",
"notWritable": "Pfad ist nicht beschreibbar"
},
"priorityTags": {
"title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "Gültige Vorlage"
}
},
"filenameTemplates": {
"title": "Dateinamen-Vorlagen",
"help": "Konfigurieren Sie Dateinamen für heruntergeladene Modelle pro Modelltyp. Leer lassen, um den ursprünglichen Dateinamen zu behalten. Der ursprüngliche Dateiname bleibt immer in den Metadaten des Modells erhalten.",
"availablePlaceholders": "Verfügbare Platzhalter:",
"templatePlaceholder": "Dateinamen-Vorlage eingeben (z.B. {base_model}-{model_name}-{version_name})",
"applyButton": "Jetzt auf Bibliothek anwenden",
"applyHelp": "Benennt alle vorhandenen Dateien dieses Modelltyps gemäß der Vorlage um. Warnung: Das Umbenennen ändert den relativen Pfad, den ComfyUI-Loader sehen; vorhandene Workflows, die den alten Dateinamen referenzieren, müssen möglicherweise aktualisiert werden. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
"confirmApply": "Alle vorhandenen Dateien dieses Modelltyps gemäß der Dateinamen-Vorlage umbenennen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
"confirmRevert": "Die gespeicherten ursprünglichen Dateinamen aller zuvor umbenannten Dateien dieses Modelltyps wiederherstellen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Dateien ohne gespeicherten ursprünglichen Dateinamen werden übersprungen.",
"validation": {
"restoreOriginal": "Gültig (leere Vorlage stellt ursprüngliche Dateinamen wieder her)",
"invalidChars": "Ungültige Zeichen erkannt (ein Dateiname darf / \\ < > : \" | ? * nicht enthalten)",
"invalidPlaceholder": "Ungültiger Platzhalter: {placeholder}",
"validTemplate": "Gültige Vorlage"
}
},
"exampleImages": {
"downloadLocation": "Download-Speicherort",
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
@@ -786,7 +894,6 @@
"setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen",
@@ -822,14 +929,22 @@
"complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}"
},
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
"filenameTemplateProgress": {
"initializing": "Anwendung der Dateinamen-Vorlage wird initialisiert...",
"starting": "Dateinamen-Vorlage wird auf {type} angewendet...",
"processing": "Verarbeitung ({processed}/{total}) {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
"completed": "Abgeschlossen: {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
"complete": "Anwendung der Dateinamen-Vorlage abgeschlossen",
"error": "Fehler: {error}"
},
"enrichHfAgent": "Metadaten mit KI anreichern"
},
"contextMenu": {
"refreshMetadata": "CivitAI-Daten aktualisieren",
"checkUpdates": "Updates prüfen",
"linkModel": "Modell verknüpfen",
"linkCivitai": "Mit CivitAI neu verknüpfen",
"linkHuggingFace": "Mit HuggingFace verknüpfen",
"linkModelSource": "Mit Modellquelle verknüpfen",
"copySyntax": "LoRA-Syntax kopieren",
"copyFilename": "Modell-Dateiname kopieren",
"copyRecipeSyntax": "Rezept-Syntax kopieren",
@@ -842,7 +957,6 @@
"replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"rematchMetadata": "Mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen",
@@ -852,7 +966,7 @@
"viewAllLoras": "Alle LoRAs anzeigen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"deleteRecipe": "Rezept löschen",
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
"enrichHfAgent": "Metadaten mit KI anreichern"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "Vorheriges Rezept (←)",
"nextWithShortcut": "Nächstes Rezept (→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "Basismodell",
"unknown": "Unbekannt"
},
"actions": {
"openFileLocation": "Dateispeicherort öffnen",
"copyId": "Rezept-ID kopieren"
},
"openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet",
"failed": "Fehler beim Öffnen des Dateispeicherorts",
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Workflow an ComfyUI senden",
"sent": "Workflow an ComfyUI gesendet",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
},
"repair": {
"starting": "Rezept-Metadaten werden repariert...",
"success": "Rezept-Metadaten erfolgreich repariert",
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
},
"reimport": {
"starting": "Rezept wird aus Quelle neu importiert...",
"success": "Rezept erfolgreich neu importiert",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Embedding-Modelle"
},
"other": {
"title": "Weitere Modelle",
"disabled": {
"title": "Die Verwaltung weiterer Modelle ist deaktiviert",
"description": "Aktivieren Sie die Option, um VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien zu scannen und zu verwalten und sie von CivitAI herunterzuladen.",
"enableButton": "Weitere Modelle aktivieren",
"hint": "Sie können die verwalteten Modelltypen später unter Einstellungen > Bibliothek ändern.",
"enableFailed": "Aktivierung weiterer Modelle fehlgeschlagen",
"downloadBlocked": "Die Verwaltung weiterer Modelle ist für diesen Modelltyp deaktiviert. Aktivieren Sie sie unter Einstellungen > Bibliothek, um diese Datei herunterzuladen.",
"enableAction": "Weitere Modelle aktivieren"
},
"noPaths": {
"title": "Keine Ordner für weitere Modelle gefunden",
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie Ihre Modellordner unter Einstellungen → Modellpfade hinzu und starten Sie LoRA Manager anschließend neu.",
"hintStandalone": "Es werden nur aktivierte Modelltypen gescannt. Aktivieren Sie die benötigten Typen unter Bibliothek → Standard-Roots.",
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
"openSettings": "Einstellungen öffnen",
"openModelPaths": "Modellordner konfigurieren",
"openSettingsFolder": "Einstellungsordner öffnen"
}
},
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
"collapseAllDisabled": "In der Listenansicht nicht verfügbar",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln",
"viewOptions": "Ansichtsoptionen",
"treeView": "Baumansicht",
"listView": "Listenansicht",
"recursiveOn": "Unterordner einbeziehen",
"recursiveOff": "Nur aktueller Ordner",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"createFolder": "Neuer Ordner",
"newSubfolder": "Neuer Unterordner",
"showEmptyFolders": "Leere Ordner anzeigen",
"createFolderResult": {
"success": "Ordner \"{name}\" erstellt",
"failed": "Ordner konnte nicht erstellt werden: {message}",
"unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"deleteFolder": "Ordner löschen",
"deleteFolderModal": {
"title": "Ordner löschen?",
"message": "Der Ordner und sein gesamter Inhalt werden endgültig vom Datenträger gelöscht.",
"folderLabel": "Ordner",
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
"notEmptyTitle": "Ordner ist nicht leer",
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"confirm": "Ordner löschen"
},
"deleteFolderResult": {
"success": "Ordner \"{name}\" gelöscht",
"successWithFiles": "Ordner \"{name}\" sowie {count} weitere(s) Element(e) gelöscht",
"restored": "Ordner wiederhergestellt",
"failed": "Ordner konnte nicht gelöscht werden: {message}",
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"renameFolder": "Ordner umbenennen",
"renameFolderResult": {
"success": "Ordner umbenannt in \"{name}\"",
"failed": "Ordner konnte nicht umbenannt werden: {message}",
"targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
},
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
"createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
},
"folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen",
@@ -1322,9 +1503,9 @@
"download": {
"title": "Modell von URL herunterladen",
"titleWithType": "{type} von URL herunterladen",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "Modell-URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"urlHint": "Geben Sie eine CivitAI-, CivArchive-, Hugging Face- oder ModelScope-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
"selectAll": "Alle auswählen",
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
@@ -1357,9 +1538,9 @@
"inLibrary": "In Bibliothek"
},
"errors": {
"invalidUrl": "Ungültiges CivitAI URL-Format",
"invalidUrl": "Ungültiges Modell-URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar",
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
"mixedSources": "CivitAI- und Hugging Face-/ModelScope-URLs können nicht in derselben Charge gemischt werden.",
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "Aktuelle Datei:",
"downloading": "Wird heruntergeladen: {name}",
"metadata": "Metadaten: {name}",
"indexingFile": "Modelldatei wird gelesen...",
"fetchingSourceMetadata": "Metadaten werden von {source} abgerufen...",
"fetchingMetadata": "Metadaten werden abgerufen...",
"transferred": "Heruntergeladen: {downloaded} / {total}",
"transferredSimple": "Heruntergeladen: {downloaded}",
"transferredUnknown": "Heruntergeladen: --",
@@ -1441,6 +1626,11 @@
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
"action": "Alles prüfen"
},
"filenameTemplateConfirm": {
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
},
"bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen",
"description": "Tags hinzufügen zu",
@@ -1467,6 +1657,41 @@
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"rematchOptions": {
"title": "Rezepte neu zuordnen",
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
"confirmButton": "Neu zuordnen"
},
"rematchResults": {
"undo": "Rückgängig",
"undone": "Rückgängig gemacht",
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
},
"rematchSummary": {
"title": "Zusammenfassung der Neuordnung",
"successMessage": "{entries} Einträge zugeordnet",
"failed": "Neuordnung fehlgeschlagen",
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
"statMatched": "Zugeordnete Einträge",
"statReview": "Zu überprüfen",
"statUnresolved": "Nicht zugeordnet",
"statErrors": "Fehler",
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
"columnRecipe": "Rezept",
"columnEntry": "Eintrag",
"columnFile": "Zugeordnete Datei",
"columnUndo": "Rückgängig",
"copyReport": "Bericht kopieren",
"close": "Schließen",
"scope_global": "Alle Rezepte",
"scope_bulk": "Ausgewählte Rezepte",
"scope_single": "Einzelnes Rezept"
},
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
"root": "Stammverzeichnis"
},
"linkHuggingFace": {
"title": "Mit HuggingFace verknüpfen",
"infoText": "Fügen Sie die HuggingFace-Repository-URL ein, um dieses Modell zuzuordnen. Dies ermöglicht die KI-gestützte Metadatenanreicherung.",
"urlLabel": "HuggingFace-Repository-URL:",
"linkModelSource": {
"title": "Mit Modellquelle verknüpfen",
"infoText": "Fügen Sie die URL der Modellseite ein, um dieses Modell seiner Quelle zuzuordnen. Die Verknüpfung ermöglicht die KI-gestützte Metadatenanreicherung für Modelle von Hugging Face und ModelScope.",
"urlLabel": "URL der Modellseite:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
"helpText": "Geben Sie die vollständige URL der Modellseite ein. Unterstützte Websites:",
"enrichNote": "Die KI-Anreicherung benötigt eine lesbare Modellkarte. Websites, die keine bereitstellen (derzeit TensorArt), können nur verknüpft werden.",
"urlRequired": "Bitte geben Sie die URL der Modellseite ein.",
"invalidUrl": "Nicht unterstützte URL. Unterstützte Websites: Hugging Face, ModelScope, TensorArt.",
"linking": "Modellquelle wird verknüpft...",
"confirmAction": "Speichern & Verknüpfen"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Wert",
"add": "Hinzufügen",
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y",
"invalidValue": "Bitte geben Sie eine gültige Zahl ein",
"saveFailed": "Fehler beim Speichern des voreingestellten Parameters",
"added": "Voreingestellter Parameter hinzugefügt",
"updated": "Voreingestellter Parameter aktualisiert"
},
"triggerWords": {
"label": "Trigger Words",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren",
"copyOrEditWord": "Klicken zum Kopieren, Doppelklick zum Bearbeiten",
"deleteWord": "Trigger Word löschen",
"suggestions": {
"noSuggestions": "Keine Vorschläge verfügbar",
@@ -1649,8 +1882,8 @@
"showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel",
"nextExample": "Nächstes Beispiel",
"previousExample": "Vorheriges Beispiel ([)",
"nextExample": "Nächstes Beispiel (])",
"noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
@@ -1736,7 +1969,7 @@
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
"error": "Versionen konnten nicht geladen werden.",
"missingModelId": "Für dieses Modell ist keine CivitAI-Model-ID vorhanden.",
"hfGroupInfo": "Dies ist eine HuggingFace-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
"sourceGroupInfo": "Dies ist eine {source}-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
"confirm": {
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
},
@@ -1808,6 +2041,10 @@
"title": "Embedding Manager wird initialisiert",
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
},
"other": {
"title": "Manager für weitere Modelle wird initialisiert",
"message": "Modell-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
},
"recipes": {
"title": "Rezept Manager wird initialisiert",
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "Erste Schritte",
"updateVlogs": "Update-Vlogs",
"documentation": "Dokumentation"
"documentation": "Dokumentation",
"shortcuts": "Tastenkürzel"
},
"gettingStarted": {
"title": "Erste Schritte mit LoRA Manager"
"title": "Erste Schritte mit LoRA Manager",
"replayTutorial": "Tutorial erneut abspielen"
},
"shortcuts": {
"title": "Tastatur- & Mauskürzel",
"groups": {
"general": "Allgemein",
"actions": "Aktionen",
"selection": "Auswahl & Massenmodus",
"navigation": "Navigation",
"modelModal": "Modell- / Rezept-Dialog",
"mediaViewer": "Medienanzeige / Beispielgalerie"
},
"keys": {
"click": "Klick",
"drag": "Ziehen",
"rightClick": "Rechtsklick",
"letter": "Buchstabe",
"swipe": "Wischen"
},
"entries": {
"focusSearch": "Suche fokussieren",
"closeModal": "Dialog / Panel schließen",
"openShortcuts": "Dieses Tastenkürzel-Panel öffnen",
"refresh": "Modellliste aktualisieren",
"fetchMetadata": "Metadaten von CivitAI abrufen (nur Modellseiten)",
"downloadModel": "Ein Modell herunterladen (nur Modellseiten)",
"toggleBulkMode": "Massenmodus umschalten",
"selectAll": "Alle sichtbaren Modelle auswählen",
"rangeSelect": "Bereich auswählen",
"marqueeSelect": "Karten mit Auswahlrahmen auswählen (auf leerem Rasterbereich)",
"exitBulkMode": "Massenmodus verlassen",
"bulkActions": "Auf ausgewählter Karte: Menü für Massenaktionen",
"globalActions": "Auf leerem Seitenbereich: Menü für globale Aktionen (Updates prüfen, ausgeschlossene Modelle verwalten)",
"scrollPages": "Seiten scrollen",
"jumpAlphabet": "Zur Alphabetleiste springen",
"prevNext": "Vorheriges / nächstes Modell",
"deleteEntry": "Löschen",
"cycleMedia": "Medien durchblättern ([ / ] in der Beispielgalerie)",
"swipeTouch": "Medien auf Touch-Geräten durchblättern",
"closeViewer": "Medienanzeige schließen"
}
},
"updateVlogs": {
"title": "Neueste Updates",
@@ -1934,7 +2213,8 @@
"settings": "Einstellungen & Konfiguration",
"extensions": "Erweiterungen",
"newBadge": "NEU"
}
},
"newContentBadge": "NEU"
},
"update": {
"title": "Nach Updates suchen",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
"filenameTemplateSuccess": "Dateinamen-Vorlage erfolgreich für {count} {type} angewendet",
"filenameTemplatePartialSuccess": "Dateinamen-Vorlage angewendet: {success} umbenannt, {failures} von {total} Modellen fehlgeschlagen",
"filenameTemplateFailed": "Anwendung der Dateinamen-Vorlage fehlgeschlagen: {error}",
"noModelsSelected": "Keine Modelle ausgewählt"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
"otherRootsFailed": "Fehler beim Laden der Stammverzeichnisse weiterer Modelle: {message}",
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
"filenameTemplatesUpdated": "Dateinamen-Vorlagen aktualisiert",
"filenameTemplatesFailed": "Dateinamen-Vorlagen konnten nicht gespeichert werden: {message}",
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "Modell erfolgreich über CivitArchive neu verknüpft",
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
"missingHash": "Modell-Hash nicht verfügbar"
"missingHash": "Modell-Hash nicht verfügbar",
"enrichNeedsSource": "Verknüpfen Sie dieses Modell zuerst mit einer Modellquelle (Modell verknüpfen → Mit Modellquelle verknüpfen)",
"enrichUnsupportedSource": "Die KI-Anreicherung ist für {source}-Modelle nicht verfügbar"
},
"exampleImages": {
"pathUpdated": "Beispielbilder-Pfad erfolgreich aktualisiert",
@@ -2487,6 +2770,17 @@
"rebuilding": "Cache wird neu aufgebaut...",
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
"retry": "Wiederholen"
},
"otherModels": {
"title": "Die Verwaltung weiterer Modelle ist verfügbar",
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
"enable": "Weitere Modelle aktivieren",
"openSettings": "Einstellungen öffnen"
},
"pager": {
"previous": "Vorherige Mitteilung",
"next": "Nächste Mitteilung",
"position": "Mitteilung {current} von {total}"
}
}
}
+353 -59
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
"reorder": {
"dragHandle": "Drag to reorder"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "Refreshing {type}s...",
"fullRebuilding": "Full rebuild {type}s...",
"actionRefresh": "Refresh",
"actionFullRebuild": "Full rebuild",
"actionRefreshLower": "refresh",
"actionRebuildLower": "rebuild",
"stages": {
"scan_folders": "Scanning folders...",
"count_models": "Found {total} files",
"process_models": "Processing models",
"reconcile_scan": "Checking for changes...",
"process_new": "Processing new models",
"finalizing": "Finalizing..."
},
"eta": {
"lessThanMinute": "Less than a minute remaining",
"minutes": "~{minutes} min remaining",
"hours": "~{hours} hr {minutes} min remaining"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "Bulk Operations",
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span>. Select multiple models and perform batch operations. Use <span class=\"onboarding-shortcut\">Ctrl+A</span> to select all visible models."
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span> to select multiple models and perform batch operations.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> select all visible models, <span class=\"onboarding-shortcut\">Shift+Click</span> select a range.<br>• <span class=\"onboarding-shortcut\">Esc</span> or clicking an empty area exits bulk mode."
},
"searchOptions": {
"title": "Search Options",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "Context Menu",
"content": "<strong>Right-click</strong> any model card for a context menu with additional actions."
"content": "<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata."
},
"marqueeSelect": {
"title": "Drag to Select",
"content": "Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once."
},
"dragToSidebar": {
"title": "Organize by Dragging",
"content": "Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode."
},
"contextMenus": {
"title": "More Context Menus",
"content": "In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "View on CivitAI",
"notAvailableFromCivitai": "Not available from CivitAI",
"viewOnHuggingFace": "View on Hugging Face",
"viewOnSource": "View on {source}",
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
"copyLoRASyntax": "Copy LoRA Syntax",
"checkpointNameCopied": "Checkpoint name copied",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name",
"embeddingNameCopied": "Embedding syntax copied",
"modelNameCopied": "Model name copied",
"sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Repair recipes data",
"loading": "Repairing recipe data...",
"success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}"
},
"rematchRecipes": {
"label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...",
"success": "Matched {entries} entries across {recipes} recipes",
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"allFailed": "Rematch failed for {failures} of {total} recipes",
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
"error": "Recipe rematch failed: {message}"
},
@@ -210,6 +238,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Other",
"statistics": "Stats"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "General",
"interface": "Interface",
"library": "Library"
"library": "Library",
"organization": "Organization",
"modelPaths": "Model Paths"
},
"search": {
"placeholder": "Search settings...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each CivitAI model is shown as a single card. Older versions are hidden.",
"stickyControls": "Keep Action Bar Visible",
"stickyControlsHelp": "When enabled, the action bar (Refresh, Download, etc.) stays pinned at the top while scrolling, together with the breadcrumb navigation.",
"displayDensity": "Display Density",
"displayDensityOptions": {
"default": "Default",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
"defaultEmbeddingRoot": "Embedding Root",
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
"defaultVaeRoot": "VAE Root",
"defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves",
"defaultUpscalerRoot": "Upscaler Root",
"defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves",
"defaultTextEncoderRoot": "Text Encoder Root",
"defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves",
"defaultClipVisionRoot": "CLIP Vision Root",
"defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "ControlNet Root",
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "Other Models Management",
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP vision / ControlNet folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "Managed Types",
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Recipes Storage Path",
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
}
},
"modelPaths": {
"title": "Model Library Paths",
"description": "Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.",
"restartRequired": "Requires restart to take effect",
"coreTypes": "Core Model Types",
"otherTypes": "Other Model Types",
"otherTypesDisabledHint": "No other model types are enabled. Turn on the types you need above to configure their folders.",
"saveSuccessRestart": "Model library paths updated. Restart required to apply changes.",
"pendingRestartNotice": "Path changes saved. Restart LoRA Manager for them to take effect.",
"pendingRestartBannerTitle": "Restart required to apply path changes",
"pendingRestartBannerMessage": "Model library paths were updated. Restart the LoRA Manager server to scan the new folders.",
"folderKeys": {
"loras": "LoRA Paths",
"checkpoints": "Checkpoint Paths",
"unet": "Diffusion Model Paths",
"embeddings": "Embedding Paths",
"vae": "VAE Paths",
"upscale_models": "Upscaler Paths",
"text_encoders": "Text Encoder Paths",
"clip": "CLIP Paths (legacy)",
"clip_vision": "CLIP Vision Paths",
"controlnet": "ControlNet Paths"
}
},
"directoryPicker": {
"title": "Browse Folders",
"selectFolder": "Select This Folder",
"goUp": "Up",
"pathPlaceholder": "Enter path...",
"go": "Go",
"emptyFolder": "No subfolders",
"loadError": "Failed to load directory"
},
"pathValidation": {
"valid": "Path is valid",
"pathNotFound": "Path does not exist",
"notADirectory": "Not a directory",
"notReadable": "Path is not readable",
"notWritable": "Path is not writable"
},
"priorityTags": {
"title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "Valid template"
}
},
"filenameTemplates": {
"title": "Filename Templates",
"help": "Configure filenames for downloaded models per model type. Leave empty to keep original filenames on download; applying an empty template restores the recorded original filenames of previously renamed models. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "Available placeholders:",
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "Apply to Library Now",
"applyHelp": "Renames all existing files of this model type according to the template; with an empty template, restores the recorded original filenames instead. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"confirmRevert": "Restore the recorded original filenames of all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.",
"validation": {
"restoreOriginal": "Valid (empty template restores original filenames)",
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
"validTemplate": "Valid template"
}
},
"exampleImages": {
"downloadLocation": "Download Location",
"downloadLocationPlaceholder": "Enter folder path for example images",
@@ -786,7 +894,6 @@
"setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"rematchMetadata": "Rematch Selected to Local Models",
"reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected",
@@ -822,14 +929,22 @@
"complete": "Auto-organize complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich HF Metadata (AI)"
"filenameTemplateProgress": {
"initializing": "Initializing filename template apply...",
"starting": "Applying filename template to {type}...",
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "Filename template apply complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich Metadata with AI"
},
"contextMenu": {
"refreshMetadata": "Refresh CivitAI Data",
"checkUpdates": "Check Updates",
"linkModel": "Link Model",
"linkCivitai": "Link to CivitAI",
"linkHuggingFace": "Link to HuggingFace",
"linkModelSource": "Link to Model Source",
"copySyntax": "Copy LoRA Syntax",
"copyFilename": "Copy Model Filename",
"copyRecipeSyntax": "Copy Recipe Syntax",
@@ -842,7 +957,6 @@
"replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata",
"rematchMetadata": "Rematch to local models",
"reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model",
@@ -852,7 +966,7 @@
"viewAllLoras": "View All LoRAs",
"downloadMissingLoras": "Download Missing LoRAs",
"deleteRecipe": "Delete Recipe",
"enrichHfAgent": "Enrich HF Metadata (AI)"
"enrichHfAgent": "Enrich Metadata with AI"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "Previous recipe (←)",
"nextWithShortcut": "Next recipe (→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "Base Model",
"unknown": "Unknown"
},
"actions": {
"openFileLocation": "Open File Location",
"copyId": "Copy recipe ID"
},
"openFileLocation": {
"success": "File location opened successfully",
"failed": "Failed to open file location",
"copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Send Workflow to ComfyUI",
"sent": "Workflow sent to ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "Failed to get information for missing LoRAs",
"prepareError": "Error preparing LoRAs for download: {message}"
},
"repair": {
"starting": "Repairing recipe metadata...",
"success": "Recipe metadata repaired successfully",
"skipped": "Recipe already at latest version, no repair needed",
"failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID"
},
"reimport": {
"starting": "Re-importing recipe from source...",
"success": "Recipe re-imported successfully",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Embedding Models"
},
"other": {
"title": "Other Models",
"disabled": {
"title": "Other Models management is off",
"description": "Enable it to scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files, and to download them from CivitAI.",
"enableButton": "Enable Other Models",
"hint": "You can change the managed model types later in Settings > Library.",
"enableFailed": "Failed to enable Other Models",
"downloadBlocked": "Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "Enable Other Models"
},
"noPaths": {
"title": "No other-model folders found",
"descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.",
"hintStandalone": "Only enabled model types are scanned; enable the types you need under Library → Folder Settings.",
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
"openSettings": "Open Settings",
"openModelPaths": "Configure Model Folders",
"openSettingsFolder": "Open Settings Folder"
}
},
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
"collapseAllDisabled": "Not available in list view",
"hideOnThisPage": "Hide sidebar on this page",
"showSidebar": "Show sidebar",
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
"switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View",
"viewOptions": "View options",
"treeView": "Tree view",
"listView": "List view",
"recursiveOn": "Include subfolders",
"recursiveOff": "Current folder only",
"recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view",
"createFolder": "New folder",
"newSubfolder": "New subfolder",
"showEmptyFolders": "Show empty folders",
"createFolderResult": {
"success": "Folder \"{name}\" created",
"failed": "Failed to create folder: {message}",
"unsupported": "Folder creation is not supported on this page",
"noRoot": "No model root is configured"
},
"deleteFolder": "Delete folder",
"deleteFolderModal": {
"title": "Delete folder?",
"message": "The folder and everything inside it will be permanently removed from disk.",
"folderLabel": "Folder",
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
"notEmptyTitle": "Folder is not empty",
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
"confirm": "Delete folder"
},
"deleteFolderResult": {
"success": "Folder \"{name}\" deleted",
"successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)",
"restored": "Folder restored",
"failed": "Failed to delete folder: {message}",
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder deletion is not supported on this page",
"noRoot": "No model root is configured"
},
"renameFolder": "Rename folder",
"renameFolderResult": {
"success": "Folder renamed to \"{name}\"",
"failed": "Failed to rename folder: {message}",
"targetExists": "A folder with that name already exists here",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder renaming is not supported on this page",
"noRoot": "No model root is configured"
},
"dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found"
},
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders"
"createHint": "Click the New Folder button above to create folders"
},
"folderUpdateCheck": {
"label": "Check for updates in this folder",
@@ -1322,9 +1503,9 @@
"download": {
"title": "Download Model from URL",
"titleWithType": "Download {type} from URL",
"civitaiUrl": "CivitAI URL(s):",
"civitaiUrl": "Model URL(s):",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
"urlHint": "Enter one CivitAI, CivArchive, Hugging Face, or ModelScope URL per line. Supports multiple URLs for batch download.",
"selectHfFiles": "Select file(s) to download from this repository:",
"selectAll": "Select All",
"fetchingRepoFiles": "Fetching repository files...",
@@ -1357,9 +1538,9 @@
"inLibrary": "In Library"
},
"errors": {
"invalidUrl": "Invalid CivitAI URL format",
"invalidUrl": "Invalid model URL format",
"noVersions": "No versions available for this model",
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
"mixedSources": "Cannot mix CivitAI and Hugging Face / ModelScope URLs in the same batch.",
"noModelFiles": "No model files found in this repository."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "Current file:",
"downloading": "Downloading: {name}",
"metadata": "Metadata: {name}",
"indexingFile": "Reading model file...",
"fetchingSourceMetadata": "Fetching metadata from {source}...",
"fetchingMetadata": "Fetching metadata...",
"transferred": "Transferred: {downloaded} / {total}",
"transferredSimple": "Transferred: {downloaded}",
"transferredUnknown": "Transferred: --",
@@ -1441,6 +1626,11 @@
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
"action": "Check All"
},
"filenameTemplateConfirm": {
"titleApply": "Apply filename template to library?",
"titleRevert": "Restore original filenames?",
"revertButton": "Restore Original Filenames"
},
"bulkAddTags": {
"title": "Add Tags to Multiple Models",
"description": "Add tags to",
@@ -1467,6 +1657,41 @@
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"rematchOptions": {
"title": "Rematch Recipes",
"messageGlobal": "All recipes will be scanned against your local model library.",
"messageSingle": "This recipe will be scanned against your local model library.",
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "Also reconnect missing models by file name",
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
"confirmButton": "Rematch"
},
"rematchResults": {
"undo": "Undo",
"undone": "Undone",
"undoFailed": "Failed to undo rematch: {message}"
},
"rematchSummary": {
"title": "Rematch Summary",
"successMessage": "Matched {entries} entries",
"failed": "Rematch failed",
"completedWithWarnings": "Rematch completed — review recommended",
"cancelledNote": "Run cancelled before completion — counts are partial.",
"statMatched": "Matched entries",
"statReview": "Needs review",
"statUnresolved": "Unresolved",
"statErrors": "Errors",
"reviewSection": "Filename matches to review ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entry",
"columnFile": "Matched file",
"columnUndo": "Undo",
"copyReport": "Copy Report",
"close": "Close",
"scope_global": "All recipes",
"scope_bulk": "Selected recipes",
"scope_single": "Single recipe"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "Type folder path or select from tree below...",
"root": "Root"
},
"linkHuggingFace": {
"title": "Link to HuggingFace",
"infoText": "Paste the HuggingFace repository URL to associate this model with its source. This enables AI-powered metadata enrichment.",
"urlLabel": "HuggingFace Repository URL:",
"linkModelSource": {
"title": "Link to Model Source",
"infoText": "Paste the model page URL to associate this model with its source. Linking enables AI-powered metadata enrichment for Hugging Face and ModelScope models.",
"urlLabel": "Model Page URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Enter the full URL of the HuggingFace repository.",
"helpText": "Enter the full URL of the model page. Supported sites:",
"enrichNote": "AI enrichment needs a readable model card. Sites that don't expose one (currently TensorArt) can only be linked.",
"urlRequired": "Please enter a model page URL.",
"invalidUrl": "Unsupported URL. Supported sites: Hugging Face, ModelScope, TensorArt.",
"linking": "Linking model source...",
"confirmAction": "Save & Link"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Value",
"add": "Add",
"invalidRange": "Invalid range format. Use x.x-y.y"
"invalidRange": "Invalid range format. Use x.x-y.y",
"invalidValue": "Please enter a valid number",
"saveFailed": "Failed to save preset parameter",
"added": "Preset parameter added",
"updated": "Preset parameter updated"
},
"triggerWords": {
"label": "Trigger Words",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word",
"copyOrEditWord": "Click to copy, double-click to edit",
"deleteWord": "Delete trigger word",
"suggestions": {
"noSuggestions": "No suggestions available",
@@ -1649,8 +1882,8 @@
"showCount": "Show examples ({count})",
"hideExamples": "Hide examples",
"addExamples": "Add examples",
"previousExample": "Previous example",
"nextExample": "Next example",
"previousExample": "Previous example ([)",
"nextExample": "Next example (])",
"noExamples": "No example images available",
"addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here",
@@ -1736,7 +1969,7 @@
"empty": "No version history available for this model yet.",
"error": "Failed to load versions.",
"missingModelId": "This model is missing a CivitAI model id.",
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
"sourceGroupInfo": "This is a {source} model group. Open the library to see all versions in the grid.",
"confirm": {
"delete": "Delete this version from your library?"
},
@@ -1808,6 +2041,10 @@
"title": "Initializing Embedding Manager",
"message": "Scanning and building embedding cache. This may take a few minutes..."
},
"other": {
"title": "Initializing Other Models Manager",
"message": "Scanning and building model cache. This may take a few minutes..."
},
"recipes": {
"title": "Initializing Recipe Manager",
"message": "Loading and processing recipes. This may take a few minutes..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "Getting Started",
"updateVlogs": "Update Vlogs",
"documentation": "Documentation"
"documentation": "Documentation",
"shortcuts": "Shortcuts"
},
"gettingStarted": {
"title": "Getting Started with LoRA Manager"
"title": "Getting Started with LoRA Manager",
"replayTutorial": "Replay Tutorial"
},
"shortcuts": {
"title": "Keyboard & Mouse Shortcuts",
"groups": {
"general": "General",
"actions": "Actions",
"selection": "Selection & Bulk Mode",
"navigation": "Navigation",
"modelModal": "Model / Recipe Modal",
"mediaViewer": "Media Viewer / Showcase"
},
"keys": {
"click": "Click",
"drag": "Drag",
"rightClick": "Right-click",
"letter": "Letter",
"swipe": "Swipe"
},
"entries": {
"focusSearch": "Focus search",
"closeModal": "Close modal / panel",
"openShortcuts": "Open this shortcuts panel",
"refresh": "Refresh model list",
"fetchMetadata": "Fetch metadata from CivitAI (model pages only)",
"downloadModel": "Download a model (model pages only)",
"toggleBulkMode": "Toggle bulk mode",
"selectAll": "Select all visible models",
"rangeSelect": "Range select",
"marqueeSelect": "Marquee-select cards (on empty grid area)",
"exitBulkMode": "Exit bulk mode",
"bulkActions": "On selected card: bulk actions menu",
"globalActions": "On empty page area: global actions menu (update check, manage excluded models)",
"scrollPages": "Scroll pages",
"jumpAlphabet": "Jump alphabet bar",
"prevNext": "Previous / next model",
"deleteEntry": "Delete",
"cycleMedia": "Cycle media ([ / ] in showcase gallery)",
"swipeTouch": "Cycle media on touch devices",
"closeViewer": "Close viewer"
}
},
"updateVlogs": {
"title": "Latest Updates",
@@ -1934,7 +2213,8 @@
"settings": "Settings & Configuration",
"extensions": "Extensions",
"newBadge": "NEW"
}
},
"newContentBadge": "New"
},
"update": {
"title": "Check for Updates",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}",
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "Applying filename template failed: {error}",
"noModelsSelected": "No models selected"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download",
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...",
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
"reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
"otherRootsFailed": "Failed to load other model roots: {message}",
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
"mappingsCleared": "Base model path mappings cleared",
"mappingSaveFailed": "Failed to save base model mappings: {message}",
"downloadTemplatesUpdated": "Download path templates updated",
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
"filenameTemplatesUpdated": "Filename templates updated",
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
"recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Settings updated: {setting}",
@@ -2324,12 +2605,14 @@
"contentRatingFailed": "Failed to set content rating: {message}",
"relinkSuccess": "Model successfully re-linked to CivitAI",
"relinkFailed": "Error: {message}",
"linkHfSuccess": "Model successfully linked to HuggingFace",
"linkHfSuccess": "Model successfully linked to its model source",
"linkHfFailed": "Error: {message}",
"linkCivArchSuccess": "Model successfully re-linked via CivitArchive",
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
"noCivitaiInfo": "No CivitAI information available",
"missingHash": "Model hash not available"
"missingHash": "Model hash not available",
"enrichNeedsSource": "Link this model to a model source first (Link Model → Link to Model Source)",
"enrichUnsupportedSource": "AI enrichment is not available for {source} models"
},
"exampleImages": {
"pathUpdated": "Example images path updated successfully",
@@ -2487,6 +2770,17 @@
"rebuilding": "Rebuilding cache...",
"rebuildFailed": "Failed to rebuild cache: {error}",
"retry": "Retry"
},
"otherModels": {
"title": "Other Models Management is available",
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
"enable": "Enable Other Models",
"openSettings": "Open Settings"
},
"pager": {
"previous": "Previous message",
"next": "Next message",
"position": "Message {current} of {total}"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"reorder": {
"dragHandle": "Arrastra para reordenar"
},
"actions": {
"save": "Guardar",
"cancel": "Cancelar",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "Actualizando {type}...",
"fullRebuilding": "Reconstrucción completa de {type}...",
"actionRefresh": "Actualización",
"actionFullRebuild": "Reconstrucción completa",
"actionRefreshLower": "actualizar",
"actionRebuildLower": "reconstruir",
"stages": {
"scan_folders": "Escaneando carpetas...",
"count_models": "Se encontraron {total} archivos",
"process_models": "Procesando modelos",
"reconcile_scan": "Comprobando cambios...",
"process_new": "Procesando modelos nuevos",
"finalizing": "Finalizando..."
},
"eta": {
"lessThanMinute": "Queda menos de un minuto",
"minutes": "Quedan ~{minutes} min",
"hours": "Quedan ~{hours} h {minutes} min"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "Operaciones por lotes",
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span>. Selecciona varios modelos y realiza operaciones por lotes. Usa <span class=\"onboarding-shortcut\">Ctrl+A</span> para seleccionar todos los modelos visibles."
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span> para seleccionar varios modelos y realizar operaciones por lotes.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> selecciona todos los modelos visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> selecciona un rango.<br>• <span class=\"onboarding-shortcut\">Esc</span> o hacer clic en un área vacía sale del modo por lotes."
},
"searchOptions": {
"title": "Opciones de búsqueda",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "Menú contextual",
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones adicionales."
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones de la tarjeta como mover, eliminar o editar metadatos."
},
"marqueeSelect": {
"title": "Arrastrar para seleccionar",
"content": "Mantén pulsado el <strong>botón izquierdo del ratón</strong> en un área vacía de la cuadrícula y arrastra para dibujar un rectángulo de selección que selecciona varias tarjetas a la vez."
},
"dragToSidebar": {
"title": "Organizar arrastrando",
"content": "Arrastra una tarjeta de modelo hasta una carpeta de la barra lateral para mover el archivo allí. Esto también funciona con varias tarjetas seleccionadas en el modo por lotes."
},
"contextMenus": {
"title": "Más menús contextuales",
"content": "En el modo por lotes, <strong>haz clic derecho en una tarjeta seleccionada</strong> para ver las acciones por lotes. <strong>Haz clic derecho en un área vacía</strong> de la página para ver acciones globales como comprobar actualizaciones y gestionar modelos excluidos."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "Ver en CivitAI",
"notAvailableFromCivitai": "No disponible en CivitAI",
"viewOnHuggingFace": "Ver en Hugging Face",
"viewOnSource": "Ver en {source}",
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
"copyLoRASyntax": "Copiar sintaxis de LoRA",
"checkpointNameCopied": "Nombre del checkpoint copiado",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding",
"embeddingNameCopied": "Sintaxis de embedding copiada",
"modelNameCopied": "Nombre del modelo copiado",
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "Todos los {typePlural} ya tienen metadatos de licencia",
"error": "No se pudieron actualizar los metadatos de licencia de los {typePlural}: {message}"
},
"repairRecipes": {
"label": "Reparar datos de recetas",
"loading": "Reparando datos de recetas...",
"success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}"
},
"rematchRecipes": {
"label": "Reasociar recetas con modelos locales",
"loading": "Reasociando recetas con modelos locales...",
"success": "{entries} entradas asociadas en {recipes} recetas",
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
"error": "Falló la reasociación de recetas: {message}"
},
@@ -210,6 +238,7 @@
"recipes": "Recetas",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Otros",
"statistics": "Estadísticas"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "General",
"interface": "Interfaz",
"library": "Biblioteca"
"library": "Biblioteca",
"organization": "Organización",
"modelPaths": "Rutas de modelos"
},
"search": {
"placeholder": "Buscar ajustes...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de CivitAI como una tarjeta única. Las versiones anteriores están ocultas.",
"stickyControls": "Mantener visible la barra de acciones",
"stickyControlsHelp": "Cuando está activado, la barra de acciones (Actualizar, Descargar, etc.) permanece fijada en la parte superior al desplazarse, junto con la navegación por rutas.",
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
"default": "Predeterminado",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
"defaultEmbeddingRoot": "Raíz de embedding",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"defaultVaeRoot": "Raíz de VAE",
"defaultVaeRootHelp": "Establecer el directorio raíz predeterminado de VAE para descargas, importaciones y movimientos",
"defaultUpscalerRoot": "Raíz de Upscaler",
"defaultUpscalerRootHelp": "Establecer el directorio raíz predeterminado de Upscaler para descargas, importaciones y movimientos",
"defaultTextEncoderRoot": "Raíz de Text Encoder",
"defaultTextEncoderRootHelp": "Establecer el directorio raíz predeterminado de Text Encoder para descargas, importaciones y movimientos",
"defaultClipVisionRoot": "Raíz de CLIP Vision",
"defaultClipVisionRootHelp": "Establecer el directorio raíz predeterminado de CLIP Vision para descargas, importaciones y movimientos",
"defaultControlnetRoot": "Raíz de ControlNet",
"defaultControlnetRootHelp": "Establecer el directorio raíz predeterminado de ControlNet para descargas, importaciones y movimientos",
"enableOtherModels": "Gestión de otros modelos",
"enableOtherModelsHelp": "Cuando está desactivado, las carpetas VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet no se escanean, la página Otros modelos permanece desactivada y estos tipos de modelos no se pueden descargar.",
"otherSubTypes": "Tipos de modelos gestionados",
"otherSubTypesHelp": "Elige qué categorías de otros modelos se escanean y se muestran en la página Otros modelos.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Ruta de almacenamiento de recetas",
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
}
},
"modelPaths": {
"title": "Rutas de la biblioteca de modelos",
"description": "Carpetas raíz que LoRA Manager escanea en busca de tus modelos. Son las ubicaciones de modelos principales leídas de settings.json en modo independiente.",
"restartRequired": "Requiere reiniciar para que surta efecto",
"coreTypes": "Tipos de modelos principales",
"otherTypes": "Otros tipos de modelos",
"otherTypesDisabledHint": "No hay habilitado ningún otro tipo de modelo. Activa los tipos que necesites arriba para configurar sus carpetas.",
"saveSuccessRestart": "Rutas de la biblioteca de modelos actualizadas. Se requiere reinicio para aplicar los cambios.",
"pendingRestartNotice": "Cambios de rutas guardados. Reinicia LoRA Manager para que surtan efecto.",
"pendingRestartBannerTitle": "Se requiere reinicio para aplicar los cambios de rutas",
"pendingRestartBannerMessage": "Se actualizaron las rutas de la biblioteca de modelos. Reinicia el servidor de LoRA Manager para escanear las nuevas carpetas.",
"folderKeys": {
"loras": "Rutas de LoRA",
"checkpoints": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embeddings": "Rutas de Embedding",
"vae": "Rutas de VAE",
"upscale_models": "Rutas de Upscaler",
"text_encoders": "Rutas de Text Encoder",
"clip": "Rutas de CLIP (heredadas)",
"clip_vision": "Rutas de CLIP Vision",
"controlnet": "Rutas de ControlNet"
}
},
"directoryPicker": {
"title": "Explorar carpetas",
"selectFolder": "Seleccionar esta carpeta",
"goUp": "Subir",
"pathPlaceholder": "Introducir ruta...",
"go": "Ir",
"emptyFolder": "No hay subcarpetas",
"loadError": "Error al cargar el directorio"
},
"pathValidation": {
"valid": "La ruta es válida",
"pathNotFound": "La ruta no existe",
"notADirectory": "No es un directorio",
"notReadable": "La ruta no es legible",
"notWritable": "La ruta no es escribible"
},
"priorityTags": {
"title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "Plantilla válida"
}
},
"filenameTemplates": {
"title": "Plantillas de nombres de archivo",
"help": "Configurar nombres de archivo de los modelos descargados por tipo de modelo. Dejar vacío para conservar los nombres de archivo originales al descargar; aplicar una plantilla vacía restaura los nombres de archivo originales registrados de los modelos renombrados previamente. El nombre de archivo original siempre se conserva en los metadatos del modelo.",
"availablePlaceholders": "Marcadores de posición disponibles:",
"templatePlaceholder": "Introduce plantilla de nombre de archivo (ej., {base_model}-{model_name}-{version_name})",
"applyButton": "Aplicar a la biblioteca ahora",
"applyHelp": "Renombra todos los archivos existentes de este tipo de modelo según la plantilla; con una plantilla vacía, restaura los nombres de archivo originales registrados. Advertencia: renombrar cambia la ruta relativa que ven los cargadores de ComfyUI, por lo que los workflows existentes que hagan referencia al nombre de archivo anterior pueden necesitar actualizarse. El nombre de archivo original se conserva en los metadatos de cada modelo.",
"confirmApply": "¿Renombrar todos los archivos existentes de este tipo de modelo según la plantilla de nombres de archivo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. El nombre de archivo original se conserva en los metadatos de cada modelo.",
"confirmRevert": "¿Restaurar los nombres de archivo originales registrados de todos los archivos renombrados previamente de este tipo de modelo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. Los archivos sin un nombre de archivo original registrado se omiten.",
"validation": {
"restoreOriginal": "Válido (la plantilla vacía restaura los nombres de archivo originales)",
"invalidChars": "Caracteres inválidos detectados (un nombre de archivo no puede contener / \\ < > : \" | ? *)",
"invalidPlaceholder": "Marcador de posición inválido: {placeholder}",
"validTemplate": "Plantilla válida"
}
},
"exampleImages": {
"downloadLocation": "Ubicación de descarga",
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
@@ -786,7 +894,6 @@
"setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
"reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección",
@@ -822,14 +929,22 @@
"complete": "Auto-organización completada",
"error": "Error: {error}"
},
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
"filenameTemplateProgress": {
"initializing": "Inicializando aplicación de plantilla de nombres de archivo...",
"starting": "Aplicando plantilla de nombres de archivo a {type}...",
"processing": "Procesando ({processed}/{total}) - {success} renombrados, {skipped} omitidos, {failures} fallidos",
"completed": "Completado: {success} renombrados, {skipped} omitidos, {failures} fallidos",
"complete": "Aplicación de plantilla de nombres de archivo completada",
"error": "Error: {error}"
},
"enrichHfAgent": "Enriquecer metadatos con IA"
},
"contextMenu": {
"refreshMetadata": "Actualizar datos de CivitAI",
"checkUpdates": "Comprobar actualizaciones",
"linkModel": "Vincular modelo",
"linkCivitai": "Re-vincular a CivitAI",
"linkHuggingFace": "Vincular a HuggingFace",
"linkModelSource": "Vincular a una fuente de modelo",
"copySyntax": "Copiar sintaxis de LoRA",
"copyFilename": "Copiar nombre de archivo del modelo",
"copyRecipeSyntax": "Copiar sintaxis de receta",
@@ -842,7 +957,6 @@
"replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"rematchMetadata": "Reasociar con modelos locales",
"reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo",
@@ -852,7 +966,7 @@
"viewAllLoras": "Ver todos los LoRAs",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"deleteRecipe": "Eliminar receta",
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
"enrichHfAgent": "Enriquecer metadatos con IA"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "Receta anterior (←)",
"nextWithShortcut": "Siguiente receta (→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "Modelo base",
"unknown": "Desconocido"
},
"actions": {
"openFileLocation": "Abrir ubicación del archivo",
"copyId": "Copiar ID de la receta"
},
"openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente",
"failed": "Error al abrir la ubicación del archivo",
"copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Enviar workflow a ComfyUI",
"sent": "Workflow enviado a ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
"prepareError": "Error preparando LoRAs para descarga: {message}"
},
"repair": {
"starting": "Reparando metadatos de la receta...",
"success": "Metadatos de la receta reparados con éxito",
"skipped": "La receta ya está en la última versión, no se necesita reparación",
"failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta"
},
"reimport": {
"starting": "Reimportando receta desde origen...",
"success": "Receta reimportada exitosamente",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Modelos embedding"
},
"other": {
"title": "Otros modelos",
"disabled": {
"title": "La gestión de otros modelos está desactivada",
"description": "Actívala para escanear y gestionar archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y para descargarlos desde CivitAI.",
"enableButton": "Activar otros modelos",
"hint": "Puedes cambiar los tipos de modelos gestionados más adelante en Configuración > Biblioteca.",
"enableFailed": "No se pudieron activar los otros modelos",
"downloadBlocked": "La gestión de otros modelos está desactivada para este tipo de modelo. Actívala en Configuración > Biblioteca para descargar este archivo.",
"enableAction": "Activar otros modelos"
},
"noPaths": {
"title": "No se encontraron carpetas de otros modelos",
"descriptionStandalone": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade tus carpetas de modelos en Configuración → Rutas de modelos y reinicia LoRA Manager.",
"hintStandalone": "Solo se escanean los tipos de modelos habilitados; activa los tipos que necesites en Biblioteca → Raíces predeterminadas.",
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
"openSettings": "Abrir configuración",
"openModelPaths": "Configurar carpetas de modelos",
"openSettingsFolder": "Abrir carpeta de ajustes"
}
},
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
"collapseAllDisabled": "No disponible en la vista de lista",
"hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol",
"viewOptions": "Opciones de vista",
"treeView": "Vista de árbol",
"listView": "Vista de lista",
"recursiveOn": "Incluir subcarpetas",
"recursiveOff": "Solo carpeta actual",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista",
"createFolder": "Nueva carpeta",
"newSubfolder": "Nueva subcarpeta",
"showEmptyFolders": "Mostrar carpetas vacías",
"createFolderResult": {
"success": "Carpeta \"{name}\" creada",
"failed": "Error al crear la carpeta: {message}",
"unsupported": "La creación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"deleteFolder": "Eliminar carpeta",
"deleteFolderModal": {
"title": "¿Eliminar carpeta?",
"message": "La carpeta y todo su contenido se eliminarán permanentemente del disco.",
"folderLabel": "Carpeta",
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
"notEmptyTitle": "La carpeta no está vacía",
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"confirm": "Eliminar carpeta"
},
"deleteFolderResult": {
"success": "Carpeta \"{name}\" eliminada",
"successWithFiles": "Carpeta \"{name}\" eliminada junto con {count} elemento(s) más",
"restored": "Carpeta restaurada",
"failed": "Error al eliminar la carpeta: {message}",
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "La eliminación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"renameFolder": "Cambiar nombre de la carpeta",
"renameFolderResult": {
"success": "Carpeta renombrada a \"{name}\"",
"failed": "Error al cambiar el nombre de la carpeta: {message}",
"targetExists": "Ya existe una carpeta con ese nombre aquí",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "El cambio de nombre de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
},
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas"
"createHint": "Haz clic en el botón Nueva carpeta de arriba para crear carpetas"
},
"folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta",
@@ -1322,9 +1503,9 @@
"download": {
"title": "Descargar modelo desde URL",
"titleWithType": "Descargar {type} desde URL",
"civitaiUrl": "URL de CivitAI:",
"civitaiUrl": "URL del modelo:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
"urlHint": "Ingrese una URL de CivitAI, CivArchive, Hugging Face o ModelScope por línea. Admite múltiples URLs para descarga por lotes.",
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
"selectAll": "Seleccionar todo",
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
@@ -1357,9 +1538,9 @@
"inLibrary": "En la biblioteca"
},
"errors": {
"invalidUrl": "Formato de URL de CivitAI inválido",
"invalidUrl": "Formato de URL de modelo inválido",
"noVersions": "No hay versiones disponibles para este modelo",
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face / ModelScope en el mismo lote.",
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "Archivo actual:",
"downloading": "Descargando: {name}",
"metadata": "Metadatos: {name}",
"indexingFile": "Leyendo el archivo de modelo...",
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
"fetchingMetadata": "Obteniendo metadatos...",
"transferred": "Descargado: {downloaded} / {total}",
"transferredSimple": "Descargado: {downloaded}",
"transferredUnknown": "Descargado: --",
@@ -1441,6 +1626,11 @@
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
"action": "Comprobar todo"
},
"filenameTemplateConfirm": {
"titleApply": "¿Aplicar la plantilla de nombres de archivo a la biblioteca?",
"titleRevert": "¿Restaurar los nombres de archivo originales?",
"revertButton": "Restaurar nombres de archivo originales"
},
"bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos",
"description": "Añadir etiquetas a",
@@ -1467,6 +1657,41 @@
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"rematchOptions": {
"title": "Reasociar recetas",
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
"confirmButton": "Reasociar"
},
"rematchResults": {
"undo": "Deshacer",
"undone": "Deshecho",
"undoFailed": "No se pudo deshacer la reasociación: {message}"
},
"rematchSummary": {
"title": "Resumen de la reasociación",
"successMessage": "{entries} entradas asociadas",
"failed": "Falló la reasociación",
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
"statMatched": "Entradas asociadas",
"statReview": "Por revisar",
"statUnresolved": "Sin coincidencia",
"statErrors": "Errores",
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
"columnRecipe": "Receta",
"columnEntry": "Entrada",
"columnFile": "Archivo coincidente",
"columnUndo": "Deshacer",
"copyReport": "Copiar informe",
"close": "Cerrar",
"scope_global": "Todas las recetas",
"scope_bulk": "Recetas seleccionadas",
"scope_single": "Receta individual"
},
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
"root": "Raíz"
},
"linkHuggingFace": {
"title": "Vincular a HuggingFace",
"infoText": "Pegue la URL del repositorio de HuggingFace para asociar este modelo. Esto permite el enriquecimiento de metadatos con IA.",
"urlLabel": "URL del repositorio de HuggingFace:",
"linkModelSource": {
"title": "Vincular a una fuente de modelo",
"infoText": "Pegue la URL de la página del modelo para asociar este modelo con su fuente. La vinculación permite el enriquecimiento de metadatos con IA para modelos de Hugging Face y ModelScope.",
"urlLabel": "URL de la página del modelo:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
"helpText": "Ingrese la URL completa de la página del modelo. Sitios soportados:",
"enrichNote": "El enriquecimiento con IA necesita una ficha de modelo legible. Los sitios que no la exponen (actualmente TensorArt) solo se pueden vincular.",
"urlRequired": "Ingrese la URL de la página del modelo.",
"invalidUrl": "URL no soportada. Sitios soportados: Hugging Face, ModelScope, TensorArt.",
"linking": "Vinculando la fuente del modelo...",
"confirmAction": "Guardar y vincular"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Valor",
"add": "Añadir",
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
"invalidRange": "Formato de rango inválido. Use x.x-y.y",
"invalidValue": "Introduce un número válido",
"saveFailed": "Error al guardar el parámetro preajustado",
"added": "Parámetro preajustado añadido",
"updated": "Parámetro preajustado actualizado"
},
"triggerWords": {
"label": "Palabras clave",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
"editWord": "Editar palabra de activación",
"editPlaceholder": "Editar palabra de activación",
"copyWord": "Copiar palabra de activación",
"copyOrEditWord": "Haz clic para copiar, doble clic para editar",
"deleteWord": "Eliminar palabra de activación",
"suggestions": {
"noSuggestions": "No hay sugerencias disponibles",
@@ -1649,8 +1882,8 @@
"showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior",
"nextExample": "Ejemplo siguiente",
"previousExample": "Ejemplo anterior ([)",
"nextExample": "Ejemplo siguiente (])",
"noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
@@ -1736,7 +1969,7 @@
"empty": "Aún no hay historial de versiones para este modelo.",
"error": "No se pudieron cargar las versiones.",
"missingModelId": "Este modelo no tiene un ID de modelo de CivitAI.",
"hfGroupInfo": "Este es un grupo de modelos de HuggingFace. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
"sourceGroupInfo": "Este es un grupo de modelos de {source}. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
"confirm": {
"delete": "¿Eliminar esta versión de tu biblioteca?"
},
@@ -1808,6 +2041,10 @@
"title": "Inicializando gestor de embedding",
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
},
"other": {
"title": "Inicializando el gestor de otros modelos",
"message": "Escaneando y construyendo la caché de modelos. Esto puede tomar unos minutos..."
},
"recipes": {
"title": "Inicializando gestor de recetas",
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "Comenzando",
"updateVlogs": "Vlogs de actualización",
"documentation": "Documentación"
"documentation": "Documentación",
"shortcuts": "Atajos"
},
"gettingStarted": {
"title": "Comenzando con el gestor de LoRA"
"title": "Comenzando con el gestor de LoRA",
"replayTutorial": "Repetir tutorial"
},
"shortcuts": {
"title": "Atajos de teclado y ratón",
"groups": {
"general": "General",
"actions": "Acciones",
"selection": "Selección y modo por lotes",
"navigation": "Navegación",
"modelModal": "Modal de modelo / receta",
"mediaViewer": "Visor de medios / Ejemplos"
},
"keys": {
"click": "Clic",
"drag": "Arrastrar",
"rightClick": "Clic derecho",
"letter": "Letra",
"swipe": "Deslizar"
},
"entries": {
"focusSearch": "Enfocar la búsqueda",
"closeModal": "Cerrar modal / panel",
"openShortcuts": "Abrir este panel de atajos",
"refresh": "Actualizar la lista de modelos",
"fetchMetadata": "Obtener metadatos de CivitAI (solo páginas de modelos)",
"downloadModel": "Descargar un modelo (solo páginas de modelos)",
"toggleBulkMode": "Activar/desactivar el modo por lotes",
"selectAll": "Seleccionar todos los modelos visibles",
"rangeSelect": "Selección por rango",
"marqueeSelect": "Seleccionar tarjetas con un rectángulo de selección (en un área vacía de la cuadrícula)",
"exitBulkMode": "Salir del modo por lotes",
"bulkActions": "En una tarjeta seleccionada: menú de acciones por lotes",
"globalActions": "En un área vacía de la página: menú de acciones globales (comprobar actualizaciones, gestionar modelos excluidos)",
"scrollPages": "Desplazarse por las páginas",
"jumpAlphabet": "Saltar con la barra alfabética",
"prevNext": "Modelo anterior / siguiente",
"deleteEntry": "Eliminar",
"cycleMedia": "Cambiar de medio ([ / ] en la galería de ejemplos)",
"swipeTouch": "Cambiar de medio en dispositivos táctiles",
"closeViewer": "Cerrar el visor"
}
},
"updateVlogs": {
"title": "Últimas actualizaciones",
@@ -1934,7 +2213,8 @@
"settings": "Configuración",
"extensions": "Extensiones",
"newBadge": "NUEVO"
}
},
"newContentBadge": "NUEVO"
},
"update": {
"title": "Comprobar actualizaciones",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}",
"filenameTemplateSuccess": "Plantilla de nombres de archivo aplicada exitosamente para {count} {type}",
"filenameTemplatePartialSuccess": "Plantilla de nombres de archivo aplicada con {success} renombrados, {failures} fallidos de un total de {total} modelos",
"filenameTemplateFailed": "Aplicación de la plantilla de nombres de archivo fallida: {error}",
"noModelsSelected": "No hay modelos seleccionados"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar",
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
"noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...",
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
"reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
"otherRootsFailed": "Error al cargar raíces de otros modelos: {message}",
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
"filenameTemplatesUpdated": "Plantillas de nombres de archivo actualizadas",
"filenameTemplatesFailed": "Error al guardar plantillas de nombres de archivo: {message}",
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
"settingsUpdated": "Configuración actualizada: {setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "Modelo re-vinculado exitosamente mediante CivitArchive",
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
"noCivitaiInfo": "No hay información de CivitAI disponible",
"missingHash": "Hash del modelo no disponible"
"missingHash": "Hash del modelo no disponible",
"enrichNeedsSource": "Vincule este modelo a una fuente de modelo primero (Vincular modelo → Vincular a una fuente de modelo)",
"enrichUnsupportedSource": "El enriquecimiento con IA no está disponible para modelos de {source}"
},
"exampleImages": {
"pathUpdated": "Ruta de imágenes de ejemplo actualizada exitosamente",
@@ -2487,6 +2770,17 @@
"rebuilding": "Reconstruyendo caché...",
"rebuildFailed": "Error al reconstruir la caché: {error}",
"retry": "Reintentar"
},
"otherModels": {
"title": "La gestión de otros modelos ya está disponible",
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
"enable": "Activar otros modelos",
"openSettings": "Abrir configuración"
},
"pager": {
"previous": "Notificación anterior",
"next": "Notificación siguiente",
"position": "Notificación {current} de {total}"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"reorder": {
"dragHandle": "Glisser pour réordonner"
},
"actions": {
"save": "Enregistrer",
"cancel": "Annuler",
@@ -50,6 +53,27 @@
"mb": "Mo",
"gb": "Go",
"tb": "To"
},
"scanProgress": {
"refreshing": "Actualisation des {type}...",
"fullRebuilding": "Reconstruction complète des {type}...",
"actionRefresh": "Actualisation",
"actionFullRebuild": "Reconstruction complète",
"actionRefreshLower": "lactualisation",
"actionRebuildLower": "la reconstruction",
"stages": {
"scan_folders": "Scan des dossiers...",
"count_models": "{total} fichiers trouvés",
"process_models": "Traitement des modèles",
"reconcile_scan": "Vérification des modifications...",
"process_new": "Traitement des nouveaux modèles",
"finalizing": "Finalisation..."
},
"eta": {
"lessThanMinute": "Moins dune minute restante",
"minutes": "~{minutes} min restantes",
"hours": "~{hours} h {minutes} min restantes"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "Opérations groupées",
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span>. Sélectionnez plusieurs modèles et effectuez des opérations groupées. Utilisez <span class=\"onboarding-shortcut\">Ctrl+A</span> pour sélectionner tous les modèles visibles."
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span> pour sélectionner plusieurs modèles et effectuer des opérations groupées.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> sélectionne tous les modèles visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> sélectionne une plage.<br>• <span class=\"onboarding-shortcut\">Esc</span> ou un clic sur une zone vide quitte le mode groupé."
},
"searchOptions": {
"title": "Options de recherche",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "Menu contextuel",
"content": "<strong>Clic droit</strong> sur une carte de modèle pour accéder à un menu contextuel avec des actions supplémentaires."
"content": "<strong>Clic droit</strong> sur n'importe quelle carte de modèle pour ouvrir un menu contextuel avec des actions sur la carte comme déplacer, supprimer ou modifier les métadonnées."
},
"marqueeSelect": {
"title": "Glisser pour sélectionner",
"content": "Maintenez le <strong>bouton gauche de la souris</strong> enfoncé sur une zone vide de la grille et glissez pour tracer un rectangle de sélection qui sélectionne plusieurs cartes à la fois."
},
"dragToSidebar": {
"title": "Organiser par glisser-déposer",
"content": "Glissez une carte de modèle sur un dossier de la barre latérale pour y déplacer le fichier. Cela fonctionne aussi avec plusieurs cartes sélectionnées en mode groupé."
},
"contextMenus": {
"title": "Plus de menus contextuels",
"content": "En mode groupé, <strong>faites un clic droit sur une carte sélectionnée</strong> pour accéder aux actions groupées. <strong>Faites un clic droit sur une zone vide</strong> de la page pour accéder aux actions globales comme la vérification des mises à jour et la gestion des modèles exclus."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "Voir sur CivitAI",
"notAvailableFromCivitai": "Non disponible sur CivitAI",
"viewOnHuggingFace": "Voir sur Hugging Face",
"viewOnSource": "Voir sur {source}",
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
"copyLoRASyntax": "Copier la syntaxe LoRA",
"checkpointNameCopied": "Nom du checkpoint copié",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding",
"embeddingNameCopied": "Syntaxe dembedding copiée",
"modelNameCopied": "Nom du modèle copié",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "Tous les {typePlural} possèdent déjà des métadonnées de licence",
"error": "Échec de l'actualisation des métadonnées de licence pour les {typePlural} : {message}"
},
"repairRecipes": {
"label": "Réparer les données de Recipes",
"loading": "Réparation des données de Recipes...",
"success": "{count} Recipes réparées avec succès.",
"cancelled": "Réparation annulée. {count} Recipes ont été réparées.",
"error": "Échec de la réparation des Recipes : {message}"
},
"rematchRecipes": {
"label": "Réassocier les Recipes aux modèles locaux",
"loading": "Réassociation des Recipes aux modèles locaux...",
"success": "{entries} entrées associées dans {recipes} Recipes",
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
"error": "Échec de la réassociation des Recipes : {message}"
},
@@ -210,6 +238,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Autres",
"statistics": "Statistiques"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "Général",
"interface": "Interface",
"library": "Bibliothèque"
"library": "Bibliothèque",
"organization": "Organisation",
"modelPaths": "Chemins de modèles"
},
"search": {
"placeholder": "Rechercher dans les paramètres...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle CivitAI s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
"stickyControls": "Garder la barre d'actions visible",
"stickyControlsHelp": "Lorsque activé, la barre d'actions (Actualiser, Télécharger, etc.) reste épinglée en haut lors du défilement, avec la navigation par fil d'Ariane.",
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
"default": "Par défaut",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
"defaultEmbeddingRoot": "Racine Embedding",
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"defaultVaeRoot": "Racine VAE",
"defaultVaeRootHelp": "Définir le répertoire racine VAE par défaut pour les téléchargements, imports et déplacements",
"defaultUpscalerRoot": "Racine Upscaler",
"defaultUpscalerRootHelp": "Définir le répertoire racine Upscaler par défaut pour les téléchargements, imports et déplacements",
"defaultTextEncoderRoot": "Racine Text Encoder",
"defaultTextEncoderRootHelp": "Définir le répertoire racine Text Encoder par défaut pour les téléchargements, imports et déplacements",
"defaultClipVisionRoot": "Racine CLIP Vision",
"defaultClipVisionRootHelp": "Définir le répertoire racine CLIP Vision par défaut pour les téléchargements, imports et déplacements",
"defaultControlnetRoot": "Racine ControlNet",
"defaultControlnetRootHelp": "Définir le répertoire racine ControlNet par défaut pour les téléchargements, imports et déplacements",
"enableOtherModels": "Gestion des autres modèles",
"enableOtherModelsHelp": "Lorsque cette option est désactivée, les dossiers VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet ne sont pas analysés, la page Autres modèles reste désactivée et ces types de modèles ne peuvent pas être téléchargés.",
"otherSubTypes": "Types de modèles gérés",
"otherSubTypesHelp": "Choisissez les catégories dautres modèles analysées et affichées sur la page Autres modèles.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Chemin de stockage des Recipes",
"recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
}
},
"modelPaths": {
"title": "Chemins de la bibliothèque de modèles",
"description": "Dossiers racine que LoRA Manager analyse pour trouver vos modèles. Ce sont les emplacements de modèles principaux lus depuis settings.json en mode autonome.",
"restartRequired": "Un redémarrage est requis pour appliquer les changements",
"coreTypes": "Types de modèles principaux",
"otherTypes": "Autres types de modèles",
"otherTypesDisabledHint": "Aucun autre type de modèle nest activé. Activez les types dont vous avez besoin ci-dessus pour configurer leurs dossiers.",
"saveSuccessRestart": "Chemins de la bibliothèque de modèles mis à jour. Redémarrage requis pour appliquer les changements.",
"pendingRestartNotice": "Changements de chemins enregistrés. Redémarrez LoRA Manager pour quils prennent effet.",
"pendingRestartBannerTitle": "Redémarrage requis pour appliquer les changements de chemins",
"pendingRestartBannerMessage": "Les chemins de la bibliothèque de modèles ont été mis à jour. Redémarrez le serveur LoRA Manager pour analyser les nouveaux dossiers.",
"folderKeys": {
"loras": "Chemins LoRA",
"checkpoints": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embeddings": "Chemins Embedding",
"vae": "Chemins VAE",
"upscale_models": "Chemins Upscaler",
"text_encoders": "Chemins Text Encoder",
"clip": "Chemins CLIP (hérité)",
"clip_vision": "Chemins CLIP Vision",
"controlnet": "Chemins ControlNet"
}
},
"directoryPicker": {
"title": "Parcourir les dossiers",
"selectFolder": "Sélectionner ce dossier",
"goUp": "Remonter",
"pathPlaceholder": "Saisir un chemin...",
"go": "Aller",
"emptyFolder": "Aucun sous-dossier",
"loadError": "Échec du chargement du dossier"
},
"pathValidation": {
"valid": "Le chemin est valide",
"pathNotFound": "Le chemin nexiste pas",
"notADirectory": "Nest pas un dossier",
"notReadable": "Le chemin nest pas lisible",
"notWritable": "Le chemin nest pas accessible en écriture"
},
"priorityTags": {
"title": "Tags prioritaires",
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "Modèle valide"
}
},
"filenameTemplates": {
"title": "Modèles de nom de fichier",
"help": "Configurer les noms de fichier des modèles téléchargés par type de modèle. Laisser vide pour conserver le nom de fichier d'origine. Le nom de fichier d'origine est toujours conservé dans les métadonnées du modèle.",
"availablePlaceholders": "Espaces réservés disponibles :",
"templatePlaceholder": "Entrez un modèle de nom de fichier (ex: {base_model}-{model_name}-{version_name})",
"applyButton": "Appliquer à la bibliothèque maintenant",
"applyHelp": "Renomme tous les fichiers existants de ce type de modèle selon le modèle. Attention : le renommage change le chemin relatif vu par les loaders ComfyUI, les workflows existants référençant l'ancien nom de fichier peuvent donc nécessiter une mise à jour. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
"confirmApply": "Renommer tous les fichiers existants de ce type de modèle selon le modèle de nom de fichier ? Cela change le chemin relatif vu par les loaders ComfyUI. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
"confirmRevert": "Restaurer les noms de fichier d'origine enregistrés de tous les fichiers précédemment renommés de ce type de modèle ? Cela change le chemin relatif vu par les loaders ComfyUI. Les fichiers sans nom de fichier d'origine enregistré sont ignorés.",
"validation": {
"restoreOriginal": "Valide (un modèle vide restaure les noms de fichier d'origine)",
"invalidChars": "Caractères invalides détectés (un nom de fichier ne peut pas contenir / \\ < > : \" | ? *)",
"invalidPlaceholder": "Espace réservé invalide : {placeholder}",
"validTemplate": "Modèle valide"
}
},
"exampleImages": {
"downloadLocation": "Emplacement de téléchargement",
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
@@ -786,7 +894,6 @@
"setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection",
"rematchMetadata": "Réassocier la sélection aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source",
"checkUpdates": "Vérifier les mises à jour pour la sélection",
@@ -822,14 +929,22 @@
"complete": "Auto-organisation terminée",
"error": "Erreur : {error}"
},
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
"filenameTemplateProgress": {
"initializing": "Initialisation de l'application du modèle de nom de fichier...",
"starting": "Application du modèle de nom de fichier pour {type}...",
"processing": "Traitement ({processed}/{total}) - {success} renommés, {skipped} ignorés, {failures} échecs",
"completed": "Terminé : {success} renommés, {skipped} ignorés, {failures} échecs",
"complete": "Application du modèle de nom de fichier terminée",
"error": "Erreur : {error}"
},
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
},
"contextMenu": {
"refreshMetadata": "Actualiser les données CivitAI",
"checkUpdates": "Vérifier les mises à jour",
"linkModel": "Lier le modèle",
"linkCivitai": "Relier à nouveau à CivitAI",
"linkHuggingFace": "Lier à HuggingFace",
"linkModelSource": "Lier à une source de modèle",
"copySyntax": "Copier la syntaxe LoRA",
"copyFilename": "Copier le nom de fichier du modèle",
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
@@ -842,7 +957,6 @@
"replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"rematchMetadata": "Réassocier aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle",
@@ -852,7 +966,7 @@
"viewAllLoras": "Voir tous les LoRAs",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"deleteRecipe": "Supprimer la recipe",
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "Recette précédente (←)",
"nextWithShortcut": "Recette suivante (→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "Modèle de base",
"unknown": "Inconnu"
},
"actions": {
"openFileLocation": "Ouvrir lemplacement du fichier",
"copyId": "Copier lID de la Recipe"
},
"openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès",
"failed": "Échec de louverture de lemplacement du fichier",
"copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
"sent": "Workflow envoyé vers ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
},
"repair": {
"starting": "Réparation des métadonnées de la Recipe...",
"success": "Métadonnées de la Recipe réparées avec succès",
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
"failed": "Échec de la réparation de la Recipe : {message}",
"missingId": "Impossible de réparer la Recipe : ID de Recipe manquant"
},
"reimport": {
"starting": "Ré-import de la Recipe depuis la source...",
"success": "Recette ré-importée avec succès",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Modèles Embedding"
},
"other": {
"title": "Autres modèles",
"disabled": {
"title": "La gestion des autres modèles est désactivée",
"description": "Activez-la pour analyser et gérer les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et pour les télécharger depuis CivitAI.",
"enableButton": "Activer les autres modèles",
"hint": "Vous pourrez modifier les types de modèles gérés plus tard dans Paramètres > Bibliothèque.",
"enableFailed": "Échec de lactivation des autres modèles",
"downloadBlocked": "La gestion des autres modèles est désactivée pour ce type de modèle. Activez-la dans Paramètres > Bibliothèque pour télécharger ce fichier.",
"enableAction": "Activer les autres modèles"
},
"noPaths": {
"title": "Aucun dossier dautres modèles trouvé",
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier dautres modèles na été trouvé. Ajoutez vos dossiers de modèles dans Paramètres → Chemins de modèles, puis redémarrez LoRA Manager.",
"hintStandalone": "Seuls les types de modèles activés sont analysés ; activez les types dont vous avez besoin dans Bibliothèque → Racines par défaut.",
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
"openSettings": "Ouvrir les paramètres",
"openModelPaths": "Configurer les dossiers de modèles",
"openSettingsFolder": "Ouvrir le dossier des paramètres"
}
},
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
"collapseAllDisabled": "Non disponible en vue liste",
"hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence",
"viewOptions": "Options daffichage",
"treeView": "Vue arborescente",
"listView": "Vue liste",
"recursiveOn": "Inclure les sous-dossiers",
"recursiveOff": "Dossier actuel uniquement",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste",
"createFolder": "Nouveau dossier",
"newSubfolder": "Nouveau sous-dossier",
"showEmptyFolders": "Afficher les dossiers vides",
"createFolderResult": {
"success": "Dossier \"{name}\" créé",
"failed": "Échec de la création du dossier : {message}",
"unsupported": "La création de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"deleteFolder": "Supprimer le dossier",
"deleteFolderModal": {
"title": "Supprimer le dossier ?",
"message": "Le dossier et tout son contenu seront définitivement supprimés du disque.",
"folderLabel": "Dossier",
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers quil contient seront également supprimés.",
"notEmptyTitle": "Le dossier nest pas vide",
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les dabord — la suppression dun dossier nentraîne jamais celle des fichiers de modèles.",
"confirm": "Supprimer le dossier"
},
"deleteFolderResult": {
"success": "Dossier \"{name}\" supprimé",
"successWithFiles": "Dossier \"{name}\" supprimé, ainsi que {count} autre(s) élément(s)",
"restored": "Dossier restauré",
"failed": "Échec de la suppression du dossier : {message}",
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre dannulation.",
"unsupported": "La suppression de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"renameFolder": "Renommer le dossier",
"renameFolderResult": {
"success": "Dossier renommé en \"{name}\"",
"failed": "Échec du renommage du dossier : {message}",
"targetExists": "Un dossier portant ce nom existe déjà ici",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre dannulation.",
"unsupported": "Le renommage de dossiers nest pas pris en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée"
},
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
"createHint": "Cliquez sur le bouton Nouveau dossier ci-dessus pour créer des dossiers"
},
"folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier",
@@ -1322,9 +1503,9 @@
"download": {
"title": "Télécharger un modèle depuis une URL",
"titleWithType": "Télécharger {type} depuis une URL",
"civitaiUrl": "URL CivitAI :",
"civitaiUrl": "URL du modèle :",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
"urlHint": "Entrez une URL CivitAI, CivArchive, Hugging Face ou ModelScope par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
"selectAll": "Tout sélectionner",
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
@@ -1357,9 +1538,9 @@
"inLibrary": "Dans la bibliothèque"
},
"errors": {
"invalidUrl": "Format d'URL CivitAI invalide",
"invalidUrl": "Format d'URL de modèle invalide",
"noVersions": "Aucune version disponible pour ce modèle",
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face / ModelScope dans le même lot.",
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "Fichier actuel :",
"downloading": "Téléchargement : {name}",
"metadata": "Métadonnées : {name}",
"indexingFile": "Lecture du fichier de modèle...",
"fetchingSourceMetadata": "Récupération des métadonnées depuis {source}...",
"fetchingMetadata": "Récupération des métadonnées...",
"transferred": "Téléchargé : {downloaded} / {total}",
"transferredSimple": "Téléchargé : {downloaded}",
"transferredUnknown": "Téléchargé : --",
@@ -1441,6 +1626,11 @@
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
"action": "Tout vérifier"
},
"filenameTemplateConfirm": {
"titleApply": "Appliquer le modèle de nom de fichier à la bibliothèque ?",
"titleRevert": "Restaurer les noms de fichier d'origine ?",
"revertButton": "Restaurer les noms de fichier d'origine"
},
"bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles",
"description": "Ajouter des tags à",
@@ -1467,6 +1657,41 @@
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"rematchOptions": {
"title": "Réassocier les Recipes",
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
"confirmButton": "Réassocier"
},
"rematchResults": {
"undo": "Annuler",
"undone": "Annulé",
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
},
"rematchSummary": {
"title": "Résumé de la réassociation",
"successMessage": "{entries} entrées associées",
"failed": "Échec de la réassociation",
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
"statMatched": "Entrées associées",
"statReview": "À vérifier",
"statUnresolved": "Sans correspondance",
"statErrors": "Erreurs",
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entrée",
"columnFile": "Fichier correspondant",
"columnUndo": "Annuler",
"copyReport": "Copier le rapport",
"close": "Fermer",
"scope_global": "Toutes les Recipes",
"scope_bulk": "Recipes sélectionnées",
"scope_single": "Une seule Recipe"
},
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
"root": "Racine"
},
"linkHuggingFace": {
"title": "Lier à HuggingFace",
"infoText": "Collez l'URL du dépôt HuggingFace pour associer ce modèle à sa source. Cela permet l'enrichissement des métadonnées par IA.",
"urlLabel": "URL du dépôt HuggingFace :",
"linkModelSource": {
"title": "Lier à une source de modèle",
"infoText": "Collez l'URL de la page du modèle pour associer ce modèle à sa source. La liaison permet l'enrichissement des métadonnées par IA pour les modèles Hugging Face et ModelScope.",
"urlLabel": "URL de la page du modèle :",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
"helpText": "Entrez l'URL complète de la page du modèle. Sites pris en charge :",
"enrichNote": "L'enrichissement par IA nécessite une fiche de modèle lisible. Les sites qui n'en exposent pas (actuellement TensorArt) ne peuvent être que liés.",
"urlRequired": "Veuillez saisir l'URL de la page du modèle.",
"invalidUrl": "URL non prise en charge. Sites pris en charge : Hugging Face, ModelScope, TensorArt.",
"linking": "Liaison de la source du modèle...",
"confirmAction": "Enregistrer & lier"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Valeur",
"add": "Ajouter",
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y",
"invalidValue": "Veuillez saisir un nombre valide",
"saveFailed": "Échec de l'enregistrement du paramètre préréglé",
"added": "Paramètre préréglé ajouté",
"updated": "Paramètre préréglé mis à jour"
},
"triggerWords": {
"label": "Mots-clés",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
"editWord": "Modifier le mot-clé",
"editPlaceholder": "Modifier le mot-clé",
"copyWord": "Copier le mot-clé",
"copyOrEditWord": "Cliquez pour copier, double-cliquez pour modifier",
"deleteWord": "Supprimer le mot-clé",
"suggestions": {
"noSuggestions": "Aucune suggestion disponible",
@@ -1649,8 +1882,8 @@
"showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent",
"nextExample": "Exemple suivant",
"previousExample": "Exemple précédent ([)",
"nextExample": "Exemple suivant (])",
"noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
@@ -1736,7 +1969,7 @@
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
"error": "Échec du chargement des versions.",
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle CivitAI.",
"hfGroupInfo": "Ceci est un groupe de modèles HuggingFace. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
"sourceGroupInfo": "Ceci est un groupe de modèles {source}. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
"confirm": {
"delete": "Supprimer cette version de votre bibliothèque ?"
},
@@ -1808,6 +2041,10 @@
"title": "Initialisation du gestionnaire Embedding",
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
},
"other": {
"title": "Initialisation du gestionnaire Autres modèles",
"message": "Analyse et construction du cache de modèles. Cela peut prendre quelques minutes..."
},
"recipes": {
"title": "Initialisation du gestionnaire de recipes",
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "Commencer",
"updateVlogs": "Vlogs de mise à jour",
"documentation": "Documentation"
"documentation": "Documentation",
"shortcuts": "Raccourcis"
},
"gettingStarted": {
"title": "Premiers pas avec le Gestionnaire LoRA"
"title": "Premiers pas avec le Gestionnaire LoRA",
"replayTutorial": "Rejouer le tutoriel"
},
"shortcuts": {
"title": "Raccourcis clavier et souris",
"groups": {
"general": "Général",
"actions": "Actions",
"selection": "Sélection et mode groupé",
"navigation": "Navigation",
"modelModal": "Modale Modèle / Recipe",
"mediaViewer": "Visionneuse de médias / Galerie d'exemples"
},
"keys": {
"click": "Clic",
"drag": "Glisser",
"rightClick": "Clic droit",
"letter": "Lettre",
"swipe": "Balayage"
},
"entries": {
"focusSearch": "Donner le focus au champ de recherche",
"closeModal": "Fermer la fenêtre modale / le panneau",
"openShortcuts": "Ouvrir ce panneau de raccourcis",
"refresh": "Actualiser la liste des modèles",
"fetchMetadata": "Récupérer les métadonnées depuis CivitAI (pages de modèles uniquement)",
"downloadModel": "Télécharger un modèle (pages de modèles uniquement)",
"toggleBulkMode": "Activer/désactiver le mode groupé",
"selectAll": "Sélectionner tous les modèles visibles",
"rangeSelect": "Sélection d'une plage",
"marqueeSelect": "Sélection par glisser-déposer des cartes (sur une zone vide de la grille)",
"exitBulkMode": "Quitter le mode groupé",
"bulkActions": "Sur une carte sélectionnée : menu des actions groupées",
"globalActions": "Sur une zone vide de la page : menu des actions globales (vérification des mises à jour, gestion des modèles exclus)",
"scrollPages": "Faire défiler les pages",
"jumpAlphabet": "Sauter via la barre alphabétique",
"prevNext": "Modèle précédent / suivant",
"deleteEntry": "Supprimer",
"cycleMedia": "Parcourir les médias ([ / ] dans la galerie d'exemples)",
"swipeTouch": "Parcourir les médias sur les appareils tactiles",
"closeViewer": "Fermer la visionneuse"
}
},
"updateVlogs": {
"title": "Dernières mises à jour",
@@ -1934,7 +2213,8 @@
"settings": "Paramètres & Configuration",
"extensions": "Extensions",
"newBadge": "NOUVEAU"
}
},
"newContentBadge": "NOUVEAU"
},
"update": {
"title": "Vérifier les mises à jour",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
"filenameTemplateSuccess": "Modèle de nom de fichier appliqué avec succès pour {count} {type}",
"filenameTemplatePartialSuccess": "Modèle de nom de fichier appliqué avec {success} renommés, {failures} échecs sur {total} modèles",
"filenameTemplateFailed": "Échec de l'application du modèle de nom de fichier : {error}",
"noModelsSelected": "Aucun modèle sélectionné"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger",
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
"noPreviousRecipe": "Aucune Recipe précédente",
"noNextRecipe": "Aucune Recipe suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
"noRecipesSelected": "Aucune Recipe sélectionnée",
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} Recipes sélectionnées",
"repairBulkFailed": "Échec de la réparation des Recipes sélectionnées : {message}",
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
"reimporting": "Ré-import de la Recipe depuis la source...",
"reimportingViaExtension": "Ré-import de la Recipe {current}/{total} via lextension du navigateur...",
"reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
"otherRootsFailed": "Échec du chargement des racines des autres modèles : {message}",
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
"filenameTemplatesUpdated": "Modèles de nom de fichier mis à jour",
"filenameTemplatesFailed": "Échec de la sauvegarde des modèles de nom de fichier : {message}",
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
"settingsUpdated": "Paramètres mis à jour : {setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "Modèle relié via CivitArchive avec succès",
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
"noCivitaiInfo": "Aucune information CivitAI disponible",
"missingHash": "Hash du modèle non disponible"
"missingHash": "Hash du modèle non disponible",
"enrichNeedsSource": "Liez d'abord ce modèle à une source de modèle (Lier le modèle → Lier à une source de modèle)",
"enrichUnsupportedSource": "L'enrichissement par IA n'est pas disponible pour les modèles {source}"
},
"exampleImages": {
"pathUpdated": "Chemin des images d'exemple mis à jour avec succès",
@@ -2487,6 +2770,17 @@
"rebuilding": "Reconstruction du cache...",
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
"retry": "Réessayer"
},
"otherModels": {
"title": "La gestion des autres modèles est disponible",
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
"enable": "Activer les autres modèles",
"openSettings": "Ouvrir les paramètres"
},
"pager": {
"previous": "Message précédent",
"next": "Message suivant",
"position": "Message {current} sur {total}"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "ביטול",
"confirm": "אישור",
"reorder": {
"dragHandle": "גרור כדי לשנות סדר"
},
"actions": {
"save": "שמירה",
"cancel": "ביטול",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "מרענן {type}...",
"fullRebuilding": "בונה מחדש את כל ה-{type}...",
"actionRefresh": "רענון",
"actionFullRebuild": "רענון מלא",
"actionRefreshLower": "רענון",
"actionRebuildLower": "רענון מלא",
"stages": {
"scan_folders": "סורק תיקיות...",
"count_models": "נמצאו {total} קבצים",
"process_models": "מעבד מודלים",
"reconcile_scan": "בודק שינויים...",
"process_new": "מעבד מודלים חדשים",
"finalizing": "מסיים..."
},
"eta": {
"lessThanMinute": "נותרה פחות מדקה",
"minutes": "נותרו ~{minutes} דקות",
"hours": "נותרו ~{hours} שעות ו-{minutes} דקות"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "פעולות בכמות גדולה",
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span>. בחר מספר מודלים ובצע פעולות בכמות גדולה. השתמש ב-<span class=\"onboarding-shortcut\">Ctrl+A</span> כדי לבחור את כל המודלים הגלויים."
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span> כדי לבחור מספר מודלים ולבצע פעולות בכמות גדולה.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> בחר את כל המודלים הגלויים, <span class=\"onboarding-shortcut\">Shift+Click</span> בחר טווח.<br>• <span class=\"onboarding-shortcut\">Esc</span> או לחיצה על אזור ריק מוציאים ממצב בכמות גדולה."
},
"searchOptions": {
"title": "אפשרויות חיפוש",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "תפריט הקשר",
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות נוספות."
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות כרטיס כמו העברה, מחיקה או עריכת מטא-נתונים."
},
"marqueeSelect": {
"title": "גרור כדי לבחור",
"content": "החזק את <strong>לחצן העכבר השמאלי</strong> לחוץ על אזור ריק של הרשת וגרור כדי לצייר מסגרת בחירה שבוחרת מספר כרטיסים בבת אחת."
},
"dragToSidebar": {
"title": "ארגון באמצעות גרירה",
"content": "גרור כרטיס מודל אל תיקייה בסרגל הצד כדי להעביר את הקובץ לשם. פעולה זו עובדת גם עם מספר כרטיסים נבחרים במצב בכמות גדולה."
},
"contextMenus": {
"title": "תפריטי הקשר נוספים",
"content": "במצב בכמות גדולה, <strong>לחץ לחיצה ימנית על כרטיס נבחר</strong> לפעולות בכמות גדולה. <strong>לחץ לחיצה ימנית על אזור ריק</strong> בדף לפעולות גלובליות כמו בדיקת עדכונים וניהול מודלים מוחרגים."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "הצג ב-CivitAI",
"notAvailableFromCivitai": "לא זמין מ-CivitAI",
"viewOnHuggingFace": "צפייה ב-Hugging Face",
"viewOnSource": "צפייה ב-{source}",
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
"copyLoRASyntax": "העתק תחביר LoRA",
"checkpointNameCopied": "שם Checkpoint הועתק",
@@ -116,6 +153,7 @@
"copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק",
"modelNameCopied": "שם המודל הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "לכל ה-{typePlural} כבר יש מטא-נתוני רישיון",
"error": "לא ניתן היה לרענן את מטא-נתוני הרישיון עבור {typePlural}: {message}"
},
"repairRecipes": {
"label": "תיקון נתוני מתכונים",
"loading": "מתקן נתוני מתכונים...",
"success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}"
},
"rematchRecipes": {
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
},
@@ -210,6 +238,7 @@
"recipes": "מתכונים",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "אחרים",
"statistics": "סטטיסטיקה"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "כללי",
"interface": "ממשק",
"library": "ספרייה"
"library": "ספרייה",
"organization": "ארגון",
"modelPaths": "נתיבי מודלים"
},
"search": {
"placeholder": "חיפוש בהגדרות...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "קיבוץ לפי מודל",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"stickyControls": "השארת סרגל הפעולות גלוי",
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {
"default": "ברירת מחדל",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"defaultVaeRoot": "תיקיית שורש VAE",
"defaultVaeRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של VAE להורדות, ייבוא והעברות",
"defaultUpscalerRoot": "תיקיית שורש Upscaler",
"defaultUpscalerRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Upscaler להורדות, ייבוא והעברות",
"defaultTextEncoderRoot": "תיקיית שורש Text Encoder",
"defaultTextEncoderRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Text Encoder להורדות, ייבוא והעברות",
"defaultClipVisionRoot": "תיקיית שורש CLIP Vision",
"defaultClipVisionRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של CLIP Vision להורדות, ייבוא והעברות",
"defaultControlnetRoot": "תיקיית שורש ControlNet",
"defaultControlnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של ControlNet להורדות, ייבוא והעברות",
"enableOtherModels": "ניהול מודלים אחרים",
"enableOtherModelsHelp": "כשהאפשרות כבויה, תיקיות VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet אינן נסרקות, עמוד המודלים האחרים נשאר מושבת ולא ניתן להוריד סוגי מודלים אלה.",
"otherSubTypes": "סוגי מודלים מנוהלים",
"otherSubTypesHelp": "בחר אילו קטגוריות של מודלים אחרים ייסרקו ויוצגו בעמוד המודלים האחרים.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "נתיב אחסון מתכונים",
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
}
},
"modelPaths": {
"title": "נתיבי ספריית המודלים",
"description": "תיקיות שורש ש-LoRA Manager סורק לאיתור המודלים שלך. אלו מיקומי המודלים הראשיים הנקראים מ-settings.json במצב עצמאי.",
"restartRequired": "נדרש אתחול כדי שהשינוי ייכנס לתוקף",
"coreTypes": "סוגי מודלים מרכזיים",
"otherTypes": "סוגי מודלים אחרים",
"otherTypesDisabledHint": "לא מופעלים סוגי מודלים אחרים. הפעל למעלה את הסוגים הדרושים לך כדי להגדיר את התיקיות שלהם.",
"saveSuccessRestart": "נתיבי ספריית המודלים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"pendingRestartNotice": "שינויי הנתיבים נשמרו. הפעל מחדש את LoRA Manager כדי שייכנסו לתוקף.",
"pendingRestartBannerTitle": "נדרשת הפעלה מחדש כדי להחיל את שינויי הנתיבים",
"pendingRestartBannerMessage": "נתיבי ספריית המודלים עודכנו. הפעל מחדש את שרת LoRA Manager כדי לסרוק את התיקיות החדשות.",
"folderKeys": {
"loras": "נתיבי LoRA",
"checkpoints": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embeddings": "נתיבי Embedding",
"vae": "נתיבי VAE",
"upscale_models": "נתיבי Upscaler",
"text_encoders": "נתיבי Text Encoder",
"clip": "נתיבי CLIP (ישן)",
"clip_vision": "נתיבי CLIP Vision",
"controlnet": "נתיבי ControlNet"
}
},
"directoryPicker": {
"title": "עיון בתיקיות",
"selectFolder": "בחר תיקייה זו",
"goUp": "למעלה",
"pathPlaceholder": "הזן נתיב...",
"go": "עבור",
"emptyFolder": "אין תתי-תיקיות",
"loadError": "טעינת התיקייה נכשלה"
},
"pathValidation": {
"valid": "הנתיב תקין",
"pathNotFound": "הנתיב לא קיים",
"notADirectory": "לא תיקייה",
"notReadable": "הנתיב לא ניתן לקריאה",
"notWritable": "הנתיב לא ניתן לכתיבה"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "תבנית תקינה"
}
},
"filenameTemplates": {
"title": "תבניות שמות קבצים",
"help": "הגדר שמות קבצים למודלים שהורדו לפי סוג מודל. השאר ריק כדי לשמור על שמות הקבצים המקוריים בעת ההורדה; החלת תבנית ריקה משחזרת את שמות הקבצים המקוריים המתועדים של מודלים ששונה שמם בעבר. שם הקובץ המקורי תמיד נשמר במטא-נתונים של המודל.",
"availablePlaceholders": "מצייני מקום זמינים:",
"templatePlaceholder": "הזן תבנית שם קובץ (למשל, {base_model}-{model_name}-{version_name})",
"applyButton": "החל על הספרייה כעת",
"applyHelp": "משנה את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית; עם תבנית ריקה, משחזר במקום זאת את שמות הקבצים המקוריים המתועדים. אזהרה: שינוי שם משנה את הנתיב היחסי שרואים הטוענים של ComfyUI, ולכן workflows קיימים המפנים לשם הקובץ הישן עשויים לדרוש עדכון. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
"confirmApply": "לשנות את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית שם הקובץ? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
"confirmRevert": "לשחזר את שמות הקבצים המקוריים המתועדים של כל הקבצים ששונה שמם בעבר מסוג מודל זה? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. קבצים ללא שם קובץ מקורי מתועד ידולגו.",
"validation": {
"restoreOriginal": "תקין (תבנית ריקה משחזרת שמות קבצים מקוריים)",
"invalidChars": "זוהו תווים לא חוקיים (שם קובץ אינו יכול להכיל / \\ < > : \" | ? *)",
"invalidPlaceholder": "מציין מקום לא חוקי: {placeholder}",
"validTemplate": "תבנית תקינה"
}
},
"exampleImages": {
"downloadLocation": "מיקום הורדה",
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
@@ -786,7 +894,6 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-נתונים",
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה",
@@ -822,14 +929,22 @@
"complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}"
},
"enrichHfAgent": "העשרת HF מטא-נתונים (AI)"
"filenameTemplateProgress": {
"initializing": "מאתחל החלת תבנית שם קובץ...",
"starting": "מחיל תבנית שם קובץ על {type}...",
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"complete": "החלת תבנית שם הקובץ הושלמה",
"error": "שגיאה: {error}"
},
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
},
"contextMenu": {
"refreshMetadata": "רענן נתוני CivitAI",
"checkUpdates": "בדוק עדכונים",
"linkModel": "קישור מודל",
"linkCivitai": "קשר מחדש ל-CivitAI",
"linkHuggingFace": "קישור ל-HuggingFace",
"linkModelSource": "קישור למקור מודל",
"copySyntax": "העתק תחביר LoRA",
"copyFilename": "העתק שם קובץ מודל",
"copyRecipeSyntax": "העתק תחביר מתכון",
@@ -842,7 +957,6 @@
"replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-נתונים",
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל",
@@ -852,7 +966,7 @@
"viewAllLoras": "הצג את כל ה-LoRAs",
"downloadMissingLoras": "הורד LoRAs חסרים",
"deleteRecipe": "מחק מתכון",
"enrichHfAgent": "העשרת HF מטא-נתונים (AI)"
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "המתכון הקודם (←)",
"nextWithShortcut": "המתכון הבא (→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "מודל בסיס",
"unknown": "לא ידוע"
},
"actions": {
"openFileLocation": "פתח מיקום קובץ",
"copyId": "העתק מזהה מתכון"
},
"openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה",
"failed": "פתיחת מיקום הקובץ נכשלה",
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}"
}
},
"workflow": {
"sendWorkflow": "שלח workflow ל-ComfyUI",
"sent": "ה-workflow נשלח ל-ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
},
"repair": {
"starting": "מתקן מטא-נתונים של מתכון...",
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
},
"reimport": {
"starting": "מייבא מתכון מחדש מהמקור...",
"success": "המתכון יובא מחדש בהצלחה",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "מודלי Embedding"
},
"other": {
"title": "מודלים אחרים",
"disabled": {
"title": "ניהול המודלים האחרים כבוי",
"description": "הפעל כדי לסרוק ולנהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, ולהוריד אותם מ-CivitAI.",
"enableButton": "הפעל מודלים אחרים",
"hint": "ניתן לשנות את סוגי המודלים המנוהלים מאוחר יותר בהגדרות > ספרייה.",
"enableFailed": "הפעלת המודלים האחרים נכשלה",
"downloadBlocked": "ניהול המודלים האחרים מושבת עבור סוג מודל זה. הפעל אותו בהגדרות > ספרייה כדי להוריד קובץ זה.",
"enableAction": "הפעל מודלים אחרים"
},
"noPaths": {
"title": "לא נמצאו תיקיות של מודלים אחרים",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את תיקיות המודלים שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
"hintStandalone": "נסרקים רק סוגי מודלים מופעלים; הפעל את הסוגים הדרושים לך תחת ספרייה > תיקיות ברירת מחדל.",
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
"openSettings": "פתח הגדרות",
"openModelPaths": "הגדר תיקיות מודלים",
"openSettingsFolder": "פתח תיקיית הגדרות"
}
},
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ",
"viewOptions": "אפשרויות תצוגה",
"treeView": "תצוגת עץ",
"listView": "תצוגת רשימה",
"recursiveOn": "כלול תיקיות משנה",
"recursiveOff": "רק התיקייה הנוכחית",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
"createFolder": "תיקייה חדשה",
"newSubfolder": "תיקיית משנה חדשה",
"showEmptyFolders": "הצג תיקיות ריקות",
"createFolderResult": {
"success": "התיקייה \"{name}\" נוצרה",
"failed": "יצירת התיקייה נכשלה: {message}",
"unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"deleteFolder": "מחק תיקייה",
"deleteFolderModal": {
"title": "למחוק את התיקייה?",
"message": "התיקייה וכל תוכנה יימחקו לצמיתות מהדיסק.",
"folderLabel": "תיקייה",
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
"success": "התיקייה \"{name}\" נמחקה",
"successWithFiles": "התיקייה \"{name}\" נמחקה יחד עם {count} פריטים נוספים",
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"renameFolder": "שנה שם תיקייה",
"renameFolderResult": {
"success": "שם התיקייה שונה ל-\"{name}\"",
"failed": "שינוי שם התיקייה נכשל: {message}",
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
},
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
"createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
@@ -1322,9 +1503,9 @@
"download": {
"title": "הורד מודל מכתובת URL",
"titleWithType": "הורד {type} מכתובת URL",
"civitaiUrl": "כתובת URL של CivitAI:",
"civitaiUrl": "כתובת URL של מודל:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive, Hugging Face או ModelScope בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
"selectAll": "בחר הכל",
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
@@ -1357,9 +1538,9 @@
"inLibrary": "בספרייה"
},
"errors": {
"invalidUrl": "פורמט URL של CivitAI לא חוקי",
"invalidUrl": "פורמט URL של מודל לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה",
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face / ModelScope באותה קבוצה.",
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "הקובץ הנוכחי:",
"downloading": "מוריד: {name}",
"metadata": "מטא-נתונים: {name}",
"indexingFile": "קורא קובץ מודל...",
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
"fetchingMetadata": "מביא מטא-נתונים...",
"transferred": "הורד: {downloaded} / {total}",
"transferredSimple": "הורד: {downloaded}",
"transferredUnknown": "הורד: --",
@@ -1441,6 +1626,11 @@
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
"action": "בדוק הכל"
},
"filenameTemplateConfirm": {
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
"titleRevert": "לשחזר שמות קבצים מקוריים?",
"revertButton": "שחזר שמות קבצים מקוריים"
},
"bulkAddTags": {
"title": "הוסף תגיות למספר מודלים",
"description": "הוסף תגיות ל-",
@@ -1467,6 +1657,41 @@
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"rematchOptions": {
"title": "התאמה מחדש של מתכונים",
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
"confirmButton": "התאם מחדש"
},
"rematchResults": {
"undo": "בטל",
"undone": "בוטל",
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
},
"rematchSummary": {
"title": "סיכום התאמה מחדש",
"successMessage": "הותאמו {entries} פריטים",
"failed": "ההתאמה מחדש נכשלה",
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
"statMatched": "פריטים שהותאמו",
"statReview": "טעוני סקירה",
"statUnresolved": "ללא התאמה",
"statErrors": "שגיאות",
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
"columnRecipe": "מתכון",
"columnEntry": "פריט",
"columnFile": "הקובץ שהותאם",
"columnUndo": "בטל",
"copyReport": "העתק דוח",
"close": "סגור",
"scope_global": "כל המתכונים",
"scope_bulk": "מתכונים שנבחרו",
"scope_single": "מתכון בודד"
},
"exampleAccess": {
"title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
"root": "שורש"
},
"linkHuggingFace": {
"title": "קישור ל-HuggingFace",
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-נתונים באמצעות AI.",
"urlLabel": "כתובת URL של מאגר HuggingFace:",
"linkModelSource": {
"title": "קישור למקור מודל",
"infoText": "הדבק את כתובת ה-URL של עמוד המודל כדי לשייך מודל זה למקורו. הקישור מאפשר העשרת מטא-נתונים באמצעות AI עבור מודלים של Hugging Face ו-ModelScope.",
"urlLabel": "כתובת URL של עמוד המודל:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
"helpText": "הזן את כתובת ה-URL המלאה של עמוד המודל. אתרים נתמכים:",
"enrichNote": "העשרת AI דורשת כרטיס מודל קריא. אתרים שאינם חושפים אותו (נכון להיום TensorArt) ניתנים לקישור בלבד.",
"urlRequired": "הזן כתובת URL של עמוד המודל.",
"invalidUrl": "כתובת URL לא נתמכת. אתרים נתמכים: Hugging Face, ModelScope, TensorArt.",
"linking": "מקשר את מקור המודל...",
"confirmAction": "שמור וקשר"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "ערך",
"add": "הוסף",
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y",
"invalidValue": "נא להזין מספר תקין",
"saveFailed": "שמירת הפרמטר הקבוע מראש נכשלה",
"added": "הפרמטר הקבוע מראש נוסף",
"updated": "הפרמטר הקבוע מראש עודכן"
},
"triggerWords": {
"label": "מילות טריגר",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר",
"copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
"deleteWord": "מחק מילת טריגר",
"suggestions": {
"noSuggestions": "אין הצעות זמינות",
@@ -1649,8 +1882,8 @@
"showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת",
"nextExample": "דוגמה הבאה",
"previousExample": "דוגמה קודמת ([)",
"nextExample": "דוגמה הבאה (])",
"noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
@@ -1736,7 +1969,7 @@
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
"error": "טעינת הגרסאות נכשלה.",
"missingModelId": "למודל זה אין מזהה מודל של CivitAI.",
"hfGroupInfo": "זוהי קבוצת מודלים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
"sourceGroupInfo": "זוהי קבוצת מודלים של {source}. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
"confirm": {
"delete": "למחוק גרסה זו מהספרייה שלך?"
},
@@ -1808,6 +2041,10 @@
"title": "מאתחל מנהל Embedding",
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
},
"other": {
"title": "מאתחל את מנהל המודלים האחרים",
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
},
"recipes": {
"title": "מאתחל מנהל מתכונים",
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "תחילת עבודה",
"updateVlogs": "בלוגי וידאו של עדכונים",
"documentation": "תיעוד"
"documentation": "תיעוד",
"shortcuts": "קיצורי דרך"
},
"gettingStarted": {
"title": "תחילת עבודה עם מנהל LoRA"
"title": "תחילת עבודה עם מנהל LoRA",
"replayTutorial": "הפעל את המדריך מחדש"
},
"shortcuts": {
"title": "קיצורי מקלדת ועכבר",
"groups": {
"general": "כללי",
"actions": "פעולות",
"selection": "בחירה ומצב בכמות גדולה",
"navigation": "ניווט",
"modelModal": "חלון מודל / מתכון",
"mediaViewer": "מציג מדיה / גלריית דוגמאות"
},
"keys": {
"click": "לחיצה",
"drag": "גרירה",
"rightClick": "לחיצה ימנית",
"letter": "אות",
"swipe": "החלקה"
},
"entries": {
"focusSearch": "העבר מיקוד לחיפוש",
"closeModal": "סגור חלון / פאנל",
"openShortcuts": "פתח את פאנל קיצורי הדרך הזה",
"refresh": "רענן את רשימת המודלים",
"fetchMetadata": "אחזר מטא-נתונים מ-CivitAI (דפי מודלים בלבד)",
"downloadModel": "הורד מודל (דפי מודלים בלבד)",
"toggleBulkMode": "הפעל/כבה מצב בכמות גדולה",
"selectAll": "בחר את כל המודלים הגלויים",
"rangeSelect": "בחר טווח",
"marqueeSelect": "בחר כרטיסים במסגרת בחירה (באזור ריק של הרשת)",
"exitBulkMode": "צא ממצב בכמות גדולה",
"bulkActions": "על כרטיס נבחר: תפריט פעולות בכמות גדולה",
"globalActions": "באזור ריק בדף: תפריט פעולות גלובליות (בדיקת עדכונים, ניהול מודלים מוחרגים)",
"scrollPages": "גלול בין דפים",
"jumpAlphabet": "קפוץ בעזרת סרגל האותיות",
"prevNext": "מודל קודם / הבא",
"deleteEntry": "מחק",
"cycleMedia": "עבור בין פריטי מדיה ([ / ] בגלריית הדוגמאות)",
"swipeTouch": "עבור בין פריטי מדיה במכשירי מגע",
"closeViewer": "סגור את המציג"
}
},
"updateVlogs": {
"title": "עדכונים אחרונים",
@@ -1934,7 +2213,8 @@
"settings": "הגדרות ותצורה",
"extensions": "הרחבות",
"newBadge": "חדש"
}
},
"newContentBadge": "חדש"
},
"update": {
"title": "בדוק עדכונים",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
"noModelsSelected": "לא נבחרו מודלים"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה",
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
"noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
"settingsUpdated": "הגדרות עודכנו: {setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה",
"fetchMetadataFirst": "אנא אחזר מטא-נתונים מ-CivitAI תחילה",
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
"missingHash": "ה-hash של המודל אינו זמין"
"missingHash": "ה-hash של המודל אינו זמין",
"enrichNeedsSource": "קשר מודל זה למקור מודל תחילה (קישור מודל → קישור למקור מודל)",
"enrichUnsupportedSource": "העשרת AI אינה זמינה עבור מודלים של {source}"
},
"exampleImages": {
"pathUpdated": "נתיב תמונות הדוגמה עודכן בהצלחה",
@@ -2487,6 +2770,17 @@
"rebuilding": "בונה מחדש את המטמון...",
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
"retry": "נסה שוב"
},
"otherModels": {
"title": "ניהול המודלים האחרים זמין",
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
"enable": "הפעל מודלים אחרים",
"openSettings": "פתח הגדרות"
},
"pager": {
"previous": "הודעה קודמת",
"next": "הודעה הבאה",
"position": "הודעה {current} מתוך {total}"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "キャンセル",
"confirm": "確認",
"reorder": {
"dragHandle": "ドラッグして並べ替え"
},
"actions": {
"save": "保存",
"cancel": "キャンセル",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "{type}を更新中...",
"fullRebuilding": "{type}を完全に再構築中...",
"actionRefresh": "更新",
"actionFullRebuild": "完全な再構築",
"actionRefreshLower": "更新",
"actionRebuildLower": "再構築",
"stages": {
"scan_folders": "フォルダをスキャン中...",
"count_models": "{total} 件のファイルが見つかりました",
"process_models": "モデルを処理中",
"reconcile_scan": "変更を確認中...",
"process_new": "新しいモデルを処理中",
"finalizing": "最終処理中..."
},
"eta": {
"lessThanMinute": "残り1分未満",
"minutes": "残り約 {minutes} 分",
"hours": "残り約 {hours} 時間 {minutes} 分"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "一括操作",
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入ります。複数のモデルを選択して一括操作が可能です。<span class=\"onboarding-shortcut\">Ctrl+A</span>で表示中のモデルをすべて選択できます。"
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入り複数のモデルを選択して一括操作を実行できます。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>で表示中のモデルをすべて選択、<span class=\"onboarding-shortcut\">Shift+Click</span>で範囲選択。<br>• <span class=\"onboarding-shortcut\">Esc</span>キーまたは空白部分をクリックすると一括モードを終了します。"
},
"searchOptions": {
"title": "検索オプション",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "コンテキストメニュー",
"content": "<strong>モデルカードを右クリック</strong>すると追加の操作ができるコンテキストメニューが表示されます。"
"content": "<strong>モデルカードを右クリック</strong>すると、移動、削除、メタデータの編集などのカード操作を含むコンテキストメニューが表示されます。"
},
"marqueeSelect": {
"title": "ドラッグで選択",
"content": "グリッドの空白部分で<strong>マウスの左ボタン</strong>を押したままドラッグすると、複数のカードを一度に選択する矩形(マーキー)を描画できます。"
},
"dragToSidebar": {
"title": "ドラッグで整理",
"content": "モデルカードをサイドバーのフォルダにドラッグすると、ファイルをそこに移動できます。一括モードで複数選択したカードでも同様に機能します。"
},
"contextMenus": {
"title": "その他のコンテキストメニュー",
"content": "一括モードでは、<strong>選択したカードを右クリック</strong>すると一括操作メニューが表示されます。<strong>ページの空白部分を右クリック</strong>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "CivitAIで表示",
"notAvailableFromCivitai": "CivitAIでは利用できません",
"viewOnHuggingFace": "Hugging Face で見る",
"viewOnSource": "{source} で見る",
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
"copyLoRASyntax": "LoRA構文をコピー",
"checkpointNameCopied": "Checkpointの名前をコピーしました",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"modelNameCopied": "モデル名をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
},
@@ -179,20 +217,10 @@
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
},
"repairRecipes": {
"label": "レシピデータの修復",
"loading": "レシピデータを修復中...",
"success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}件のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}"
},
"rematchRecipes": {
"label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...",
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}"
},
@@ -210,6 +238,7 @@
"recipes": "レシピ",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "その他",
"statistics": "統計"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "一般",
"interface": "インターフェース",
"library": "ライブラリ"
"library": "ライブラリ",
"organization": "整理",
"modelPaths": "モデルパス"
},
"search": {
"placeholder": "設定を検索...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"stickyControls": "アクションバーを常に表示",
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
"displayDensity": "表示密度",
"displayDensityOptions": {
"default": "デフォルト",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "Embeddingルート",
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"defaultVaeRoot": "VAEルート",
"defaultVaeRootHelp": "ダウンロード、インポート、移動用のデフォルトVAEルートディレクトリを設定",
"defaultUpscalerRoot": "Upscalerルート",
"defaultUpscalerRootHelp": "ダウンロード、インポート、移動用のデフォルトUpscalerルートディレクトリを設定",
"defaultTextEncoderRoot": "Text Encoderルート",
"defaultTextEncoderRootHelp": "ダウンロード、インポート、移動用のデフォルトText Encoderルートディレクトリを設定",
"defaultClipVisionRoot": "CLIP Visionルート",
"defaultClipVisionRootHelp": "ダウンロード、インポート、移動用のデフォルトCLIP Visionルートディレクトリを設定",
"defaultControlnetRoot": "ControlNetルート",
"defaultControlnetRootHelp": "ダウンロード、インポート、移動用のデフォルトControlNetルートディレクトリを設定",
"enableOtherModels": "その他のモデル管理",
"enableOtherModelsHelp": "オフにすると、VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet フォルダーはスキャンされず、その他のモデルページは無効のままになり、これらのモデルタイプはダウンロードできません。",
"otherSubTypes": "管理するモデルタイプ",
"otherSubTypesHelp": "その他のモデルページでスキャンおよび表示するカテゴリを選択します。",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "レシピ保存先",
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
}
},
"modelPaths": {
"title": "モデルライブラリパス",
"description": "LoRA Managerがモデルをスキャンするルートフォルダーです。スタンドアロンモードでは settings.json から読み込まれる主要なモデルの場所になります。",
"restartRequired": "変更を有効にするには再起動が必要です",
"coreTypes": "コアモデルタイプ",
"otherTypes": "その他のモデルタイプ",
"otherTypesDisabledHint": "その他のモデルタイプが有効になっていません。フォルダーを設定するには、上で必要なタイプをオンにしてください。",
"saveSuccessRestart": "モデルライブラリパスを更新しました。変更を適用するには再起動が必要です。",
"pendingRestartNotice": "パスの変更を保存しました。変更を有効にするにはLoRA Managerを再起動してください。",
"pendingRestartBannerTitle": "パスの変更を適用するには再起動が必要です",
"pendingRestartBannerMessage": "モデルライブラリパスが更新されました。新しいフォルダーをスキャンするにはLoRA Managerサーバーを再起動してください。",
"folderKeys": {
"loras": "LoRAパス",
"checkpoints": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embeddings": "Embeddingパス",
"vae": "VAEパス",
"upscale_models": "Upscalerパス",
"text_encoders": "Text Encoderパス",
"clip": "CLIPパス(レガシー)",
"clip_vision": "CLIP Visionパス",
"controlnet": "ControlNetパス"
}
},
"directoryPicker": {
"title": "フォルダを参照",
"selectFolder": "このフォルダを選択",
"goUp": "上へ",
"pathPlaceholder": "パスを入力...",
"go": "移動",
"emptyFolder": "サブフォルダがありません",
"loadError": "ディレクトリの読み込みに失敗しました"
},
"pathValidation": {
"valid": "パスは有効です",
"pathNotFound": "パスが存在しません",
"notADirectory": "ディレクトリではありません",
"notReadable": "パスは読み取れません",
"notWritable": "パスは書き込めません"
},
"priorityTags": {
"title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "有効なテンプレート"
}
},
"filenameTemplates": {
"title": "ファイル名テンプレート",
"help": "ダウンロードしたモデルのファイル名をモデルタイプごとに設定します。空欄にするとダウンロード時は元のファイル名が保持され、空のテンプレートを適用すると以前にリネームされたモデルの記録済みの元のファイル名が復元されます。元のファイル名は常にモデルのメタデータに保持されます。",
"availablePlaceholders": "利用可能なプレースホルダー:",
"templatePlaceholder": "ファイル名テンプレートを入力(例:{base_model}-{model_name}-{version_name}",
"applyButton": "ライブラリに今すぐ適用",
"applyHelp": "このモデルタイプの既存のすべてのファイルをテンプレートに従ってリネームします。空のテンプレートの場合は、代わりに記録済みの元のファイル名を復元します。警告:リネームするとComfyUIローダーから見える相対パスが変わるため、古いファイル名を参照する既存のワークフローは更新が必要になる場合があります。元のファイル名は各モデルのメタデータに保持されます。",
"confirmApply": "このモデルタイプの既存のすべてのファイルをファイル名テンプレートに従ってリネームしますか?ComfyUIローダーから見える相対パスが変わります。元のファイル名は各モデルのメタデータに保持されます。",
"confirmRevert": "このモデルタイプの以前にリネームされたすべてのファイルについて、記録済みの元のファイル名を復元しますか?ComfyUIローダーから見える相対パスが変わります。記録済みの元のファイル名がないファイルはスキップされます。",
"validation": {
"restoreOriginal": "有効(空のテンプレートは元のファイル名を復元)",
"invalidChars": "無効な文字が検出されました(ファイル名に / \\ < > : \" | ? * は使用できません)",
"invalidPlaceholder": "無効なプレースホルダー:{placeholder}",
"validTemplate": "有効なテンプレート"
}
},
"exampleImages": {
"downloadLocation": "ダウンロード場所",
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
@@ -786,7 +894,6 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認",
@@ -822,14 +929,22 @@
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
},
"enrichHfAgent": "HF メタデータをAIで補完"
"filenameTemplateProgress": {
"initializing": "ファイル名テンプレートの適用を初期化中...",
"starting": "{type}にファイル名テンプレートを適用中...",
"processing": "処理中({processed}/{total}- {success} リネーム、{skipped} スキップ、{failures} 失敗",
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
"complete": "ファイル名テンプレートの適用が完了しました",
"error": "エラー:{error}"
},
"enrichHfAgent": "メタデータをAIで補完"
},
"contextMenu": {
"refreshMetadata": "CivitAIデータを更新",
"checkUpdates": "更新確認",
"linkModel": "モデルをリンク",
"linkCivitai": "CivitAI にリンク",
"linkHuggingFace": "HuggingFace にリンク",
"linkModelSource": "モデルソースにリンク",
"copySyntax": "LoRA構文をコピー",
"copyFilename": "モデルファイル名をコピー",
"copyRecipeSyntax": "レシピ構文をコピー",
@@ -842,7 +957,6 @@
"replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外",
@@ -852,7 +966,7 @@
"viewAllLoras": "すべてのLoRAを表示",
"downloadMissingLoras": "不足しているLoRAをダウンロード",
"deleteRecipe": "レシピを削除",
"enrichHfAgent": "HF メタデータをAIで補完"
"enrichHfAgent": "メタデータをAIで補完"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "前のレシピ(←)",
"nextWithShortcut": "次のレシピ(→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "ベースモデル",
"unknown": "不明"
},
"actions": {
"openFileLocation": "ファイルの場所を開く",
"copyId": "レシピIDをコピー"
},
"openFileLocation": {
"success": "ファイルの場所を正常に開きました",
"failed": "ファイルの場所を開くのに失敗しました",
"copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}"
}
},
"workflow": {
"sendWorkflow": "ワークフローをComfyUIへ送信",
"sent": "ワークフローをComfyUIへ送信しました",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
},
"repair": {
"starting": "レシピのメタデータを修復中...",
"success": "レシピのメタデータが正常に修復されました",
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
},
"reimport": {
"starting": "ソースからレシピを再インポート中...",
"success": "レシピの再インポートが完了しました",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Embeddingモデル"
},
"other": {
"title": "その他のモデル",
"disabled": {
"title": "その他のモデル管理はオフです",
"description": "有効にすると VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enableButton": "その他のモデルを有効にする",
"hint": "管理するモデルタイプは後で「設定 > ライブラリ」で変更できます。",
"enableFailed": "その他のモデルの有効化に失敗しました",
"downloadBlocked": "このモデルタイプではその他のモデル管理が無効です。このファイルをダウンロードするには「設定 > ライブラリ」で有効にしてください。",
"enableAction": "その他のモデルを有効にする"
},
"noPaths": {
"title": "その他のモデルのフォルダーが見つかりません",
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりませんでした。「設定 > モデルパス」でモデルフォルダーを追加し、LoRA Managerを再起動してください。",
"hintStandalone": "有効になっているモデルタイプのみがスキャンされます。必要なタイプは「ライブラリ > デフォルトルート」で有効にしてください。",
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
"openSettings": "設定を開く",
"openModelPaths": "モデルフォルダーを設定",
"openSettingsFolder": "設定フォルダーを開く"
}
},
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
"collapseAllDisabled": "リスト表示では利用できません",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え",
"viewOptions": "表示オプション",
"treeView": "ツリー表示",
"listView": "リスト表示",
"recursiveOn": "サブフォルダーを含める",
"recursiveOff": "現在のフォルダーのみ",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません",
"createFolder": "新規フォルダ",
"newSubfolder": "新規サブフォルダ",
"showEmptyFolders": "空のフォルダを表示",
"createFolderResult": {
"success": "フォルダ \"{name}\" を作成しました",
"failed": "フォルダの作成に失敗しました: {message}",
"unsupported": "このページではフォルダを作成できません",
"noRoot": "モデルルートが設定されていません"
},
"deleteFolder": "フォルダを削除",
"deleteFolderModal": {
"title": "フォルダを削除しますか?",
"message": "フォルダとその内容はすべてディスクから完全に削除されます。",
"folderLabel": "フォルダ",
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
"success": "フォルダ \"{name}\" を削除しました",
"successWithFiles": "フォルダ \"{name}\" を削除し、他に {count} 件の項目も削除しました",
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
},
"renameFolder": "フォルダ名を変更",
"renameFolderResult": {
"success": "フォルダ名を \"{name}\" に変更しました",
"failed": "フォルダ名の変更に失敗しました: {message}",
"targetExists": "同じ名前のフォルダが既に存在します",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダ名を変更できません",
"noRoot": "モデルルートが設定されていません"
},
"dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません"
},
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成ます"
"createHint": "上部の新規フォルダボタンからフォルダを作成できます"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
@@ -1322,9 +1503,9 @@
"download": {
"title": "URLからモデルをダウンロード",
"titleWithType": "URLから{type}をダウンロード",
"civitaiUrl": "CivitAI URL",
"civitaiUrl": "モデル URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
"urlHint": "1行に1つのCivitAI、CivArchive、Hugging Face、またはModelScope URLを入力してください。複数のURLを一括ダウンロードできます。",
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
"selectAll": "すべて選択",
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
@@ -1357,9 +1538,9 @@
"inLibrary": "ライブラリ内"
},
"errors": {
"invalidUrl": "無効なCivitAI URL形式",
"invalidUrl": "無効なモデル URL 形式",
"noVersions": "このモデルの利用可能なバージョンがありません",
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
"mixedSources": "同じバッチ内でCivitAIとHugging Face / ModelScopeのURLを混在させることはできません。",
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "現在のファイル:",
"downloading": "ダウンロード中: {name}",
"metadata": "メタデータ: {name}",
"indexingFile": "モデルファイルを読み込み中...",
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
"fetchingMetadata": "メタデータを取得中...",
"transferred": "ダウンロード済み: {downloaded} / {total}",
"transferredSimple": "ダウンロード済み: {downloaded}",
"transferredUnknown": "ダウンロード済み: --",
@@ -1441,6 +1626,11 @@
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"action": "すべて確認"
},
"filenameTemplateConfirm": {
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
"titleRevert": "元のファイル名を復元しますか?",
"revertButton": "元のファイル名を復元"
},
"bulkAddTags": {
"title": "複数モデルにタグを追加",
"description": "タグを追加するモデル:",
@@ -1467,6 +1657,41 @@
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"rematchOptions": {
"title": "レシピの再マッチング",
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
"confirmButton": "再マッチング"
},
"rematchResults": {
"undo": "元に戻す",
"undone": "元に戻しました",
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
},
"rematchSummary": {
"title": "再マッチングの概要",
"successMessage": "{entries} エントリをマッチングしました",
"failed": "再マッチングに失敗しました",
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
"statMatched": "マッチしたエントリ",
"statReview": "要確認",
"statUnresolved": "マッチなし",
"statErrors": "エラー",
"reviewSection": "確認が必要なファイル名マッチ({count})",
"columnRecipe": "レシピ",
"columnEntry": "エントリ",
"columnFile": "マッチしたファイル",
"columnUndo": "元に戻す",
"copyReport": "レポートをコピー",
"close": "閉じる",
"scope_global": "すべてのレシピ",
"scope_bulk": "選択したレシピ",
"scope_single": "単一のレシピ"
},
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
"root": "ルート"
},
"linkHuggingFace": {
"title": "HuggingFace にリンク",
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
"urlLabel": "HuggingFace リポジトリ URL",
"linkModelSource": {
"title": "モデルソースにリンク",
"infoText": "モデルページの URL を貼り付けて、このモデルをソースに関連付けます。リンクすると、Hugging Face と ModelScope のモデルで AI によるメタデータ補完が有効になります。",
"urlLabel": "モデルページ URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
"helpText": "完全なモデルページ URL を入力してください。対応サイト:",
"enrichNote": "AI 補完には読み取り可能なモデルカードが必要です。モデルカードを公開していないサイト(現在は TensorArt)はリンクのみ可能です。",
"urlRequired": "モデルページの URL を入力してください。",
"invalidUrl": "サポートされていない URL です。対応サイト:Hugging Face、ModelScope、TensorArt。",
"linking": "モデルソースをリンクしています...",
"confirmAction": "保存&リンク"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "値",
"add": "追加",
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください",
"invalidValue": "有効な数値を入力してください",
"saveFailed": "プリセットパラメータの保存に失敗しました",
"added": "プリセットパラメータを追加しました",
"updated": "プリセットパラメータを更新しました"
},
"triggerWords": {
"label": "トリガーワード",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
"deleteWord": "トリガーワードを削除",
"suggestions": {
"noSuggestions": "提案はありません",
@@ -1649,8 +1882,8 @@
"showCount": "例を表示({count}",
"hideExamples": "例を非表示",
"addExamples": "例を追加",
"previousExample": "前の例",
"nextExample": "次の例",
"previousExample": "前の例[",
"nextExample": "次の例]",
"noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
@@ -1736,7 +1969,7 @@
"empty": "このモデルにはまだバージョン履歴がありません。",
"error": "バージョンの読み込みに失敗しました。",
"missingModelId": "このモデルにはCivitAIのモデルIDがありません。",
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
"sourceGroupInfo": "これは {source} モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
"confirm": {
"delete": "このバージョンをライブラリから削除しますか?"
},
@@ -1808,6 +2041,10 @@
"title": "Embedding Managerを初期化中",
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
},
"other": {
"title": "その他のモデルマネージャーを初期化中",
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
},
"recipes": {
"title": "レシピマネージャーを初期化中",
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "はじめに",
"updateVlogs": "更新Vlog",
"documentation": "ドキュメント"
"documentation": "ドキュメント",
"shortcuts": "ショートカット"
},
"gettingStarted": {
"title": "LoRA Managerを始める"
"title": "LoRA Managerを始める",
"replayTutorial": "チュートリアルをもう一度再生"
},
"shortcuts": {
"title": "キーボード & マウスのショートカット",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選択 & 一括モード",
"navigation": "ナビゲーション",
"modelModal": "モデル / レシピモーダル",
"mediaViewer": "メディアビューア / ショーケース"
},
"keys": {
"click": "クリック",
"drag": "ドラッグ",
"rightClick": "右クリック",
"letter": "文字キー",
"swipe": "スワイプ"
},
"entries": {
"focusSearch": "検索にフォーカス",
"closeModal": "モーダル / パネルを閉じる",
"openShortcuts": "このショートカットパネルを開く",
"refresh": "モデルリストを更新",
"fetchMetadata": "CivitAIからメタデータを取得(モデルページのみ)",
"downloadModel": "モデルをダウンロード(モデルページのみ)",
"toggleBulkMode": "一括モードを切り替え",
"selectAll": "表示中のモデルをすべて選択",
"rangeSelect": "範囲選択",
"marqueeSelect": "カードを矩形選択(グリッドの空白部分で)",
"exitBulkMode": "一括モードを終了",
"bulkActions": "選択したカード上:一括操作メニュー",
"globalActions": "ページの空白部分:グローバル操作メニュー(更新の確認、除外モデルの管理)",
"scrollPages": "ページをスクロール",
"jumpAlphabet": "アルファベットバーへジャンプ",
"prevNext": "前 / 次のモデル",
"deleteEntry": "削除",
"cycleMedia": "メディアを切り替え(ショーケースギャラリーでは [ / ])",
"swipeTouch": "タッチデバイスでメディアを切り替え",
"closeViewer": "ビューアを閉じる"
}
},
"updateVlogs": {
"title": "最新の更新",
@@ -1934,7 +2213,8 @@
"settings": "設定&構成",
"extensions": "拡張機能",
"newBadge": "新着"
}
},
"newContentBadge": "新着"
},
"update": {
"title": "更新確認",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
"noModelsSelected": "モデルが選択されていません"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません",
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
"noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
"noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
"reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
"recipesPathUpdated": "レシピ保存先を更新しました",
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
"settingsUpdated": "設定が更新されました:{setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
"noCivitaiInfo": "CivitAI情報が利用できません",
"missingHash": "モデルハッシュが利用できません"
"missingHash": "モデルハッシュが利用できません",
"enrichNeedsSource": "まずこのモデルをモデルソースにリンクしてください(モデルをリンク → モデルソースにリンク)",
"enrichUnsupportedSource": "{source} モデルでは AI 補完を利用できません"
},
"exampleImages": {
"pathUpdated": "例画像パスが正常に更新されました",
@@ -2487,6 +2770,17 @@
"rebuilding": "キャッシュを再構築中...",
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
"retry": "再試行"
},
"otherModels": {
"title": "その他のモデル管理が利用可能になりました",
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enable": "その他のモデルを有効にする",
"openSettings": "設定を開く"
},
"pager": {
"previous": "前の通知",
"next": "次の通知",
"position": "{total} 件中 {current} 件目の通知"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "취소",
"confirm": "확인",
"reorder": {
"dragHandle": "드래그하여 순서 변경"
},
"actions": {
"save": "저장",
"cancel": "취소",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "{type} 새로고침 중...",
"fullRebuilding": "{type} 전체 재구성 중...",
"actionRefresh": "새로고침",
"actionFullRebuild": "전체 재구성",
"actionRefreshLower": "새로고침",
"actionRebuildLower": "재구성",
"stages": {
"scan_folders": "폴더 스캔 중...",
"count_models": "파일 {total}개 발견",
"process_models": "모델 처리 중",
"reconcile_scan": "변경 사항 확인 중...",
"process_new": "새 모델 처리 중",
"finalizing": "마무리 중..."
},
"eta": {
"lessThanMinute": "남은 시간 1분 미만",
"minutes": "약 {minutes}분 남음",
"hours": "약 {hours}시간 {minutes}분 남음"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "일괄 작업",
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하세요. 여러 모델을 선택하 일괄 작업을 수행할 수 있습니다. <span class=\"onboarding-shortcut\">Ctrl+A</span>로 모든 표시된 모델을 선택하세요."
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하 여러 모델을 선택하 일괄 작업을 수행하세요.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>로 모든 표시된 모델을 선택하고, <span class=\"onboarding-shortcut\">Shift+Click</span>으로 범위를 선택할 수 있습니다.<br>• <span class=\"onboarding-shortcut\">Esc</span> 키를 누르거나 빈 영역을 클릭하면 일괄 모드가 종료됩니다."
},
"searchOptions": {
"title": "검색 옵션",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "컨텍스트 메뉴",
"content": "<strong>오른쪽 클릭</strong>으로 모델 카드의 추가 작업 메뉴를 사용할 수 있습니다."
"content": "모델 카드를 <strong>오른쪽 클릭</strong>하면 이동, 삭제, 메타데이터 편집 같은 카드 작업이 담긴 컨텍스트 메뉴를 사용할 수 있습니다."
},
"marqueeSelect": {
"title": "드래그로 선택",
"content": "그리드의 빈 영역에서 <strong>마우스 왼쪽 버튼</strong>을 누른 채 드래그하여 여러 카드를 한 번에 선택하는 선택 영역을 그리세요."
},
"dragToSidebar": {
"title": "드래그로 정리",
"content": "모델 카드를 사이드바의 폴더로 드래그하면 파일이 해당 폴더로 이동합니다. 일괄 모드에서 선택한 여러 카드에도 적용됩니다."
},
"contextMenus": {
"title": "더 많은 컨텍스트 메뉴",
"content": "일괄 모드에서는 <strong>선택한 카드를 오른쪽 클릭</strong>하여 일괄 작업을 사용할 수 있습니다. 페이지의 <strong>빈 영역을 오른쪽 클릭</strong>하면 업데이트 확인이나 제외된 모델 관리 같은 전역 작업을 사용할 수 있습니다."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "CivitAI에서 보기",
"notAvailableFromCivitai": "CivitAI에서 사용할 수 없음",
"viewOnHuggingFace": "Hugging Face에서 보기",
"viewOnSource": "{source}에서 보기",
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
"copyLoRASyntax": "LoRA 문법 복사",
"checkpointNameCopied": "Checkpoint 이름 복사됨",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨",
"modelNameCopied": "모델 이름 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
},
@@ -179,20 +217,10 @@
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
},
"repairRecipes": {
"label": "레시피 데이터 복구",
"loading": "레시피 데이터 복구 중...",
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}"
},
"rematchRecipes": {
"label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}"
},
@@ -210,6 +238,7 @@
"recipes": "레시피",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "기타",
"statistics": "통계"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "일반",
"interface": "인터페이스",
"library": "라이브러리"
"library": "라이브러리",
"organization": "정리",
"modelPaths": "모델 경로"
},
"search": {
"placeholder": "설정 검색...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"stickyControls": "작업 표시줄 항상 표시",
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
"defaultEmbeddingRoot": "Embedding 루트",
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"defaultVaeRoot": "VAE 루트",
"defaultVaeRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 VAE 루트 디렉토리를 설정합니다",
"defaultUpscalerRoot": "Upscaler 루트",
"defaultUpscalerRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Upscaler 루트 디렉토리를 설정합니다",
"defaultTextEncoderRoot": "Text Encoder 루트",
"defaultTextEncoderRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Text Encoder 루트 디렉토리를 설정합니다",
"defaultClipVisionRoot": "CLIP Vision 루트",
"defaultClipVisionRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 CLIP Vision 루트 디렉토리를 설정합니다",
"defaultControlnetRoot": "ControlNet 루트",
"defaultControlnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 ControlNet 루트 디렉토리를 설정합니다",
"enableOtherModels": "기타 모델 관리",
"enableOtherModelsHelp": "끄면 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 폴더를 스캔하지 않으며, 기타 모델 페이지가 비활성화된 상태로 유지되고 이러한 모델 유형은 다운로드할 수 없습니다.",
"otherSubTypes": "관리할 모델 유형",
"otherSubTypesHelp": "기타 모델 페이지에서 스캔하고 표시할 카테고리를 선택합니다.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "레시피 저장 경로",
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
}
},
"modelPaths": {
"title": "모델 라이브러리 경로",
"description": "LoRA Manager가 모델을 스캔하는 루트 폴더입니다. 독립 실행 모드에서는 settings.json에서 읽어오는 기본 모델 위치입니다.",
"restartRequired": "변경 사항을 적용하려면 재시작이 필요합니다",
"coreTypes": "핵심 모델 유형",
"otherTypes": "기타 모델 유형",
"otherTypesDisabledHint": "활성화된 기타 모델 유형이 없습니다. 위에서 필요한 유형을 켜면 해당 폴더를 구성할 수 있습니다.",
"saveSuccessRestart": "모델 라이브러리 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"pendingRestartNotice": "경로 변경 사항이 저장되었습니다. 적용하려면 LoRA Manager를 재시작하세요.",
"pendingRestartBannerTitle": "경로 변경 사항을 적용하려면 재시작이 필요합니다",
"pendingRestartBannerMessage": "모델 라이브러리 경로가 업데이트되었습니다. 새 폴더를 스캔하려면 LoRA Manager 서버를 재시작하세요.",
"folderKeys": {
"loras": "LoRA 경로",
"checkpoints": "Checkpoint 경로",
"unet": "Diffusion Model 경로",
"embeddings": "Embedding 경로",
"vae": "VAE 경로",
"upscale_models": "Upscaler 경로",
"text_encoders": "Text Encoder 경로",
"clip": "CLIP 경로 (레거시)",
"clip_vision": "CLIP Vision 경로",
"controlnet": "ControlNet 경로"
}
},
"directoryPicker": {
"title": "폴더 찾아보기",
"selectFolder": "이 폴더 선택",
"goUp": "위로",
"pathPlaceholder": "경로 입력...",
"go": "이동",
"emptyFolder": "하위 폴더 없음",
"loadError": "디렉터리를 불러오지 못했습니다"
},
"pathValidation": {
"valid": "유효한 경로입니다",
"pathNotFound": "경로가 존재하지 않습니다",
"notADirectory": "디렉터리가 아닙니다",
"notReadable": "경로를 읽을 수 없습니다",
"notWritable": "경로에 쓸 수 없습니다"
},
"priorityTags": {
"title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -589,6 +681,22 @@
"validTemplate": "유효한 템플릿"
}
},
"filenameTemplates": {
"title": "파일명 템플릿",
"help": "모델 유형별로 다운로드되는 모델의 파일명을 구성합니다. 비워 두면 다운로드 시 원본 파일명을 유지하고, 빈 템플릿을 적용하면 이전에 이름이 변경된 모델의 기록된 원본 파일명이 복원됩니다. 원본 파일명은 항상 모델의 메타데이터에 보존됩니다.",
"availablePlaceholders": "사용 가능한 플레이스홀더:",
"templatePlaceholder": "파일명 템플릿 입력 (예: {base_model}-{model_name}-{version_name})",
"applyButton": "지금 라이브러리에 적용",
"applyHelp": "이 모델 유형의 기존 파일을 모두 템플릿에 따라 이름 변경합니다. 빈 템플릿이면 기록된 원본 파일명을 대신 복원합니다. 경고: 이름을 변경하면 ComfyUI 로더에서 보이는 상대 경로가 바뀌므로 이전 파일명을 참조하는 기존 워크플로를 업데이트해야 할 수 있습니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
"confirmApply": "이 모델 유형의 기존 파일을 모두 파일명 템플릿에 따라 이름 변경하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
"confirmRevert": "이 모델 유형에서 이전에 이름이 변경된 모든 파일의 기록된 원본 파일명을 복원하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 기록된 원본 파일명이 없는 파일은 건너뜁니다.",
"validation": {
"restoreOriginal": "유효함 (빈 템플릿은 원본 파일명을 복원합니다)",
"invalidChars": "잘못된 문자가 감지됨 (파일명에는 / \\ < > : \" | ? * 문자를 사용할 수 없습니다)",
"invalidPlaceholder": "잘못된 플레이스홀더: {placeholder}",
"validTemplate": "유효한 템플릿"
}
},
"exampleImages": {
"downloadLocation": "다운로드 위치",
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
@@ -786,7 +894,6 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인",
@@ -822,14 +929,22 @@
"complete": "자동 정리 완료",
"error": "오류: {error}"
},
"enrichHfAgent": "HF AI로 메타데이터 보강"
"filenameTemplateProgress": {
"initializing": "파일명 템플릿 적용 초기화 중...",
"starting": "{type}에 파일명 템플릿 적용 중...",
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"complete": "파일명 템플릿 적용 완료",
"error": "오류: {error}"
},
"enrichHfAgent": "AI로 메타데이터 보강"
},
"contextMenu": {
"refreshMetadata": "CivitAI 데이터 새로고침",
"checkUpdates": "업데이트 확인",
"linkModel": "모델 연결",
"linkCivitai": "CivitAI에 연결",
"linkHuggingFace": "HuggingFace에 연결",
"linkModelSource": "모델 소스에 연결",
"copySyntax": "LoRA 문법 복사",
"copyFilename": "모델 파일명 복사",
"copyRecipeSyntax": "레시피 문법 복사",
@@ -842,7 +957,6 @@
"replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"rematchMetadata": "로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외",
@@ -852,7 +966,7 @@
"viewAllLoras": "모든 LoRA 보기",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"deleteRecipe": "레시피 삭제",
"enrichHfAgent": "HF AI로 메타데이터 보강"
"enrichHfAgent": "AI로 메타데이터 보강"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "이전 레시피(←)",
"nextWithShortcut": "다음 레시피(→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "베이스 모델",
"unknown": "알 수 없음"
},
"actions": {
"openFileLocation": "파일 위치 열기",
"copyId": "레시피 ID 복사"
},
"openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다",
"failed": "파일 위치 열기에 실패했습니다",
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}"
}
},
"workflow": {
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
"sent": "워크플로를 ComfyUI로 보냈습니다",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
},
"repair": {
"starting": "레시피 메타데이터 복구 중...",
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
},
"reimport": {
"starting": "소스에서 레시피를 다시 가져오는 중...",
"success": "레시피를 다시 가져왔습니다",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Embedding 모델"
},
"other": {
"title": "기타 모델",
"disabled": {
"title": "기타 모델 관리가 꺼져 있습니다",
"description": "활성화하면 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔하고 관리하며 CivitAI에서 다운로드할 수 있습니다.",
"enableButton": "기타 모델 활성화",
"hint": "관리할 모델 유형은 나중에 설정 > 라이브러리에서 변경할 수 있습니다.",
"enableFailed": "기타 모델 활성화 실패",
"downloadBlocked": "이 모델 유형에 대해서는 기타 모델 관리가 비활성화되어 있습니다. 이 파일을 다운로드하려면 설정 > 라이브러리에서 활성화하세요.",
"enableAction": "기타 모델 활성화"
},
"noPaths": {
"title": "기타 모델 폴더를 찾을 수 없습니다",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 모델 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "활성화된 모델 유형만 스캔됩니다. 라이브러리 → 기본 루트에서 필요한 유형을 활성화하세요.",
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
"openSettings": "설정 열기",
"openModelPaths": "모델 폴더 구성",
"openSettingsFolder": "설정 폴더 열기"
}
},
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환",
"viewOptions": "보기 옵션",
"treeView": "트리 보기",
"listView": "목록 보기",
"recursiveOn": "하위 폴더 포함",
"recursiveOff": "현재 폴더",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"createFolder": " 폴더",
"newSubfolder": "새 하위 폴더",
"showEmptyFolders": "빈 폴더 표시",
"createFolderResult": {
"success": "\"{name}\" 폴더를 생성했습니다",
"failed": "폴더 생성 실패: {message}",
"unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"deleteFolder": "폴더 삭제",
"deleteFolderModal": {
"title": "폴더를 삭제할까요?",
"message": "폴더와 그 안의 모든 내용이 디스크에서 영구적으로 삭제됩니다.",
"folderLabel": "폴더",
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
"success": "\"{name}\" 폴더를 삭제했습니다",
"successWithFiles": "\"{name}\" 폴더와 {count}개 항목을 함께 삭제했습니다",
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"renameFolder": "폴더 이름 바꾸기",
"renameFolderResult": {
"success": "폴더 이름을 \"{name}\"(으)로 변경했습니다",
"failed": "폴더 이름 바꾸기 실패: {message}",
"targetExists": "같은 이름의 폴더가 이미 있습니다",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
},
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만니다"
"createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
@@ -1322,9 +1503,9 @@
"download": {
"title": "URL에서 모델 다운로드",
"titleWithType": "URL에서 {type} 다운로드",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "모델 URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"urlHint": "한 줄에 하나의 CivitAI, CivArchive, Hugging Face 또는 ModelScope URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
"selectAll": "모두 선택",
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
@@ -1357,9 +1538,9 @@
"inLibrary": "라이브러리에 있음"
},
"errors": {
"invalidUrl": "잘못된 CivitAI URL 형식",
"invalidUrl": "잘못된 모델 URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face / ModelScope URL을 혼합할 수 없습니다.",
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "현재 파일:",
"downloading": "다운로드 중: {name}",
"metadata": "메타데이터: {name}",
"indexingFile": "모델 파일 읽는 중...",
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
"fetchingMetadata": "메타데이터 가져오는 중...",
"transferred": "다운로드됨: {downloaded} / {total}",
"transferredSimple": "다운로드됨: {downloaded}",
"transferredUnknown": "다운로드됨: --",
@@ -1441,6 +1626,11 @@
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
"action": "전체 확인"
},
"filenameTemplateConfirm": {
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
"titleRevert": "원본 파일명을 복원하시겠습니까?",
"revertButton": "원본 파일명 복원"
},
"bulkAddTags": {
"title": "여러 모델에 태그 추가",
"description": "다음에 태그를 추가합니다:",
@@ -1467,6 +1657,41 @@
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"rematchOptions": {
"title": "레시피 재매칭",
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
"confirmButton": "재매칭"
},
"rematchResults": {
"undo": "실행 취소",
"undone": "실행 취소됨",
"undoFailed": "재매칭 실행 취소 실패: {message}"
},
"rematchSummary": {
"title": "재매칭 요약",
"successMessage": "{entries}개 항목이 매칭되었습니다",
"failed": "재매칭 실패",
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
"statMatched": "매칭된 항목",
"statReview": "검토 필요",
"statUnresolved": "매칭 없음",
"statErrors": "오류",
"reviewSection": "검토할 파일 이름 매칭 ({count})",
"columnRecipe": "레시피",
"columnEntry": "항목",
"columnFile": "매칭된 파일",
"columnUndo": "실행 취소",
"copyReport": "보고서 복사",
"close": "닫기",
"scope_global": "모든 레시피",
"scope_bulk": "선택한 레시피",
"scope_single": "단일 레시피"
},
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
"root": "루트"
},
"linkHuggingFace": {
"title": "HuggingFace에 연결",
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
"urlLabel": "HuggingFace 저장소 URL",
"linkModelSource": {
"title": "모델 소스에 연결",
"infoText": "모델 페이지 URL을 붙여넣어 모델을 소스에 연결합니다. 연결하면 Hugging Face 및 ModelScope 모델에 AI 메타데이터 보강을 사용할 수 있습니다.",
"urlLabel": "모델 페이지 URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
"helpText": "전체 모델 페이지 URL을 입력하세요. 지원 사이트:",
"enrichNote": "AI 보강에는 읽을 수 있는 모델 카드가 필요합니다. 모델 카드를 제공하지 않는 사이트(현재 TensorArt)는 연결만 가능합니다.",
"urlRequired": "모델 페이지 URL을 입력하세요.",
"invalidUrl": "지원되지 않는 URL입니다. 지원 사이트: Hugging Face, ModelScope, TensorArt.",
"linking": "모델 소스를 연결하는 중...",
"confirmAction": "저장 및 연결"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "클립 스킵",
"valuePlaceholder": "값",
"add": "추가",
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요",
"invalidValue": "유효한 숫자를 입력하세요",
"saveFailed": "프리셋 매개변수 저장에 실패했습니다",
"added": "프리셋 매개변수가 추가되었습니다",
"updated": "프리셋 매개변수가 업데이트되었습니다"
},
"triggerWords": {
"label": "트리거 단어",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사",
"copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
"deleteWord": "트리거 단어 삭제",
"suggestions": {
"noSuggestions": "사용 가능한 제안이 없습니다",
@@ -1649,8 +1882,8 @@
"showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기",
"addExamples": "예시 추가",
"previousExample": "이전 예시",
"nextExample": "다음 예시",
"previousExample": "이전 예시([)",
"nextExample": "다음 예시(])",
"noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
@@ -1736,7 +1969,7 @@
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
"error": "버전을 불러오지 못했습니다.",
"missingModelId": "이 모델에는 CivitAI 모델 ID가 없습니다.",
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
"sourceGroupInfo": "{source} 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
"confirm": {
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
},
@@ -1808,6 +2041,10 @@
"title": "Embedding Manager 초기화 중",
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"other": {
"title": "기타 모델 관리자 초기화 중",
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"recipes": {
"title": "레시피 매니저 초기화 중",
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "시작하기",
"updateVlogs": "업데이트 영상",
"documentation": "문서"
"documentation": "문서",
"shortcuts": "단축키"
},
"gettingStarted": {
"title": "LoRA Manager 시작하기"
"title": "LoRA Manager 시작하기",
"replayTutorial": "튜토리얼 다시 보기"
},
"shortcuts": {
"title": "키보드 & 마우스 단축키",
"groups": {
"general": "일반",
"actions": "작업",
"selection": "선택 & 일괄 모드",
"navigation": "내비게이션",
"modelModal": "모델 / 레시피 모달",
"mediaViewer": "미디어 뷰어 / 쇼케이스"
},
"keys": {
"click": "클릭",
"drag": "드래그",
"rightClick": "오른쪽 클릭",
"letter": "문자 키",
"swipe": "스와이프"
},
"entries": {
"focusSearch": "검색창으로 포커스 이동",
"closeModal": "모달 / 패널 닫기",
"openShortcuts": "이 단축키 패널 열기",
"refresh": "모델 목록 새로고침",
"fetchMetadata": "CivitAI에서 메타데이터 가져오기 (모델 페이지만)",
"downloadModel": "모델 다운로드 (모델 페이지만)",
"toggleBulkMode": "일괄 모드 전환",
"selectAll": "표시된 모든 모델 선택",
"rangeSelect": "범위 선택",
"marqueeSelect": "드래그로 카드 선택 (빈 그리드 영역에서)",
"exitBulkMode": "일괄 모드 종료",
"bulkActions": "선택한 카드에서: 일괄 작업 메뉴",
"globalActions": "페이지 빈 영역에서: 전역 작업 메뉴 (업데이트 확인, 제외된 모델 관리)",
"scrollPages": "페이지 스크롤",
"jumpAlphabet": "알파벳 바로 이동",
"prevNext": "이전 / 다음 모델",
"deleteEntry": "삭제",
"cycleMedia": "미디어 전환 (쇼케이스 갤러리에서 [ / ])",
"swipeTouch": "터치 기기에서 미디어 전환",
"closeViewer": "뷰어 닫기"
}
},
"updateVlogs": {
"title": "최신 업데이트",
@@ -1934,7 +2213,8 @@
"settings": "설정 & 구성",
"extensions": "확장",
"newBadge": "신규"
}
},
"newContentBadge": "신규"
},
"update": {
"title": "업데이트 확인",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}",
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
"noModelsSelected": "선택된 모델이 없습니다"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
"noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
"batchImportDirectorySelected": "선택한 폴더: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
"reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
"settingsUpdated": "설정 업데이트됨: {setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
"missingHash": "모델 해시를 사용할 수 없습니다"
"missingHash": "모델 해시를 사용할 수 없습니다",
"enrichNeedsSource": "먼저 이 모델을 모델 소스에 연결하세요 (모델 연결 → 모델 소스에 연결)",
"enrichUnsupportedSource": "{source} 모델에서는 AI 보강을 사용할 수 없습니다"
},
"exampleImages": {
"pathUpdated": "예시 이미지 경로가 성공적으로 업데이트되었습니다",
@@ -2487,6 +2770,17 @@
"rebuilding": "캐시 재구축 중...",
"rebuildFailed": "캐시 재구축 실패: {error}",
"retry": "다시 시도"
},
"otherModels": {
"title": "기타 모델 관리를 사용할 수 있습니다",
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
"enable": "기타 모델 활성화",
"openSettings": "설정 열기"
},
"pager": {
"previous": "이전 알림",
"next": "다음 알림",
"position": "전체 {total}개 중 {current}번째 알림"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"reorder": {
"dragHandle": "Перетащите, чтобы изменить порядок"
},
"actions": {
"save": "Сохранить",
"cancel": "Отмена",
@@ -50,6 +53,27 @@
"mb": "МБ",
"gb": "ГБ",
"tb": "ТБ"
},
"scanProgress": {
"refreshing": "Обновление {type}...",
"fullRebuilding": "Полная пересборка {type}...",
"actionRefresh": "Обновление",
"actionFullRebuild": "Полная пересборка",
"actionRefreshLower": "обновить",
"actionRebuildLower": "пересобрать",
"stages": {
"scan_folders": "Сканирование папок...",
"count_models": "Найдено файлов: {total}",
"process_models": "Обработка моделей",
"reconcile_scan": "Проверка изменений...",
"process_new": "Обработка новых моделей",
"finalizing": "Завершение..."
},
"eta": {
"lessThanMinute": "Осталось меньше минуты",
"minutes": "Осталось ~{minutes} мин",
"hours": "Осталось ~{hours} ч {minutes} мин"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "Массовые операции",
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>. Выберите несколько моделей и выполните пакетные операции. Используйте <span class=\"onboarding-shortcut\">Ctrl+A</span> для выбора всех видимых моделей."
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>, чтобы выбрать несколько моделей и выполнить пакетные операции.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> выбрать все видимые модели, <span class=\"onboarding-shortcut\">Shift+Click</span> — выбрать диапазон.<br>• <span class=\"onboarding-shortcut\">Esc</span> или клик по пустой области выходит из массового режима."
},
"searchOptions": {
"title": "Опции поиска",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "Контекстное меню",
"content": "<strong>Правый клик</strong> по карточке модели откроет контекстное меню с дополнительными действиями."
"content": "<strong>Правый клик</strong> по любой карточке модели открывает контекстное меню с действиями над карточкой, такими как перемещение, удаление или редактирование метаданных."
},
"marqueeSelect": {
"title": "Выделение рамкой",
"content": "Удерживайте <strong>левую кнопку мыши</strong> на пустой области сетки и перетащите, чтобы нарисовать рамку, выделяющую сразу несколько карточек."
},
"dragToSidebar": {
"title": "Организация перетаскиванием",
"content": "Перетащите карточку модели на папку в боковой панели, чтобы переместить туда файл. Это также работает с несколькими выделенными карточками в массовом режиме."
},
"contextMenus": {
"title": "Другие контекстные меню",
"content": "В массовом режиме <strong>правый клик по выделенной карточке</strong> открывает меню массовых операций. <strong>Правый клик по пустой области</strong> страницы открывает глобальные действия, такие как проверка обновлений и управление исключёнными моделями."
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "Посмотреть на CivitAI",
"notAvailableFromCivitai": "Недоступно на CivitAI",
"viewOnHuggingFace": "Открыть Hugging Face",
"viewOnSource": "Открыть {source}",
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
"copyLoRASyntax": "Копировать синтаксис LoRA",
"checkpointNameCopied": "Имя checkpoint скопировано",
@@ -116,6 +153,7 @@
"copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован",
"modelNameCopied": "Имя модели скопировано",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "У всех {typePlural} уже есть метаданные лицензии",
"error": "Не удалось обновить метаданные лицензии для {typePlural}: {message}"
},
"repairRecipes": {
"label": "Восстановить данные рецептов",
"loading": "Восстановление данных рецептов...",
"success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}"
},
"rematchRecipes": {
"label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}"
},
@@ -210,6 +238,7 @@
"recipes": "Рецепты",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Другое",
"statistics": "Статистика"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "Общее",
"interface": "Интерфейс",
"library": "Библиотека"
"library": "Библиотека",
"organization": "Организация",
"modelPaths": "Пути к моделям"
},
"search": {
"placeholder": "Поиск в настройках...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
"stickyControls": "Держать панель действий видимой",
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
"defaultEmbeddingRoot": "Корневая папка Embedding",
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"defaultVaeRoot": "Корневая папка VAE",
"defaultVaeRootHelp": "Установить корневую папку VAE по умолчанию для загрузок, импорта и перемещений",
"defaultUpscalerRoot": "Корневая папка Upscaler",
"defaultUpscalerRootHelp": "Установить корневую папку Upscaler по умолчанию для загрузок, импорта и перемещений",
"defaultTextEncoderRoot": "Корневая папка Text Encoder",
"defaultTextEncoderRootHelp": "Установить корневую папку Text Encoder по умолчанию для загрузок, импорта и перемещений",
"defaultClipVisionRoot": "Корневая папка CLIP Vision",
"defaultClipVisionRootHelp": "Установить корневую папку CLIP Vision по умолчанию для загрузок, импорта и перемещений",
"defaultControlnetRoot": "Корневая папка ControlNet",
"defaultControlnetRootHelp": "Установить корневую папку ControlNet по умолчанию для загрузок, импорта и перемещений",
"enableOtherModels": "Управление другими моделями",
"enableOtherModelsHelp": "Если выключено, папки VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet не сканируются, страница «Другие модели» остаётся отключённой, а эти типы моделей нельзя загрузить.",
"otherSubTypes": "Управляемые типы моделей",
"otherSubTypesHelp": "Выберите, какие категории других моделей сканируются и отображаются на странице «Другие модели».",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Путь хранения рецептов",
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
}
},
"modelPaths": {
"title": "Пути библиотеки моделей",
"description": "Корневые папки, которые LoRA Manager сканирует в поисках ваших моделей. В автономном режиме это основные расположения моделей, считываемые из settings.json.",
"restartRequired": "Требуется перезапуск, чтобы изменения вступили в силу",
"coreTypes": "Основные типы моделей",
"otherTypes": "Другие типы моделей",
"otherTypesDisabledHint": "Другие типы моделей не включены. Включите нужные типы выше, чтобы настроить их папки.",
"saveSuccessRestart": "Пути библиотеки моделей обновлены. Требуется перезапуск для применения изменений.",
"pendingRestartNotice": "Изменения путей сохранены. Перезапустите LoRA Manager, чтобы они вступили в силу.",
"pendingRestartBannerTitle": "Требуется перезапуск для применения изменений путей",
"pendingRestartBannerMessage": "Пути библиотеки моделей обновлены. Перезапустите сервер LoRA Manager, чтобы просканировать новые папки.",
"folderKeys": {
"loras": "Пути LoRA",
"checkpoints": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embeddings": "Пути Embedding",
"vae": "Пути VAE",
"upscale_models": "Пути Upscaler",
"text_encoders": "Пути Text Encoder",
"clip": "Пути CLIP (устаревшие)",
"clip_vision": "Пути CLIP Vision",
"controlnet": "Пути ControlNet"
}
},
"directoryPicker": {
"title": "Обзор папок",
"selectFolder": "Выбрать эту папку",
"goUp": "Вверх",
"pathPlaceholder": "Введите путь...",
"go": "Перейти",
"emptyFolder": "Нет подпапок",
"loadError": "Не удалось загрузить каталог"
},
"pathValidation": {
"valid": "Путь действителен",
"pathNotFound": "Путь не существует",
"notADirectory": "Не является каталогом",
"notReadable": "Путь недоступен для чтения",
"notWritable": "Путь недоступен для записи"
},
"priorityTags": {
"title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -589,6 +681,22 @@
"validTemplate": "Действительный шаблон"
}
},
"filenameTemplates": {
"title": "Шаблоны имён файлов",
"help": "Настройте имена файлов загружаемых моделей для каждого типа моделей. Оставьте пустым, чтобы сохранять исходные имена файлов при загрузке; применение пустого шаблона восстанавливает записанные исходные имена файлов ранее переименованных моделей. Исходное имя файла всегда сохраняется в метаданных модели.",
"availablePlaceholders": "Доступные заполнители:",
"templatePlaceholder": "Введите шаблон имени файла (например, {base_model}-{model_name}-{version_name})",
"applyButton": "Применить к библиотеке сейчас",
"applyHelp": "Переименовывает все существующие файлы этого типа моделей согласно шаблону; при пустом шаблоне вместо этого восстанавливает записанные исходные имена файлов. Предупреждение: переименование меняет относительный путь, который видят загрузчики ComfyUI, поэтому существующие workflow, ссылающиеся на старое имя файла, может потребоваться обновить. Исходное имя файла сохраняется в метаданных каждой модели.",
"confirmApply": "Переименовать все существующие файлы этого типа моделей согласно шаблону имён файлов? Это меняет относительный путь, который видят загрузчики ComfyUI. Исходное имя файла сохраняется в метаданных каждой модели.",
"confirmRevert": "Восстановить записанные исходные имена файлов всех ранее переименованных файлов этого типа моделей? Это меняет относительный путь, который видят загрузчики ComfyUI. Файлы без записанного исходного имени файла пропускаются.",
"validation": {
"restoreOriginal": "Действительный (пустой шаблон восстанавливает исходные имена файлов)",
"invalidChars": "Обнаружены недопустимые символы (имя файла не может содержать / \\ < > : \" | ? *)",
"invalidPlaceholder": "Недопустимый заполнитель: {placeholder}",
"validTemplate": "Действительный шаблон"
}
},
"exampleImages": {
"downloadLocation": "Место загрузки",
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
@@ -786,7 +894,6 @@
"setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
"reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных",
@@ -822,14 +929,22 @@
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
},
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
"filenameTemplateProgress": {
"initializing": "Инициализация применения шаблона имён файлов...",
"starting": "Применение шаблона имён файлов к {type}...",
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
"complete": "Применение шаблона имён файлов завершено",
"error": "Ошибка: {error}"
},
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
},
"contextMenu": {
"refreshMetadata": "Обновить данные CivitAI",
"checkUpdates": "Проверить обновления",
"linkModel": "Связать модель",
"linkCivitai": "Пересвязать с CivitAI",
"linkHuggingFace": "Связать с HuggingFace",
"linkModelSource": "Связать с источником модели",
"copySyntax": "Копировать синтаксис LoRA",
"copyFilename": "Копировать имя файла модели",
"copyRecipeSyntax": "Копировать синтаксис рецепта",
@@ -842,7 +957,6 @@
"replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"rematchMetadata": "Сопоставить с локальными моделями",
"reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель",
@@ -852,7 +966,7 @@
"viewAllLoras": "Посмотреть все LoRAs",
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
"deleteRecipe": "Удалить рецепт",
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "Предыдущий рецепт (←)",
"nextWithShortcut": "Следующий рецепт (→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "Базовая модель",
"unknown": "Неизвестно"
},
"actions": {
"openFileLocation": "Открыть расположение файла",
"copyId": "Копировать ID рецепта"
},
"openFileLocation": {
"success": "Расположение файла успешно открыто",
"failed": "Не удалось открыть расположение файла",
"copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Отправить workflow в ComfyUI",
"sent": "Workflow отправлен в ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
},
"repair": {
"starting": "Восстановление метаданных рецепта...",
"success": "Метаданные рецепта успешно восстановлены",
"skipped": "Рецепт уже последней версии, восстановление не требуется",
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
},
"reimport": {
"starting": "Переимпорт рецепта из источника...",
"success": "Рецепт успешно переимпортирован",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Модели Embedding"
},
"other": {
"title": "Другие модели",
"disabled": {
"title": "Управление другими моделями отключено",
"description": "Включите, чтобы сканировать и управлять файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загружать их с CivitAI.",
"enableButton": "Включить другие модели",
"hint": "Вы сможете изменить управляемые типы моделей позже в разделе «Настройки > Библиотека».",
"enableFailed": "Не удалось включить другие модели",
"downloadBlocked": "Управление другими моделями отключено для этого типа моделей. Включите его в разделе «Настройки > Библиотека», чтобы загрузить этот файл.",
"enableAction": "Включить другие модели"
},
"noPaths": {
"title": "Папки других моделей не найдены",
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте свои папки моделей в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только включённые типы моделей; включите нужные типы в разделе «Библиотека → Корневые папки».",
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
"openSettings": "Открыть настройки",
"openModelPaths": "Настроить папки моделей",
"openSettingsFolder": "Открыть папку настроек"
}
},
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
"collapseAllDisabled": "Недоступно в виде списка",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид",
"viewOptions": "Параметры отображения",
"treeView": "Дерево",
"listView": "Список",
"recursiveOn": "Включать вложенные папки",
"recursiveOff": "Только текущая папка",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка",
"createFolder": "Новая папка",
"newSubfolder": "Новая вложенная папка",
"showEmptyFolders": "Показывать пустые папки",
"createFolderResult": {
"success": "Папка \"{name}\" создана",
"failed": "Не удалось создать папку: {message}",
"unsupported": "Создание папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"deleteFolder": "Удалить папку",
"deleteFolderModal": {
"title": "Удалить папку?",
"message": "Папка и всё её содержимое будут безвозвратно удалены с диска.",
"folderLabel": "Папка",
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
"success": "Папка \"{name}\" удалена",
"successWithFiles": "Папка \"{name}\" удалена вместе с ещё {count} элемент(ами)",
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"renameFolder": "Переименовать папку",
"renameFolderResult": {
"success": "Папка переименована в \"{name}\"",
"failed": "Не удалось переименовать папку: {message}",
"targetExists": "Папка с таким именем уже существует здесь",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Переименование папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена"
},
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
"createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
@@ -1322,9 +1503,9 @@
"download": {
"title": "Скачать модель по URL",
"titleWithType": "Скачать {type} по URL",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "URL модели:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
"urlHint": "Введите один URL CivitAI, CivArchive, Hugging Face или ModelScope в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
"selectAll": "Выбрать все",
"fetchingRepoFiles": "Получение файлов репозитория...",
@@ -1357,9 +1538,9 @@
"inLibrary": "В библиотеке"
},
"errors": {
"invalidUrl": "Неверный формат URL CivitAI",
"invalidUrl": "Неверный формат URL модели",
"noVersions": "Нет доступных версий для этой модели",
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face / ModelScope в одном пакете.",
"noModelFiles": "В этом репозитории не найдено файлов моделей."
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "Текущий файл:",
"downloading": "Скачивается: {name}",
"metadata": "Метаданные: {name}",
"indexingFile": "Чтение файла модели...",
"fetchingSourceMetadata": "Получение метаданных из {source}...",
"fetchingMetadata": "Получение метаданных...",
"transferred": "Скачано: {downloaded} / {total}",
"transferredSimple": "Скачано: {downloaded}",
"transferredUnknown": "Скачано: --",
@@ -1441,6 +1626,11 @@
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
"action": "Проверить всё"
},
"filenameTemplateConfirm": {
"titleApply": "Применить шаблон имён файлов к библиотеке?",
"titleRevert": "Восстановить исходные имена файлов?",
"revertButton": "Восстановить исходные имена файлов"
},
"bulkAddTags": {
"title": "Добавить теги к нескольким моделям",
"description": "Добавить теги к",
@@ -1467,6 +1657,41 @@
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"rematchOptions": {
"title": "Повторное сопоставление рецептов",
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
"confirmButton": "Сопоставить"
},
"rematchResults": {
"undo": "Отменить",
"undone": "Отменено",
"undoFailed": "Не удалось отменить сопоставление: {message}"
},
"rematchSummary": {
"title": "Сводка повторного сопоставления",
"successMessage": "Сопоставлено записей: {entries}",
"failed": "Не удалось выполнить сопоставление",
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
"statMatched": "Сопоставленные записи",
"statReview": "Требуют проверки",
"statUnresolved": "Не сопоставлено",
"statErrors": "Ошибки",
"reviewSection": "Совпадения по имени файла для проверки ({count})",
"columnRecipe": "Рецепт",
"columnEntry": "Запись",
"columnFile": "Совпавший файл",
"columnUndo": "Отменить",
"copyReport": "Скопировать отчёт",
"close": "Закрыть",
"scope_global": "Все рецепты",
"scope_bulk": "Выбранные рецепты",
"scope_single": "Один рецепт"
},
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
"root": "Корень"
},
"linkHuggingFace": {
"title": "Связать с HuggingFace",
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
"urlLabel": "URL репозитория HuggingFace:",
"linkModelSource": {
"title": "Связать с источником модели",
"infoText": "Вставьте URL страницы модели, чтобы связать эту модель с её источником. Связывание включает обогащение метаданных с помощью ИИ для моделей Hugging Face и ModelScope.",
"urlLabel": "URL страницы модели:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Введите полный URL репозитория HuggingFace.",
"helpText": "Введите полный URL страницы модели. Поддерживаемые сайты:",
"enrichNote": "Для обогащения с помощью ИИ нужна читаемая карточка модели. Сайты, которые её не предоставляют (сейчас TensorArt), можно только связать.",
"urlRequired": "Введите URL страницы модели.",
"invalidUrl": "Неподдерживаемый URL. Поддерживаемые сайты: Hugging Face, ModelScope, TensorArt.",
"linking": "Связывание с источником модели...",
"confirmAction": "Сохранить и связать"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Значение",
"add": "Добавить",
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y",
"invalidValue": "Введите корректное число",
"saveFailed": "Не удалось сохранить предустановленный параметр",
"added": "Предустановленный параметр добавлен",
"updated": "Предустановленный параметр обновлён"
},
"triggerWords": {
"label": "Триггерные слова",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово",
"copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
"deleteWord": "Удалить триггерное слово",
"suggestions": {
"noSuggestions": "Предложения недоступны",
@@ -1649,8 +1882,8 @@
"showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример",
"nextExample": "Следующий пример",
"previousExample": "Предыдущий пример ([)",
"nextExample": "Следующий пример (])",
"noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда",
@@ -1736,7 +1969,7 @@
"empty": "Для этой модели пока нет истории версий.",
"error": "Не удалось загрузить версии.",
"missingModelId": "У этой модели отсутствует идентификатор модели CivitAI.",
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
"sourceGroupInfo": "Это группа моделей {source}. Откройте библиотеку, чтобы увидеть все версии в сетке.",
"confirm": {
"delete": "Удалить эту версию из библиотеки?"
},
@@ -1808,6 +2041,10 @@
"title": "Инициализация Embedding Manager",
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
},
"other": {
"title": "Инициализация менеджера других моделей",
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
},
"recipes": {
"title": "Инициализация менеджера рецептов",
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "Начало работы",
"updateVlogs": "Видео обновлений",
"documentation": "Документация"
"documentation": "Документация",
"shortcuts": "Горячие клавиши"
},
"gettingStarted": {
"title": "Начало работы с LoRA Manager"
"title": "Начало работы с LoRA Manager",
"replayTutorial": "Повторить обучение"
},
"shortcuts": {
"title": "Горячие клавиши и действия мыши",
"groups": {
"general": "Общие",
"actions": "Действия",
"selection": "Выделение и массовый режим",
"navigation": "Навигация",
"modelModal": "Окно модели / рецепта",
"mediaViewer": "Просмотр медиа / Витрина"
},
"keys": {
"click": "Клик",
"drag": "Перетаскивание",
"rightClick": "Правый клик",
"letter": "Буква",
"swipe": "Свайп"
},
"entries": {
"focusSearch": "Переход к поиску",
"closeModal": "Закрыть модальное окно / панель",
"openShortcuts": "Открыть эту панель горячих клавиш",
"refresh": "Обновить список моделей",
"fetchMetadata": "Получить метаданные с CivitAI (только на страницах моделей)",
"downloadModel": "Загрузить модель (только на страницах моделей)",
"toggleBulkMode": "Переключить массовый режим",
"selectAll": "Выбрать все видимые модели",
"rangeSelect": "Выбор диапазона",
"marqueeSelect": "Выделение карточек рамкой (на пустой области сетки)",
"exitBulkMode": "Выйти из массового режима",
"bulkActions": "На выделенной карточке: меню массовых операций",
"globalActions": "На пустой области страницы: меню глобальных действий (проверка обновлений, управление исключёнными моделями)",
"scrollPages": "Прокрутка страниц",
"jumpAlphabet": "Переход по алфавитной панели",
"prevNext": "Предыдущая / следующая модель",
"deleteEntry": "Удалить",
"cycleMedia": "Переключение медиа ([ / ] в галерее витрины)",
"swipeTouch": "Переключение медиа на сенсорных устройствах",
"closeViewer": "Закрыть окно просмотра"
}
},
"updateVlogs": {
"title": "Последние обновления",
@@ -1934,7 +2213,8 @@
"settings": "Настройки и конфигурация",
"extensions": "Расширения",
"newBadge": "НОВОЕ"
}
},
"newContentBadge": "НОВОЕ"
},
"update": {
"title": "Проверить обновления",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
"noModelsSelected": "Модели не выбраны"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
"batchImportDirectorySelected": "Выбрана папка: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...",
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
"reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
"recipesPathUpdated": "Путь хранения рецептов обновлён",
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
"settingsUpdated": "Настройки обновлены: {setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive",
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
"noCivitaiInfo": "Информация CivitAI недоступна",
"missingHash": "Хеш модели недоступен"
"missingHash": "Хеш модели недоступен",
"enrichNeedsSource": "Сначала свяжите эту модель с источником модели (Связать модель → Связать с источником модели)",
"enrichUnsupportedSource": "Обогащение с помощью ИИ недоступно для моделей {source}"
},
"exampleImages": {
"pathUpdated": "Путь к примерам изображений успешно обновлен",
@@ -2487,6 +2770,17 @@
"rebuilding": "Перестроение кэша...",
"rebuildFailed": "Не удалось перестроить кэш: {error}",
"retry": "Повторить"
},
"otherModels": {
"title": "Управление другими моделями доступно",
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
"enable": "Включить другие модели",
"openSettings": "Открыть настройки"
},
"pager": {
"previous": "Предыдущее уведомление",
"next": "Следующее уведомление",
"position": "Уведомление {current} из {total}"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "取消",
"confirm": "确认",
"reorder": {
"dragHandle": "拖拽以调整顺序"
},
"actions": {
"save": "保存",
"cancel": "取消",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "正在刷新 {type}...",
"fullRebuilding": "正在完全重建 {type}...",
"actionRefresh": "刷新",
"actionFullRebuild": "完全重建",
"actionRefreshLower": "刷新",
"actionRebuildLower": "重建",
"stages": {
"scan_folders": "正在扫描文件夹...",
"count_models": "找到 {total} 个文件",
"process_models": "正在处理模型",
"reconcile_scan": "正在检查变更...",
"process_new": "正在处理新模型",
"finalizing": "正在收尾..."
},
"eta": {
"lessThanMinute": "剩余时间不到一分钟",
"minutes": "剩余约 {minutes} 分钟",
"hours": "剩余约 {hours} 小时 {minutes} 分钟"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "批量操作",
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 全选所有可见模型。"
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 全选所有可见模型<span class=\"onboarding-shortcut\">Shift+Click</span> 选择一个范围。<br>• 按 <span class=\"onboarding-shortcut\">Esc</span> 或点击空白区域退出批量模式。"
},
"searchOptions": {
"title": "搜索选项",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "右键菜单",
"content": "<strong>右键点击</strong>任意模型卡片可打开更多操作菜单。"
"content": "<strong>右键点击</strong>任意模型卡片可打开包含移动、删除或编辑元数据等卡片操作菜单。"
},
"marqueeSelect": {
"title": "拖动框选",
"content": "在网格的空白区域按住<strong>鼠标左键</strong>并拖动,绘制一个可同时选中多张卡片的框选区域。"
},
"dragToSidebar": {
"title": "拖放整理",
"content": "将模型卡片拖到侧边栏的文件夹上,即可把文件移动到该文件夹。批量模式下选中的多张卡片也可如此操作。"
},
"contextMenus": {
"title": "更多右键菜单",
"content": "在批量模式下,<strong>右键点击已选中的卡片</strong>可进行批量操作。<strong>右键点击页面空白区域</strong>可使用检查更新、管理已排除的模型等全局操作。"
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "在 CivitAI 查看",
"notAvailableFromCivitai": "CivitAI 上不可用",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnSource": "在 {source} 查看",
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
"copyLoRASyntax": "复制 LoRA 语法",
"checkpointNameCopied": "Checkpoint 名称已复制",
@@ -116,6 +153,7 @@
"copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法",
"modelNameCopied": "模型名称已复制",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "所有 {typePlural} 都已具备许可证元数据",
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
},
"repairRecipes": {
"label": "修复配方数据",
"loading": "正在修复配方数据...",
"success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}"
},
"rematchRecipes": {
"label": "将配方重新匹配到本地模型",
"loading": "正在将配方重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"allFailed": "{failures}/{total} 个配方重新匹配失败",
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
"error": "配方重新匹配失败:{message}"
},
@@ -210,6 +238,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"statistics": "统计"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "通用",
"interface": "界面",
"library": "库"
"library": "库",
"organization": "整理",
"modelPaths": "模型路径"
},
"search": {
"placeholder": "搜索设置...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"stickyControls": "保持操作栏可见",
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
"displayDensity": "显示密度",
"displayDensityOptions": {
"default": "默认",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
"defaultEmbeddingRoot": "Embedding 根目录",
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"defaultVaeRoot": "VAE 根目录",
"defaultVaeRootHelp": "设置下载、导入和移动时的默认 VAE 根目录",
"defaultUpscalerRoot": "Upscaler 根目录",
"defaultUpscalerRootHelp": "设置下载、导入和移动时的默认 Upscaler 根目录",
"defaultTextEncoderRoot": "Text Encoder 根目录",
"defaultTextEncoderRootHelp": "设置下载、导入和移动时的默认 Text Encoder 根目录",
"defaultClipVisionRoot": "CLIP Vision 根目录",
"defaultClipVisionRootHelp": "设置下载、导入和移动时的默认 CLIP Vision 根目录",
"defaultControlnetRoot": "ControlNet 根目录",
"defaultControlnetRootHelp": "设置下载、导入和移动时的默认 ControlNet 根目录",
"enableOtherModels": "其他模型管理",
"enableOtherModelsHelp": "关闭后,不会扫描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 文件夹,其他模型页面保持禁用,且无法下载这些模型类型。",
"otherSubTypes": "管理的模型类型",
"otherSubTypesHelp": "选择要在其他模型页面中扫描和显示的其他模型类别。",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "配方存储路径",
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
}
},
"modelPaths": {
"title": "模型库路径",
"description": "LoRA Manager 扫描模型所用的根文件夹。独立模式下,这些是从 settings.json 读取的主要模型位置。",
"restartRequired": "需要重启才能生效",
"coreTypes": "核心模型类型",
"otherTypes": "其他模型类型",
"otherTypesDisabledHint": "未启用任何其他模型类型。请在上方启用你需要的类型,然后为其配置文件夹。",
"saveSuccessRestart": "模型库路径已更新,需要重启才能生效。",
"pendingRestartNotice": "路径更改已保存。重启 LoRA Manager 后生效。",
"pendingRestartBannerTitle": "需要重启以应用路径更改",
"pendingRestartBannerMessage": "模型库路径已更新。请重启 LoRA Manager 服务器以扫描新文件夹。",
"folderKeys": {
"loras": "LoRA 路径",
"checkpoints": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embeddings": "Embedding 路径",
"vae": "VAE 路径",
"upscale_models": "Upscaler 路径",
"text_encoders": "Text Encoder 路径",
"clip": "CLIP 路径(旧版)",
"clip_vision": "CLIP Vision 路径",
"controlnet": "ControlNet 路径"
}
},
"directoryPicker": {
"title": "浏览文件夹",
"selectFolder": "选择此文件夹",
"goUp": "上级目录",
"pathPlaceholder": "输入路径...",
"go": "跳转",
"emptyFolder": "没有子文件夹",
"loadError": "目录加载失败"
},
"pathValidation": {
"valid": "路径有效",
"pathNotFound": "路径不存在",
"notADirectory": "不是一个目录",
"notReadable": "路径不可读",
"notWritable": "路径不可写"
},
"priorityTags": {
"title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "有效模板"
}
},
"filenameTemplates": {
"title": "文件名模板",
"help": "按模型类型配置下载模型的文件名。留空则下载时保留原始文件名;应用空模板会恢复此前被重命名模型所记录的原始文件名。原始文件名始终保留在模型的元数据中。",
"availablePlaceholders": "可用占位符:",
"templatePlaceholder": "输入文件名模板(如:{base_model}-{model_name}-{version_name}",
"applyButton": "立即应用到库",
"applyHelp": "根据模板重命名此模型类型的所有现有文件;模板为空时则恢复已记录的原始文件名。警告:重命名会改变 ComfyUI 加载器所见的相对路径,因此引用旧文件名的现有工作流可能需要更新。原始文件名保留在每个模型的元数据中。",
"confirmApply": "要根据文件名模板重命名此模型类型的所有现有文件吗?这会改变 ComfyUI 加载器所见的相对路径。原始文件名保留在每个模型的元数据中。",
"confirmRevert": "要恢复此模型类型中所有此前被重命名文件所记录的原始文件名吗?这会改变 ComfyUI 加载器所见的相对路径。未记录原始文件名的文件将被跳过。",
"validation": {
"restoreOriginal": "有效(空模板将恢复原始文件名)",
"invalidChars": "检测到无效字符(文件名不能包含 / \\ < > : \" | ? *",
"invalidPlaceholder": "无效占位符:{placeholder}",
"validTemplate": "有效模板"
}
},
"exampleImages": {
"downloadLocation": "下载位置",
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
@@ -786,7 +894,6 @@
"setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新",
@@ -822,14 +929,22 @@
"complete": "自动整理已完成",
"error": "错误:{error}"
},
"enrichHfAgent": "AI HF 元数据增强"
"filenameTemplateProgress": {
"initializing": "正在初始化应用文件名模板...",
"starting": "正在为 {type} 应用文件名模板...",
"processing": "处理中({processed}/{total}- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "文件名模板应用完成",
"error": "错误:{error}"
},
"enrichHfAgent": "AI 元数据增强"
},
"contextMenu": {
"refreshMetadata": "刷新 CivitAI 数据",
"checkUpdates": "检查更新",
"linkModel": "链接模型",
"linkCivitai": "链接到 CivitAI",
"linkHuggingFace": "链接到 HuggingFace",
"linkModelSource": "链接到模型来源",
"copySyntax": "复制 LoRA 语法",
"copyFilename": "复制模型文件名",
"copyRecipeSyntax": "复制配方语法",
@@ -842,7 +957,6 @@
"replacePreview": "替换预览",
"setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"excludeModel": "排除模型",
@@ -852,7 +966,7 @@
"viewAllLoras": "查看所有 LoRA",
"downloadMissingLoras": "下载缺失的 LoRA",
"deleteRecipe": "删除配方",
"enrichHfAgent": "AI HF 元数据增强"
"enrichHfAgent": "AI 元数据增强"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "上一个配方(←)",
"nextWithShortcut": "下一个配方(→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "基础模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "打开文件位置",
"copyId": "复制配方 ID"
},
"openFileLocation": {
"success": "文件位置已成功打开",
"failed": "打开文件位置失败",
"copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}"
}
},
"workflow": {
"sendWorkflow": "发送工作流到 ComfyUI",
"sent": "工作流已发送到 ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "获取缺失 LoRA 信息失败",
"prepareError": "准备下载 LoRA 时出错:{message}"
},
"repair": {
"starting": "正在修复配方元数据...",
"success": "配方元数据修复成功",
"skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
},
"reimport": {
"starting": "正在从源重新导入配方...",
"success": "配方已从源重新导入成功",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Embedding 模型"
},
"other": {
"title": "其他模型",
"disabled": {
"title": "其他模型管理已关闭",
"description": "启用后可扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enableButton": "启用其他模型",
"hint": "你可以稍后在“设置 > 库”中更改管理的模型类型。",
"enableFailed": "启用其他模型失败",
"downloadBlocked": "其他模型管理已对此模型类型禁用。请在“设置 > 库”中启用以下载此文件。",
"enableAction": "启用其他模型"
},
"noPaths": {
"title": "未找到其他模型文件夹",
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请在“设置 → 模型路径”中添加你的模型文件夹,然后重启 LoRA Manager。",
"hintStandalone": "仅扫描已启用的模型类型;请在“库 → 默认根目录”中启用你需要的类型。",
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
"openSettings": "打开设置",
"openModelPaths": "配置模型文件夹",
"openSettingsFolder": "打开设置文件夹"
}
},
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
"collapseAllDisabled": "列表视图下不可用",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图",
"viewOptions": "视图选项",
"treeView": "树形视图",
"listView": "列表视图",
"recursiveOn": "包含子文件夹",
"recursiveOff": "仅当前文件夹",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用",
"createFolder": "新建文件夹",
"newSubfolder": "新建子文件夹",
"showEmptyFolders": "显示空文件夹",
"createFolderResult": {
"success": "已创建文件夹 \"{name}\"",
"failed": "创建文件夹失败: {message}",
"unsupported": "此页面不支持创建文件夹",
"noRoot": "未配置模型根目录"
},
"deleteFolder": "删除文件夹",
"deleteFolderModal": {
"title": "删除文件夹?",
"message": "该文件夹及其中所有内容都将从磁盘上永久删除。",
"folderLabel": "文件夹",
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
"success": "已删除文件夹 \"{name}\"",
"successWithFiles": "已删除文件夹 \"{name}\",同时删除了另外 {count} 项内容",
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
},
"renameFolder": "重命名文件夹",
"renameFolderResult": {
"success": "文件夹已重命名为 \"{name}\"",
"failed": "重命名文件夹失败: {message}",
"targetExists": "此处已存在同名文件夹",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持重命名文件夹",
"noRoot": "未配置模型根目录"
},
"dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
"createHint": "点击上方的新建文件夹按钮即可创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
@@ -1322,9 +1503,9 @@
"download": {
"title": "从 URL 下载模型",
"titleWithType": "从 URL 下载 {type}",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "模型 URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAI、CivArchiveHugging Face URL。支持批量下载多个 URL。",
"urlHint": "每行输入一个 CivitAI、CivArchiveHugging Face 或 ModelScope URL。支持批量下载多个 URL。",
"selectHfFiles": "选择从此仓库下载的文件:",
"selectAll": "全选",
"fetchingRepoFiles": "正在获取仓库文件...",
@@ -1357,9 +1538,9 @@
"inLibrary": "已在库中"
},
"errors": {
"invalidUrl": "无效的 CivitAI URL 格式",
"invalidUrl": "无效的模型 URL 格式",
"noVersions": "此模型没有可用版本",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face / ModelScope URL。",
"noModelFiles": "在此仓库中未找到模型文件。"
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "当前文件:",
"downloading": "下载中:{name}",
"metadata": "元数据:{name}",
"indexingFile": "正在读取模型文件...",
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
"fetchingMetadata": "正在获取元数据...",
"transferred": "已下载:{downloaded} / {total}",
"transferredSimple": "已下载:{downloaded}",
"transferredUnknown": "已下载:--",
@@ -1441,6 +1626,11 @@
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
"action": "检查全部"
},
"filenameTemplateConfirm": {
"titleApply": "将文件名模板应用到库?",
"titleRevert": "恢复原始文件名?",
"revertButton": "恢复原始文件名"
},
"bulkAddTags": {
"title": "批量添加标签",
"description": "为多个模型添加标签",
@@ -1467,6 +1657,41 @@
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
"messageSingle": "将对照你的本地模型库扫描此配方。",
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
"relaxedLabel": "同时按文件名重新关联缺失的模型",
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤销",
"undone": "已撤销",
"undoFailed": "撤销重新匹配失败:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 个条目",
"failed": "重新匹配失败",
"completedWithWarnings": "重新匹配已完成——建议检查",
"cancelledNote": "运行在完成前已取消——统计不完整。",
"statMatched": "已匹配条目",
"statReview": "需要检查",
"statUnresolved": "未匹配",
"statErrors": "错误",
"reviewSection": "需要检查的文件名匹配({count}",
"columnRecipe": "配方",
"columnEntry": "条目",
"columnFile": "匹配到的文件",
"columnUndo": "撤销",
"copyReport": "复制报告",
"close": "关闭",
"scope_global": "所有配方",
"scope_bulk": "所选配方",
"scope_single": "单个配方"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
"root": "根目录"
},
"linkHuggingFace": {
"title": "链接到 HuggingFace",
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
"urlLabel": "HuggingFace 仓库 URL",
"linkModelSource": {
"title": "链接到模型来源",
"infoText": "粘贴模型页面 URL 以关联此模型与其来源。关联后可对 Hugging Face 和 ModelScope 模型启用 AI 元数据增强。",
"urlLabel": "模型页面 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
"helpText": "请输入完整的模型页面 URL。支持的站点:",
"enrichNote": "AI 增强需要可读取的模型卡。未提供模型卡的站点(目前为 TensorArt)只能建立链接。",
"urlRequired": "请输入模型页面 URL。",
"invalidUrl": "URL 不受支持。支持的站点:Hugging Face、ModelScope、TensorArt。",
"linking": "正在链接模型来源...",
"confirmAction": "保存并链接"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "数值",
"add": "添加",
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
"invalidRange": "无效的范围格式。请使用 x.x-y.y",
"invalidValue": "请输入有效的数值",
"saveFailed": "保存预设参数失败",
"added": "已添加预设参数",
"updated": "已更新预设参数"
},
"triggerWords": {
"label": "触发词",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"copyOrEditWord": "单击复制,双击编辑",
"deleteWord": "删除触发词",
"suggestions": {
"noSuggestions": "暂无建议",
@@ -1649,8 +1882,8 @@
"showCount": "显示示例({count}",
"hideExamples": "隐藏示例",
"addExamples": "添加示例",
"previousExample": "上一个示例",
"nextExample": "下一个示例",
"previousExample": "上一个示例[",
"nextExample": "下一个示例]",
"noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处",
@@ -1736,7 +1969,7 @@
"empty": "该模型还没有版本历史。",
"error": "加载版本失败。",
"missingModelId": "该模型缺少 CivitAI 模型 ID。",
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
"sourceGroupInfo": "这是一个 {source} 模型组。打开库页面即可在网格中查看所有版本。",
"confirm": {
"delete": "从库中删除此版本?"
},
@@ -1808,6 +2041,10 @@
"title": "初始化 Embedding 管理器",
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
},
"other": {
"title": "正在初始化其他模型管理器",
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在加载和处理配方。这可能需要几分钟..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "新手入门",
"updateVlogs": "更新日志",
"documentation": "文档"
"documentation": "文档",
"shortcuts": "快捷键"
},
"gettingStarted": {
"title": "LoRA 管理器新手入门"
"title": "LoRA 管理器新手入门",
"replayTutorial": "重播教程"
},
"shortcuts": {
"title": "键盘与鼠标快捷键",
"groups": {
"general": "通用",
"actions": "操作",
"selection": "选择与批量模式",
"navigation": "导航",
"modelModal": "模型 / 配方弹窗",
"mediaViewer": "媒体查看器 / 示例展示"
},
"keys": {
"click": "单击",
"drag": "拖动",
"rightClick": "右键点击",
"letter": "字母",
"swipe": "滑动"
},
"entries": {
"focusSearch": "聚焦搜索框",
"closeModal": "关闭弹窗 / 面板",
"openShortcuts": "打开本快捷键面板",
"refresh": "刷新模型列表",
"fetchMetadata": "从 CivitAI 获取元数据(仅模型页面)",
"downloadModel": "下载模型(仅模型页面)",
"toggleBulkMode": "切换批量模式",
"selectAll": "全选所有可见模型",
"rangeSelect": "范围选择",
"marqueeSelect": "框选卡片(在网格空白区域)",
"exitBulkMode": "退出批量模式",
"bulkActions": "在已选中的卡片上:批量操作菜单",
"globalActions": "在页面空白区域:全局操作菜单(检查更新、管理已排除的模型)",
"scrollPages": "滚动页面",
"jumpAlphabet": "字母索引栏跳转",
"prevNext": "上一个 / 下一个模型",
"deleteEntry": "删除",
"cycleMedia": "切换媒体(在示例展示中按 [ / ])",
"swipeTouch": "在触屏设备上切换媒体",
"closeViewer": "关闭查看器"
}
},
"updateVlogs": {
"title": "最新更新",
@@ -1934,7 +2213,8 @@
"settings": "设置与配置",
"extensions": "扩展",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "检查更新",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}",
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
"filenameTemplateFailed": "应用文件名模板失败:{error}",
"noModelsSelected": "未选中模型"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载",
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
"noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
"reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
"otherRootsFailed": "加载其他模型根目录失败:{message}",
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
"mappingsCleared": "基础模型路径映射已清除",
"mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新",
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
"filenameTemplatesUpdated": "文件名模板已更新",
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
"recipesPathUpdated": "配方存储路径已更新",
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
"settingsUpdated": "设置已更新:{setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
"noCivitaiInfo": "无 CivitAI 信息",
"missingHash": "模型哈希不可用"
"missingHash": "模型哈希不可用",
"enrichNeedsSource": "请先将此模型链接到模型来源(链接模型 → 链接到模型来源)",
"enrichUnsupportedSource": "{source} 模型不支持 AI 增强"
},
"exampleImages": {
"pathUpdated": "示例图片路径更新成功",
@@ -2487,6 +2770,17 @@
"rebuilding": "正在重建缓存...",
"rebuildFailed": "重建缓存失败:{error}",
"retry": "重试"
},
"otherModels": {
"title": "其他模型管理现已可用",
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enable": "启用其他模型",
"openSettings": "打开设置"
},
"pager": {
"previous": "上一条通知",
"next": "下一条通知",
"position": "第 {current} 条通知,共 {total} 条"
}
}
}
+352 -58
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "取消",
"confirm": "確認",
"reorder": {
"dragHandle": "拖曳以調整順序"
},
"actions": {
"save": "儲存",
"cancel": "取消",
@@ -50,6 +53,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "正在重新整理 {type}...",
"fullRebuilding": "正在完整重建 {type}...",
"actionRefresh": "重新整理",
"actionFullRebuild": "完整重建",
"actionRefreshLower": "重新整理",
"actionRebuildLower": "重建",
"stages": {
"scan_folders": "正在掃描資料夾...",
"count_models": "找到 {total} 個檔案",
"process_models": "正在處理模型",
"reconcile_scan": "正在檢查變更...",
"process_new": "正在處理新模型",
"finalizing": "正在收尾..."
},
"eta": {
"lessThanMinute": "剩餘時間不到一分鐘",
"minutes": "剩餘約 {minutes} 分鐘",
"hours": "剩餘約 {hours} 小時 {minutes} 分鐘"
}
}
},
"onboarding": {
@@ -75,7 +99,7 @@
},
"bulk": {
"title": "批次操作",
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式。可選取多個模型並執行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 選取所有可見模型。"
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式選取多個模型並執行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 選取所有可見模型<span class=\"onboarding-shortcut\">Shift+Click</span> 選取一段範圍。<br>• <span class=\"onboarding-shortcut\">Esc</span> 或點擊空白處離開批量模式。"
},
"searchOptions": {
"title": "搜尋選項",
@@ -95,7 +119,19 @@
},
"contextMenu": {
"title": "右鍵選單",
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟更多操作選單。"
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟包含移動、刪除或編輯中繼資料等卡片操作的右鍵選單。"
},
"marqueeSelect": {
"title": "拖曳框選",
"content": "在網格空白處按住<strong>滑鼠左鍵</strong>並拖曳,畫出框選範圍,一次選取多張卡片。"
},
"dragToSidebar": {
"title": "拖曳整理",
"content": "將模型卡片拖曳到側邊欄的資料夾上,即可將檔案移動到該處。在批量模式下選取多張卡片也可一起拖曳。"
},
"contextMenus": {
"title": "更多右鍵選單",
"content": "在批量模式下,<strong>右鍵點擊已選取的卡片</strong>可開啟批量操作選單。<strong>右鍵點擊頁面空白處</strong>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
}
}
},
@@ -106,6 +142,7 @@
"viewOnCivitai": "在 CivitAI 查看",
"notAvailableFromCivitai": "CivitAI 不提供",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnSource": "在 {source} 查看",
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
"copyLoRASyntax": "複製 LoRA 語法",
"checkpointNameCopied": "Checkpoint 名稱已複製",
@@ -116,6 +153,7 @@
"copyCheckpointName": "複製 Checkpoint 名稱",
"copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"modelNameCopied": "模型名稱已複製",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
},
@@ -179,20 +217,10 @@
"none": "所有 {typePlural} 已具備授權中繼資料",
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
},
"repairRecipes": {
"label": "修復配方資料",
"loading": "正在修復配方資料...",
"success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}"
},
"rematchRecipes": {
"label": "將配方重新匹配到本地模型",
"loading": "正在將配方重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
"error": "配方重新匹配失敗:{message}"
},
@@ -210,6 +238,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"statistics": "統計"
},
"search": {
@@ -353,7 +382,9 @@
"nav": {
"general": "通用",
"interface": "介面",
"library": "模型庫"
"library": "模型庫",
"organization": "整理",
"modelPaths": "模型路徑"
},
"search": {
"placeholder": "搜尋設定...",
@@ -451,6 +482,8 @@
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"stickyControls": "保持操作列可見",
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
"displayDensity": "顯示密度",
"displayDensityOptions": {
"default": "預設",
@@ -508,6 +541,25 @@
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
"defaultEmbeddingRoot": "Embedding 根目錄",
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"defaultVaeRoot": "VAE 根目錄",
"defaultVaeRootHelp": "設定下載、匯入和移動時的預設 VAE 根目錄",
"defaultUpscalerRoot": "Upscaler 根目錄",
"defaultUpscalerRootHelp": "設定下載、匯入和移動時的預設 Upscaler 根目錄",
"defaultTextEncoderRoot": "Text Encoder 根目錄",
"defaultTextEncoderRootHelp": "設定下載、匯入和移動時的預設 Text Encoder 根目錄",
"defaultClipVisionRoot": "CLIP Vision 根目錄",
"defaultClipVisionRootHelp": "設定下載、匯入和移動時的預設 CLIP Vision 根目錄",
"defaultControlnetRoot": "ControlNet 根目錄",
"defaultControlnetRootHelp": "設定下載、匯入和移動時的預設 ControlNet 根目錄",
"enableOtherModels": "其他模型管理",
"enableOtherModelsHelp": "關閉後,不會掃描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 資料夾,其他模型頁面會保持停用,且無法下載這些模型類型。",
"otherSubTypes": "管理的模型類型",
"otherSubTypesHelp": "選擇要在其他模型頁面中掃描和顯示的其他模型類別。",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "配方儲存路徑",
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -533,6 +585,46 @@
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
}
},
"modelPaths": {
"title": "模型庫路徑",
"description": "LoRA Manager 掃描您模型的根目錄資料夾。這些是獨立模式下從 settings.json 讀取的主要模型位置。",
"restartRequired": "需要重新啟動才能生效",
"coreTypes": "核心模型類型",
"otherTypes": "其他模型類型",
"otherTypesDisabledHint": "尚未啟用任何其他模型類型。請在上方開啟您需要的類型,以設定其資料夾。",
"saveSuccessRestart": "模型庫路徑已更新,需要重新啟動才能生效。",
"pendingRestartNotice": "路徑變更已儲存。請重新啟動 LoRA Manager 以使其生效。",
"pendingRestartBannerTitle": "需要重新啟動才能套用路徑變更",
"pendingRestartBannerMessage": "模型庫路徑已更新。請重新啟動 LoRA Manager 伺服器以掃描新的資料夾。",
"folderKeys": {
"loras": "LoRA 路徑",
"checkpoints": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embeddings": "Embedding 路徑",
"vae": "VAE 路徑",
"upscale_models": "Upscaler 路徑",
"text_encoders": "Text Encoder 路徑",
"clip": "CLIP 路徑(舊版)",
"clip_vision": "CLIP Vision 路徑",
"controlnet": "ControlNet 路徑"
}
},
"directoryPicker": {
"title": "瀏覽資料夾",
"selectFolder": "選擇此資料夾",
"goUp": "上一層",
"pathPlaceholder": "輸入路徑...",
"go": "前往",
"emptyFolder": "沒有子資料夾",
"loadError": "目錄載入失敗"
},
"pathValidation": {
"valid": "路徑有效",
"pathNotFound": "路徑不存在",
"notADirectory": "不是目錄",
"notReadable": "路徑無法讀取",
"notWritable": "路徑無法寫入"
},
"priorityTags": {
"title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -589,6 +681,22 @@
"validTemplate": "範本有效"
}
},
"filenameTemplates": {
"title": "檔案名稱範本",
"help": "依模型類型設定已下載模型的檔案名稱。留空則下載時保留原始檔案名稱;套用空範本會還原先前已重新命名模型所記錄的原始檔案名稱。原始檔案名稱一律會保存在模型的中繼資料中。",
"availablePlaceholders": "可用佔位符:",
"templatePlaceholder": "輸入檔案名稱範本(例如:{base_model}-{model_name}-{version_name}",
"applyButton": "立即套用至模型庫",
"applyHelp": "依範本重新命名此模型類型的所有現有檔案;若範本為空,則改為還原已記錄的原始檔案名稱。警告:重新命名會變更 ComfyUI 載入器所見的相對路徑,因此參照舊檔案名稱的現有工作流可能需要更新。原始檔案名稱會保存在每個模型的中繼資料中。",
"confirmApply": "要依檔案名稱範本重新命名此模型類型的所有現有檔案嗎?這會變更 ComfyUI 載入器所見的相對路徑。原始檔案名稱會保存在每個模型的中繼資料中。",
"confirmRevert": "要將此模型類型所有先前已重新命名的檔案還原為已記錄的原始檔案名稱嗎?這會變更 ComfyUI 載入器所見的相對路徑。沒有記錄原始檔案名稱的檔案將被略過。",
"validation": {
"restoreOriginal": "有效(空範本會還原原始檔案名稱)",
"invalidChars": "偵測到無效字元(檔案名稱不能包含 / \\ < > : \" | ? *",
"invalidPlaceholder": "無效佔位符:{placeholder}",
"validTemplate": "範本有效"
}
},
"exampleImages": {
"downloadLocation": "下載位置",
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
@@ -786,7 +894,6 @@
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新",
@@ -822,14 +929,22 @@
"complete": "自動整理完成",
"error": "錯誤:{error}"
},
"enrichHfAgent": "AI HF 中繼資料增強"
"filenameTemplateProgress": {
"initializing": "正在初始化檔案名稱範本套用...",
"starting": "正在將檔案名稱範本套用至 {type}...",
"processing": "處理中({processed}/{total}- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
"complete": "檔案名稱範本套用完成",
"error": "錯誤:{error}"
},
"enrichHfAgent": "AI 中繼資料增強"
},
"contextMenu": {
"refreshMetadata": "刷新 CivitAI 資料",
"checkUpdates": "檢查更新",
"linkModel": "連結模型",
"linkCivitai": "連結到 CivitAI",
"linkHuggingFace": "連結到 HuggingFace",
"linkModelSource": "連結到模型來源",
"copySyntax": "複製 LoRA 語法",
"copyFilename": "複製模型檔名",
"copyRecipeSyntax": "複製配方語法",
@@ -842,7 +957,6 @@
"replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型",
@@ -852,7 +966,7 @@
"viewAllLoras": "檢視全部 LoRA",
"downloadMissingLoras": "下載缺少的 LoRA",
"deleteRecipe": "刪除配方",
"enrichHfAgent": "AI HF 中繼資料增強"
"enrichHfAgent": "AI 中繼資料增強"
}
},
"recipes": {
@@ -868,6 +982,23 @@
"previousWithShortcut": "上一個配方(←)",
"nextWithShortcut": "下一個配方(→)"
},
"modal": {
"metadata": {
"id": "ID",
"baseModel": "基礎模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "開啟檔案位置",
"copyId": "複製配方 ID"
},
"openFileLocation": {
"success": "檔案位置已成功開啟",
"failed": "開啟檔案位置失敗",
"copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}"
}
},
"workflow": {
"sendWorkflow": "傳送工作流到 ComfyUI",
"sent": "工作流已傳送到 ComfyUI",
@@ -1080,13 +1211,6 @@
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
},
"repair": {
"starting": "正在修復配方元數據...",
"success": "配方元數據修復成功",
"skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
},
"reimport": {
"starting": "正在從來源重新匯入配方...",
"success": "配方已從來源重新匯入成功",
@@ -1170,31 +1294,88 @@
"embeddings": {
"title": "Embedding 模型"
},
"other": {
"title": "其他模型",
"disabled": {
"title": "其他模型管理已關閉",
"description": "啟用後可掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enableButton": "啟用其他模型",
"hint": "您稍後可以在「設定 > 模型庫」中變更管理的模型類型。",
"enableFailed": "啟用其他模型失敗",
"downloadBlocked": "其他模型管理已對此模型類型停用。請在「設定 > 模型庫」中啟用以下載此檔案。",
"enableAction": "啟用其他模型"
},
"noPaths": {
"title": "找不到其他模型資料夾",
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型的資料夾。請在「設定 > 模型路徑」中加入您的模型資料夾,然後重新啟動 LoRA Manager。",
"hintStandalone": "僅會掃描已啟用的模型類型;請在「模型庫 > 預設根目錄」中啟用您需要的類型。",
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
"openSettings": "開啟設定",
"openModelPaths": "設定模型資料夾",
"openSettingsFolder": "開啟設定資料夾"
}
},
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
"collapseAllDisabled": "清單檢視下無法使用",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視",
"viewOptions": "檢視選項",
"treeView": "樹狀檢視",
"listView": "清單檢視",
"recursiveOn": "包含子資料夾",
"recursiveOff": "僅目前資料夾",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用",
"createFolder": "新增資料夾",
"newSubfolder": "新增子資料夾",
"showEmptyFolders": "顯示空資料夾",
"createFolderResult": {
"success": "已建立資料夾 \"{name}\"",
"failed": "建立資料夾失敗: {message}",
"unsupported": "此頁面不支援建立資料夾",
"noRoot": "未設定模型根目錄"
},
"deleteFolder": "刪除資料夾",
"deleteFolderModal": {
"title": "刪除資料夾?",
"message": "該資料夾及其中的所有內容都將從磁碟上永久刪除。",
"folderLabel": "資料夾",
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
"success": "已刪除資料夾 \"{name}\"",
"successWithFiles": "已刪除資料夾 \"{name}\",同時刪除了另外 {count} 項內容",
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
},
"renameFolder": "重新命名資料夾",
"renameFolderResult": {
"success": "資料夾已重新命名為 \"{name}\"",
"failed": "重新命名資料夾失敗: {message}",
"targetExists": "此處已存在同名資料夾",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援重新命名資料夾",
"noRoot": "未設定模型根目錄"
},
"dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
"emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作"
},
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
"createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
@@ -1322,9 +1503,9 @@
"download": {
"title": "從網址下載模型",
"titleWithType": "從網址下載 {type}",
"civitaiUrl": "CivitAI 網址:",
"civitaiUrl": "模型網址:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAI、CivArchiveHugging Face URL。支援批量下載多個 URL。",
"urlHint": "每行輸入一個 CivitAI、CivArchiveHugging Face 或 ModelScope URL。支援批量下載多個 URL。",
"selectHfFiles": "選擇從此倉庫下載的檔案:",
"selectAll": "全選",
"fetchingRepoFiles": "正在獲取倉庫檔案...",
@@ -1357,9 +1538,9 @@
"inLibrary": "已在庫中"
},
"errors": {
"invalidUrl": "CivitAI 網址格式無效",
"invalidUrl": "模型網址格式無效",
"noVersions": "此模型無可用版本",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face / ModelScope URL。",
"noModelFiles": "在此倉庫中未找到模型檔案。"
},
"status": {
@@ -1373,6 +1554,10 @@
"progress": {
"currentFile": "目前檔案:",
"downloading": "下載中:{name}",
"metadata": "中繼資料:{name}",
"indexingFile": "正在讀取模型檔案...",
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
"fetchingMetadata": "正在取得中繼資料...",
"transferred": "已下載:{downloaded} / {total}",
"transferredSimple": "已下載:{downloaded}",
"transferredUnknown": "已下載:--",
@@ -1441,6 +1626,11 @@
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
"action": "全部檢查"
},
"filenameTemplateConfirm": {
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
"titleRevert": "要還原原始檔案名稱嗎?",
"revertButton": "還原原始檔案名稱"
},
"bulkAddTags": {
"title": "新增標籤到多個模型",
"description": "新增標籤到",
@@ -1467,6 +1657,41 @@
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤銷",
"undone": "已撤銷",
"undoFailed": "撤銷重新匹配失敗:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 個條目",
"failed": "重新匹配失敗",
"completedWithWarnings": "重新匹配已完成——建議檢查",
"cancelledNote": "執行在完成前已取消——統計不完整。",
"statMatched": "已匹配條目",
"statReview": "需要檢查",
"statUnresolved": "未匹配",
"statErrors": "錯誤",
"reviewSection": "需要檢查的檔案名稱匹配({count})",
"columnRecipe": "配方",
"columnEntry": "條目",
"columnFile": "匹配到的檔案",
"columnUndo": "撤銷",
"copyReport": "複製報告",
"close": "關閉",
"scope_global": "所有配方",
"scope_bulk": "所選配方",
"scope_single": "單個配方"
},
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -1489,12 +1714,16 @@
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
"root": "根目錄"
},
"linkHuggingFace": {
"title": "連結到 HuggingFace",
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
"urlLabel": "HuggingFace 倉庫 URL",
"linkModelSource": {
"title": "連結到模型來源",
"infoText": "貼上模型頁面 URL 以關聯此模型與其來源。關聯後可對 Hugging Face 和 ModelScope 模型啟用 AI 中繼資料增強。",
"urlLabel": "模型頁面 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
"helpText": "請輸入完整的模型頁面 URL。支援的站點:",
"enrichNote": "AI 增強需要可讀取的模型卡。未提供模型卡的站點(目前為 TensorArt)只能建立連結。",
"urlRequired": "請輸入模型頁面 URL。",
"invalidUrl": "URL 不受支援。支援的站點:Hugging Face、ModelScope、TensorArt。",
"linking": "正在連結模型來源...",
"confirmAction": "儲存並連結"
},
"relinkCivitai": {
@@ -1578,7 +1807,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "數值",
"add": "新增",
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
"invalidRange": "無效的範圍格式。請使用 x.x-y.y",
"invalidValue": "請輸入有效的數值",
"saveFailed": "儲存預設參數失敗",
"added": "已新增預設參數",
"updated": "已更新預設參數"
},
"triggerWords": {
"label": "觸發詞",
@@ -1589,7 +1822,7 @@
"addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"copyOrEditWord": "點擊複製,雙擊編輯",
"deleteWord": "刪除觸發詞",
"suggestions": {
"noSuggestions": "無可用建議",
@@ -1649,8 +1882,8 @@
"showCount": "顯示範例({count}",
"hideExamples": "隱藏範例",
"addExamples": "新增範例",
"previousExample": "上一個範例",
"nextExample": "下一個範例",
"previousExample": "上一個範例[",
"nextExample": "下一個範例]",
"noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處",
@@ -1736,7 +1969,7 @@
"empty": "此模型尚無版本歷史。",
"error": "載入版本失敗。",
"missingModelId": "此模型缺少 CivitAI 模型 ID。",
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
"sourceGroupInfo": "這是一個 {source} 模型組。打開庫頁面即可在網格中查看所有版本。",
"confirm": {
"delete": "要從庫中刪除此版本嗎?"
},
@@ -1808,6 +2041,10 @@
"title": "初始化 Embedding 管理器",
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
},
"other": {
"title": "正在初始化其他模型管理器",
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在載入並處理配方,可能需要幾分鐘..."
@@ -1914,10 +2151,52 @@
"tabs": {
"gettingStarted": "快速開始",
"updateVlogs": "更新影片",
"documentation": "文件"
"documentation": "文件",
"shortcuts": "快捷鍵"
},
"gettingStarted": {
"title": "LoRA 管理器快速開始"
"title": "LoRA 管理器快速開始",
"replayTutorial": "重新播放教學"
},
"shortcuts": {
"title": "鍵盤與滑鼠快捷鍵",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選取與批量模式",
"navigation": "導覽",
"modelModal": "模型 / 配方彈窗",
"mediaViewer": "媒體檢視器 / 範例展示"
},
"keys": {
"click": "點擊",
"drag": "拖曳",
"rightClick": "右鍵點擊",
"letter": "字母鍵",
"swipe": "滑動"
},
"entries": {
"focusSearch": "聚焦搜尋欄",
"closeModal": "關閉彈窗 / 面板",
"openShortcuts": "開啟此快捷鍵面板",
"refresh": "重新整理模型列表",
"fetchMetadata": "從 CivitAI 擷取中繼資料(僅限模型頁面)",
"downloadModel": "下載模型(僅限模型頁面)",
"toggleBulkMode": "切換批量模式",
"selectAll": "選取所有可見模型",
"rangeSelect": "範圍選取",
"marqueeSelect": "框選卡片(在網格空白處拖曳)",
"exitBulkMode": "離開批量模式",
"bulkActions": "在已選取的卡片上:批量操作選單",
"globalActions": "在頁面空白處:全域操作選單(檢查更新、管理已排除的模型)",
"scrollPages": "捲動頁面",
"jumpAlphabet": "字母列跳轉",
"prevNext": "上一個 / 下一個模型",
"deleteEntry": "刪除",
"cycleMedia": "切換媒體(範例展示中的 [ / ])",
"swipeTouch": "在觸控裝置上滑動切換媒體",
"closeViewer": "關閉檢視器"
}
},
"updateVlogs": {
"title": "最新更新",
@@ -1934,7 +2213,8 @@
"settings": "設定與配置",
"extensions": "擴充功能",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "檢查更新",
@@ -2060,6 +2340,9 @@
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}",
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
"noModelsSelected": "未選擇任何模型"
},
"recipes": {
@@ -2087,6 +2370,7 @@
"createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載",
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
"noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
@@ -2141,16 +2425,10 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何配方",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
"reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
@@ -2225,11 +2503,14 @@
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
"mappingsCleared": "基礎模型路徑對應已清除",
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
"downloadTemplatesUpdated": "下載路徑範本已更新",
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
"filenameTemplatesUpdated": "檔案名稱範本已更新",
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
"recipesPathUpdated": "配方儲存路徑已更新",
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
"settingsUpdated": "設定已更新:{setting}",
@@ -2329,7 +2610,9 @@
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
"noCivitaiInfo": "無 CivitAI 資訊",
"missingHash": "模型雜湊不可用"
"missingHash": "模型雜湊不可用",
"enrichNeedsSource": "請先將此模型連結到模型來源(連結模型 → 連結到模型來源)",
"enrichUnsupportedSource": "{source} 模型不支援 AI 增強"
},
"exampleImages": {
"pathUpdated": "範例圖片路徑已更新",
@@ -2487,6 +2770,17 @@
"rebuilding": "重建快取中...",
"rebuildFailed": "重建快取失敗:{error}",
"retry": "重試"
},
"otherModels": {
"title": "其他模型管理現已可用",
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enable": "啟用其他模型",
"openSettings": "開啟設定"
},
"pager": {
"previous": "上一則通知",
"next": "下一則通知",
"position": "第 {current} 則通知,共 {total} 則"
}
}
}
+276 -1
View File
@@ -17,6 +17,9 @@ import types as _types
import time
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 (
ensure_settings_file,
get_settings_dir,
@@ -172,6 +175,13 @@ class Config:
self.embeddings_roots = None
self.base_models_roots = self._init_checkpoint_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)
self.extra_loras_roots: List[str] = []
self.extra_checkpoints_roots: List[str] = []
@@ -336,6 +346,10 @@ class Config:
"unet": list(self.unet_roots or []),
"embeddings": list(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)
normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths
@@ -522,6 +536,7 @@ class Config:
roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or [])
roots.extend(self.other_roots or [])
# Include extra paths for scanning symlinks
roots.extend(self.extra_loras_roots or [])
roots.extend(self.extra_checkpoints_roots or [])
@@ -862,6 +877,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []:
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
for root in self.extra_loras_roots or []:
preview_roots.update(self._expand_preview_root(root))
@@ -882,7 +899,7 @@ class Config:
path for path in preview_roots if path.is_absolute()
}
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.loras_roots or []),
len(self.extra_loras_roots or []),
@@ -890,6 +907,7 @@ class Config:
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
len(self.other_roots or []),
len(self._path_mappings),
)
@@ -1128,6 +1146,155 @@ class Config:
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(
self,
folder_paths: Mapping[str, Any],
@@ -1151,6 +1318,16 @@ class Config:
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_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.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)
extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get("loras", []) or []
@@ -1267,6 +1444,104 @@ class Config:
logger.warning(f"Error initializing embedding paths: {e}")
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)
(
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 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 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:
if not preview_path:
return ""
+7 -8
View File
@@ -219,6 +219,7 @@ class LoraManager:
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
@@ -236,6 +237,10 @@ class LoraManager:
embedding_scanner.initialize_in_background(),
name="embedding_cache_init",
),
asyncio.create_task(
other_scanner.initialize_in_background(),
name="other_cache_init",
),
asyncio.create_task(
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.base_models_roots or [])
all_roots.update(config.embeddings_roots or [])
all_roots.update(config.other_roots or [])
total_deleted = 0
total_size_freed = 0
@@ -460,18 +466,11 @@ class LoraManager:
# Cancel any in-flight scanner initialization tasks so thread-pool
# workers (e.g. _initialize_cache_sync) can break out of their loops
# 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)
if scanner is not None and hasattr(scanner, "cancel_task"):
scanner.cancel_task()
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:
logger.error(f"Error during cleanup: {e}", exc_info=True)
+3 -2
View File
@@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = {
"get_lora_scanner": "lora",
"get_checkpoint_scanner": "checkpoint",
"get_embedding_scanner": "embedding",
"get_other_scanner": "other",
}
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:
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
``\"embedding\"``) for *model_path*.
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
``\"embedding\"``, or ``\"other\"``) for *model_path*.
Falls back to ``\"lora\"`` when unknown.
"""
+2 -2
View File
@@ -78,7 +78,7 @@ class CheckpointLoaderLM:
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
@@ -126,7 +126,7 @@ class CheckpointLoaderLM:
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
+1 -1
View File
@@ -601,7 +601,7 @@ class SaveImageLM:
os.path.basename(name),
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")
if file_name in candidates:
return model
+2 -2
View File
@@ -93,7 +93,7 @@ class UNETLoaderLM:
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
@@ -141,7 +141,7 @@ class UNETLoaderLM:
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
+1 -1
View File
@@ -156,7 +156,7 @@ def _find_missing_loras(names: list[str]) -> list[str]:
lookup = {}
basename_candidates = {}
for item in cache.raw_data:
for item in list(cache.raw_data):
file_path = item.get("file_path")
if not file_path:
continue
+21
View File
@@ -8,6 +8,7 @@ from typing import Dict, Any
from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS
from ...services.metadata_service import get_default_metadata_provider
from ...utils.constants import is_empty_placeholder_hash
logger = logging.getLogger(__name__)
@@ -524,6 +525,26 @@ class AutomaticMetadataParser(RecipeMetadataParser):
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)
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':
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
if local_lora:
+22 -1
View File
@@ -196,7 +196,7 @@ class RecipeFormatParser(RecipeMetadataParser):
filtered_gen_params[key] = value
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,
'gen_params': filtered_gen_params,
'tags': recipe_metadata.get('tags', []),
@@ -208,3 +208,24 @@ class RecipeFormatParser(RecipeMetadataParser):
except Exception as e:
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
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,
BulkMetadataRefreshUseCase,
DownloadModelUseCase,
FilenameTemplateUseCase,
)
from ..services.websocket_progress_callback import (
WebSocketBroadcastCallback,
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ..utils.exif_utils import ExifUtils
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
ModelAutoOrganizeHandler,
ModelCivitaiHandler,
ModelDownloadHandler,
ModelFilenameTemplateHandler,
ModelHandlerSet,
ModelListingHandler,
ModelManagementHandler,
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
self.model_lifecycle_service: ModelLifecycleService | None = None
self.websocket_progress_callback = WebSocketProgressCallback()
self.metadata_progress_callback = WebSocketBroadcastCallback()
self.filename_template_progress_callback = (
WebSocketFilenameTemplateProgressCallback()
)
self._handler_set: ModelHandlerSet | 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,
server_i18n=self._server_i18n,
logger=logger,
page_context_provider=self._get_page_context_provider(),
)
listing = ModelListingHandler(
service=service,
@@ -201,6 +208,17 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager,
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(
service=service,
update_service=update_service,
@@ -217,6 +235,7 @@ class BaseModelRoutes(ABC):
civitai=civitai,
move=move,
auto_organize=auto_organize,
filename_template=filename_template,
updates=updates,
)
@@ -250,6 +269,10 @@ class BaseModelRoutes(ABC):
"""Get expected model types string for error messages - to be overridden by subclasses."""
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):
"""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)
@@ -0,0 +1,110 @@
"""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,
)
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,
}
)
is_diffusion = is_diffusion_model_download(
model_type,
file_types=(str(t) for t in file_types),
base_model=base_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
)
+289 -17
View File
@@ -53,11 +53,15 @@ from ...utils.constants import (
PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS,
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 .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.directory_browser import browse_directory
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
@@ -419,6 +423,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | 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):
"""Subset of PromptServer used by the handlers."""
@@ -657,9 +666,21 @@ class HealthCheckHandler:
"lora": ServiceRegistry.get_lora_scanner,
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
"embedding": ServiceRegistry.get_embedding_scanner,
"other": ServiceRegistry.get_other_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:
return web.json_response({"status": "ok"})
@@ -671,7 +692,7 @@ class HealthCheckHandler:
page accepts the update and only reloads once all scanners are done.
"""
pending: list[str] = []
for name, getter in self._scanner_getters.items():
for name, getter in self._active_scanner_getters().items():
try:
scanner = await getter()
except Exception:
@@ -756,10 +777,19 @@ class DoctorHandler:
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
("other", "Other Models", ServiceRegistry.get_other_scanner),
)
)
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:
try:
client_version = (request.query.get("clientVersion") or "").strip()
@@ -807,7 +837,7 @@ class DoctorHandler:
repaired: list[dict[str, Any]] = []
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:
scanner = await factory()
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
@@ -839,7 +869,7 @@ class DoctorHandler:
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
@@ -1071,7 +1101,7 @@ class DoctorHandler:
overall_status = "ok"
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:
scanner = await factory()
persisted = None
@@ -1156,7 +1186,7 @@ class DoctorHandler:
total_conflict_groups = 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
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
# models via relative paths with extensions, so conflicts there would
@@ -1536,6 +1566,46 @@ class SettingsHandler:
response_data["civitai_api_key_set"] = bool(raw_key)
raw_llm_key = self._settings.get("llm_api_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)
if settings_file:
response_data["settings_file"] = settings_file
@@ -2065,6 +2135,7 @@ class ServiceRegistryAdapter:
get_embedding_scanner: Callable[[], Awaitable[Any]]
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
class ModelLibraryHandler:
@@ -2089,6 +2160,8 @@ class ModelLibraryHandler:
return "checkpoint"
if normalized in {"embedding", "textualinversion"}:
return "embedding"
if normalized in VALID_OTHER_CIVITAI_TYPES:
return "other"
return None
async def _get_scanner_for_type(self, model_type: str | None):
@@ -2099,6 +2172,13 @@ class ModelLibraryHandler:
return normalized_type, await self._service_registry.get_checkpoint_scanner()
if normalized_type == "embedding":
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
async def _get_download_history_service(self):
@@ -2190,6 +2270,11 @@ class ModelLibraryHandler:
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_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:
try:
@@ -2228,6 +2313,13 @@ class ModelLibraryHandler:
exists = True
model_type = "embedding"
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:
return web.json_response(
@@ -2245,7 +2337,7 @@ class ModelLibraryHandler:
history_service = await self._get_download_history_service()
has_been_downloaded = False
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(
candidate_type,
model_version_id,
@@ -2267,6 +2359,7 @@ class ModelLibraryHandler:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
checkpoint_versions = []
embedding_versions = []
other_versions = []
if not lora_versions and checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
model_id
@@ -2275,6 +2368,13 @@ class ModelLibraryHandler:
embedding_versions = await embedding_scanner.get_model_versions_by_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
versions = []
@@ -2306,9 +2406,18 @@ class ModelLibraryHandler:
"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()
for candidate_type in ("lora", "checkpoint", "embedding"):
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
@@ -2363,6 +2472,11 @@ class ModelLibraryHandler:
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_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]] = []
for model_id in model_ids:
@@ -2398,6 +2512,17 @@ class ModelLibraryHandler:
})
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({
"modelId": model_id,
"modelType": None,
@@ -2665,12 +2790,40 @@ class ModelLibraryHandler:
normalized_type, scanner = await self._get_scanner_for_type(model_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(
{
"success": False,
"error": f'Model type "{model_type}" is not supported',
},
status=400,
"success": True,
"modelId": model_id,
"modelName": model_name,
"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:
@@ -2712,6 +2865,7 @@ class ModelLibraryHandler:
"modelId": model_id,
"modelName": model_name,
"modelType": model_type,
"supported": True,
"versions": enriched_versions,
}
)
@@ -2786,12 +2940,32 @@ class ModelLibraryHandler:
model_type.lower() for model_type in CIVITAI_USER_MODEL_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] = {
**{alias: lora_scanner for alias in lora_type_aliases},
"checkpoint": checkpoint_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]] = []
history_service = await self._get_download_history_service()
@@ -2815,12 +2989,17 @@ class ModelLibraryHandler:
"embedding",
model_ids,
)
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
"other",
model_ids,
)
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
"lora": lora_downloaded,
"locon": lora_downloaded,
"dora": lora_downloaded,
"checkpoint": checkpoint_downloaded,
"textualinversion": embedding_downloaded,
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
}
for model in models:
if not isinstance(model, dict):
@@ -3274,6 +3453,18 @@ class FileSystemHandler:
subprocess.Popen(["open", "-R", settings_file])
else:
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])
return web.json_response(
@@ -3307,6 +3498,76 @@ class FileSystemHandler:
logger.error("Failed to open wildcards 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:
"""Handler for autocomplete via TagFTSIndex."""
@@ -3882,8 +4143,9 @@ class MiscHandlerSet:
doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
hf_handler: Any = None,
model_source_handler: Any = None,
agent_handler: Any = None,
download_routing: Any = None,
) -> None:
self.health = health
self.settings = settings
@@ -3902,8 +4164,9 @@ class MiscHandlerSet:
self.doctor = doctor
self.example_workflows = example_workflows
self.base_model = base_model
self.hf_handler = hf_handler
self.model_source_handler = model_source_handler
self.agent_handler = agent_handler
self.download_routing = download_routing
def to_route_mapping(
self,
@@ -3949,19 +4212,27 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,
# Hugging Face handlers
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
"download_hf_model": self.hf_handler.download_hf_model,
"set_hf_url": self.hf_handler.set_hf_url,
# External model sources (Hugging Face / ModelScope)
"list_model_source_files": self.model_source_handler.list_model_source_files,
"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
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
# Download routing handler
"get_download_routing": self.download_routing.get_download_routing,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
@@ -3975,6 +4246,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_lora_scanner=ServiceRegistry.get_lora_scanner,
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_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_backup_service=ServiceRegistry.get_backup_service,
)
+265 -2
View File
@@ -15,6 +15,10 @@ from aiohttp import web
import jinja2
from ...config import config
from ...services.active_filters_store import (
ActiveFiltersStore,
active_filters_to_query_kwargs,
)
from ...services.download_coordinator import DownloadCoordinator
from ...services.connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE,
@@ -33,10 +37,14 @@ from ...services.use_cases import (
DownloadModelEarlyAccessError,
DownloadModelUseCase,
DownloadModelValidationError,
FilenameTemplateUseCase,
MetadataRefreshProgressReporter,
)
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.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
@@ -86,6 +94,7 @@ class ModelPageView:
settings_service: SettingsManager,
server_i18n,
logger: logging.Logger,
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
) -> None:
self._template_env = template_env
self._template_name = template_name
@@ -93,6 +102,7 @@ class ModelPageView:
self._settings = settings_service
self._server_i18n = server_i18n
self._logger = logger
self._page_context_provider = page_context_provider
def _load_supporters(self) -> dict[str, Any]:
"""Load supporters data from JSON file."""
@@ -206,6 +216,16 @@ class ModelPageView:
self._logger.error("Error loading cache data: %s", cache_error)
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(
**template_context
)
@@ -1595,12 +1615,50 @@ class ModelQueryHandler:
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
# widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint.
apply_filters = (
"recursive" in request.query
use_active_filters
or "recursive" in request.query
or folder is not None
or bool(base_models)
or bool(model_types)
@@ -1634,6 +1692,50 @@ class ModelQueryHandler:
)
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:
"""Coordinate downloads and progress reporting."""
@@ -1812,6 +1914,11 @@ class ModelDownloadHandler:
response_payload["status"] = status
if "message" in progress_data:
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:
response_payload["message"] = progress_data["message"]
@@ -2381,6 +2488,90 @@ class ModelMoveHandler:
self._move_service = move_service
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 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:
try:
data = await request.json()
@@ -2505,6 +2696,71 @@ class ModelAutoOrganizeHandler:
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:
"""Handle update tracking requests."""
@@ -3272,6 +3528,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler
move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler
filename_template: ModelFilenameTemplateHandler
updates: ModelUpdateHandler
def to_route_mapping(
@@ -3331,14 +3588,20 @@ class ModelHandlerSet:
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
"move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder,
"delete_folder": self.move.delete_folder,
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models,
"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_preview_url": self.query.get_model_preview_url,
"get_model_civitai_url": self.query.get_model_civitai_url,
"get_model_metadata": self.query.get_model_metadata,
"get_model_description": self.query.get_model_description,
"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,
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
"set_model_update_ignore": self.updates.set_model_update_ignore,
+639
View File
@@ -0,0 +1,639 @@
"""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,
is_valid_source_id,
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
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"
# 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 [],
):
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)
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(
"user/repo" if source.platform != "tensorart" else "827823520299086029"
),
}
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('user/repo')})"
if s.platform != "tensorart"
else f"{s.label} (https://tensor.art/models/<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 is_valid_source_id(repo):
return web.json_response(
{"error": "Missing or invalid 'repo' parameter (expected owner/name)"},
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
)
# `owner/name` only; the components become path segments below.
if not is_valid_source_id(repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
owner, repo_name = repo.split("/", 1)
# 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, owner, repo_name)
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.
resolve_url = source.file_download_url(repo, filename, revision)
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")
)
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,
)
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,
)
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",
"checkpoints": "get_checkpoint_scanner",
"embeddings": "get_embedding_scanner",
"other": "get_other_scanner",
}
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
+263 -365
View File
@@ -9,7 +9,6 @@ import re
import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
from aiohttp import web
@@ -26,6 +25,7 @@ from ...services.recipes import (
RecipeValidationError,
)
from ...services.metadata_service import get_default_metadata_provider
from ...services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
from ...utils.civitai_utils import (
build_civitai_image_page_url,
extract_civitai_image_id,
@@ -33,6 +33,7 @@ from ...utils.civitai_utils import (
rewrite_preview_url,
)
from ...utils.constants import NSFW_LEVELS
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger
@@ -73,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
async def _parse_relaxed_flag(request: web.Request) -> bool:
"""Read the relaxed-rematch flag from the JSON body or query string.
The flag defaults to False (strict candidacy). A JSON body value wins;
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
"""
relaxed = False
if request.can_read_body:
try:
data = await request.json()
except Exception: # noqa: BLE001 - any parse failure means no flag
data = None
if isinstance(data, dict):
relaxed = bool(data.get("relaxed"))
if not relaxed:
relaxed = request.query.get("relaxed", "").lower() == "true"
return relaxed
@dataclass(frozen=True)
class RecipeHandlerSet:
"""Group of handlers providing recipe route implementations."""
@@ -128,11 +149,6 @@ class RecipeHandlerSet:
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
"scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress,
"rematch_recipes": self.management.rematch_recipes,
"cancel_rematch": self.management.cancel_rematch,
"rematch_recipe": self.management.rematch_recipe,
@@ -352,6 +368,17 @@ class RecipeListingHandler:
if not recipe:
return web.json_response({"error": "Recipe not found"}, status=404)
# Expose the on-disk recipe JSON path so the modal can offer
# "open file location" without guessing the storage layout.
recipe = dict(recipe)
try:
json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
except Exception: # pragma: no cover - details must still load
json_path = None
if json_path:
recipe["recipe_json_path"] = json_path
return web.json_response(recipe)
except Exception as exc:
self._logger.error(
@@ -473,17 +500,32 @@ class RecipeQueryHandler:
cache = await recipe_scanner.get_cached_data()
base_model_counts: Dict[str, int] = {}
unknown_count = 0
for recipe in getattr(cache, "raw_data", []):
base_model = recipe.get("base_model")
if base_model:
base_model_counts[base_model] = (
base_model_counts.get(base_model, 0) + 1
)
else:
unknown_count += 1
sorted_models = [
{"name": model, "count": count}
for model, count in base_model_counts.items()
]
if unknown_count:
# Synthetic "Unknown" bucket for recipes whose base model could
# not be determined. `value` carries the filter marker so the
# UI can display "Unknown" without colliding with real base
# model strings.
sorted_models.append(
{
"name": "Unknown",
"value": UNKNOWN_BASE_MODEL_FILTER,
"count": unknown_count,
}
)
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
if limit > 0:
sorted_models = sorted_models[:limit]
@@ -769,157 +811,6 @@ class RecipeManagementHandler:
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def repair_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Check if already running
if self._ws_manager.is_recipe_repair_running():
return web.json_response(
{"success": False, "error": "Recipe repair already in progress"},
status=409,
)
recipe_scanner.reset_cancellation()
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_repair_progress(data)
# Run in background to avoid timeout
async def run_repair():
try:
await recipe_scanner.repair_all_recipes(
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(
f"Error in recipe repair task: {e}", exc_info=True
)
await self._ws_manager.broadcast_recipe_repair_progress(
{"status": "error", "error": str(e)}
)
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
# Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit?
# Actually cleanup_recipe_repair_progress is fine as long as we waited enough.
self._ws_manager.cleanup_recipe_repair_progress()
asyncio.create_task(run_repair())
return web.json_response(
{"success": True, "message": "Recipe repair started"}
)
except Exception as exc:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_repair(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task()
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
"""Bulk repair metadata for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array and iterates
repair_recipe_by_id over each entry, collecting statistics.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
data = await request.json()
recipe_ids = data.get("recipe_ids", [])
if not recipe_ids:
return web.json_response(
{"success": False, "error": "recipe_ids are required"},
status=400,
)
total = len(recipe_ids)
repaired = 0
skipped = 0
errors = 0
recipes = []
for recipe_id in recipe_ids:
try:
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
if result.get("success"):
repaired += result.get("repaired", 0)
skipped += result.get("skipped", 0)
if result.get("recipe"):
recipes.append(result["recipe"])
else:
errors += 1
except RecipeNotFoundError:
skipped += 1
except Exception as exc:
self._logger.error(
"Error repairing recipe %s: %s", recipe_id, exc
)
errors += 1
return web.json_response({
"success": True,
"total": total,
"repaired": repaired,
"skipped": skipped,
"errors": errors,
"recipes": recipes,
})
except Exception as exc:
self._logger.error(
"Error performing bulk repair: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def repair_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -931,12 +822,9 @@ class RecipeManagementHandler:
)
# Mutual exclusion: a global rematch cannot start while a rematch
# OR a repair is already running — both mutate recipes under the
# same mutation lock.
if (
self._ws_manager.is_recipe_rematch_running()
or self._ws_manager.is_recipe_repair_running()
):
# is already running — both mutate recipes under the same
# mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
@@ -944,6 +832,8 @@ class RecipeManagementHandler:
recipe_scanner.reset_cancellation()
relaxed = await _parse_relaxed_flag(request)
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
@@ -951,7 +841,8 @@ class RecipeManagementHandler:
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
progress_callback=progress_callback,
relaxed=relaxed,
)
except Exception as e:
self._logger.error(
@@ -1024,7 +915,13 @@ class RecipeManagementHandler:
status=400,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
relaxed = bool(data.get("relaxed")) or (
request.query.get("relaxed", "").lower() == "true"
)
result = await recipe_scanner.rematch_recipes_bulk(
recipe_ids, relaxed=relaxed
)
return web.json_response(result)
except Exception as exc:
self._logger.error(
@@ -1053,7 +950,10 @@ class RecipeManagementHandler:
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
relaxed = await _parse_relaxed_flag(request)
result = await recipe_scanner.rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
@@ -1074,12 +974,14 @@ class RecipeManagementHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reimport_recipe(self, request: web.Request) -> web.Response:
"""Delete a recipe and re-import it from its source URL.
"""Delete a recipe and re-import it from its source.
This gives the recipe a fresh start re-downloads the image from
CivitAI, re-parses EXIF metadata with the current parser, and
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
are carried over from the old recipe.
Gives the recipe a fresh start: URL-sourced recipes re-download the
image from CivitAI; local ones re-parse the saved recipe image. Both
use the original embedded generation metadata (the appended recipe
metadata block is ignored) with the current parser, and re-resolve
LoRAs / checkpoint. User edits (title, tags, favorite) are carried
over from the old recipe.
"""
try:
await self._ensure_dependencies_ready()
@@ -1092,13 +994,40 @@ class RecipeManagementHandler:
if not old_recipe:
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
source_path = old_recipe.get("source_path")
if not source_path:
old_file_path = old_recipe.get("file_path", "")
old_folder = os.path.dirname(old_file_path) if old_file_path else None
source_path = old_recipe.get("source_path") or ""
image_id = extract_civitai_image_id(source_path) if source_path else None
# Local re-import sources: an explicit local source_path, or — when
# no usable source_path was recorded (drag & drop / file-picker
# imports, or a dangling path left by an earlier re-import) — the
# recipe's own saved image, which still carries the original
# embedded generation metadata next to the recipe metadata block.
# In the fallback case nothing is persisted as source_path: the
# recipe's own previous preview is not an external source, and it
# is deleted together with the old recipe below.
local_source = None
persisted_source_path = ""
if not image_id and source_path and os.path.isfile(source_path):
local_source = source_path
persisted_source_path = source_path
elif (
not image_id
and not source_path.startswith(("http://", "https://"))
and old_file_path
and os.path.isfile(old_file_path)
):
local_source = old_file_path
if not image_id and not local_source:
return web.json_response(
{
"success": False,
"error": (
"Recipe has no source URL — cannot re-import. "
"Recipe has no re-importable source (no source URL "
"and no accessible local image). "
"Use repair or manual import instead."
),
},
@@ -1112,41 +1041,66 @@ class RecipeManagementHandler:
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
del user_edits["tags"]
old_file_path = old_recipe.get("file_path", "")
old_folder = os.path.dirname(old_file_path) if old_file_path else None
image_id = extract_civitai_image_id(source_path)
is_local_file = not image_id and os.path.isfile(source_path)
if not image_id and not is_local_file:
return web.json_response(
{
"success": False,
"error": (
"Recipe source is neither a valid CivitAI image URL "
"nor an accessible local file. "
"Use repair or manual import instead."
),
},
status=400,
)
if is_local_file:
if local_source:
return await self._do_reimport_from_local(
source_path,
local_source,
recipe_scanner,
recipe_id=recipe_id,
target_dir=old_folder,
user_edits=user_edits,
old_title=old_recipe.get("title", ""),
persisted_source_path=persisted_source_path,
)
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
# Optional caller-supplied metadata payload (companion browser
# extension re-import). Only honored for CivitAI image page
# sources; everything else uses the native URL import below.
params = request.rel_url.query
payload_image_url = params.get("image_url")
payload_name = params.get("name")
payload_resources = params.get("resources")
has_import_payload = bool(
payload_image_url and payload_name and payload_resources
)
import_response: web.Response | None = None
if has_import_payload and image_id:
try:
async with self._import_semaphore:
import_response = await self._import_remote_recipe_impl(
image_url=payload_image_url,
name=payload_name,
resources_raw=payload_resources,
gen_params_raw=params.get("gen_params"),
tags_raw=params.get("tags"),
base_model=params.get("base_model", "") or "",
source_path=source_path,
target_dir=old_folder,
)
except RecipeValidationError as exc:
# Malformed resources/gen_params JSON: treat as "no
# payload" and use the legacy URL re-import.
self._logger.warning(
"Ignoring malformed re-import payload for recipe %s "
"(%s); falling back to source URL re-import",
recipe_id,
exc,
)
except Exception as exc:
self._logger.warning(
"Payload-based re-import failed for recipe %s: %s; "
"falling back to source URL re-import",
recipe_id,
exc,
)
if import_response is None:
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
@@ -1173,14 +1127,19 @@ class RecipeManagementHandler:
exc,
)
return web.json_response(
{
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
response_body: Dict[str, Any] = {
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
loras_count = await self._count_recipe_loras(
recipe_scanner, new_recipe_id
)
if loras_count is not None:
response_body["loras_count"] = loras_count
return web.json_response(response_body)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except RecipeValidationError as exc:
@@ -1193,18 +1152,6 @@ class RecipeManagementHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_repair_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_repair_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response(
{"success": False, "message": "No repair in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def import_remote_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -1225,31 +1172,14 @@ class RecipeManagementHandler:
if not resources_raw:
raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload(
resources_raw
)
gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info(
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# Throttle concurrent imports to avoid starving ComfyUI's event loop
async with self._import_semaphore:
return await self._do_import_remote_recipe(
return await self._import_remote_recipe_impl(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(params.get("tags")),
resources_raw=resources_raw,
gen_params_raw=params.get("gen_params"),
tags_raw=params.get("tags"),
base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url,
)
@@ -1263,6 +1193,52 @@ class RecipeManagementHandler:
)
return web.json_response({"error": str(exc)}, status=500)
async def _import_remote_recipe_impl(
self,
*,
image_url: str,
name: str,
resources_raw: str,
gen_params_raw: Optional[str],
tags_raw: Optional[str],
base_model: str,
source_path: str,
target_dir: str | None = None,
) -> web.Response:
"""Payload-based remote import engine shared by import-remote and the
extension-driven re-import path.
Parses the caller-supplied payloads and delegates to
:meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on
malformed payloads so callers can decide how to handle them (the
re-import path falls back to the legacy URL import).
"""
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
gen_params_request = self._parse_gen_params(gen_params_raw)
self._logger.info(
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
return await self._do_import_remote_recipe(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(tags_raw),
base_model=base_model,
source_path=source_path,
target_dir=target_dir,
)
async def _do_import_remote_recipe(
self,
*,
@@ -1274,6 +1250,7 @@ class RecipeManagementHandler:
tags: list[Any],
base_model: str,
source_path: str,
target_dir: str | None = None,
) -> web.Response:
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
@@ -1437,6 +1414,7 @@ class RecipeManagementHandler:
tags=tags,
metadata=metadata,
extension=extension,
target_dir=target_dir,
)
return web.json_response(result.payload, status=result.status)
@@ -1901,6 +1879,25 @@ class RecipeManagementHandler:
return []
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
async def _count_recipe_loras(
self, recipe_scanner: Any, recipe_id: Optional[str]
) -> Optional[int]:
"""Best-effort LoRA count for a freshly saved recipe (for the
re-import response). Returns None when the recipe cannot be read."""
if not recipe_id:
return None
try:
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
except Exception as exc:
self._logger.debug(
"Could not read new recipe %s for loras_count: %s",
recipe_id,
exc,
)
return None
loras = (recipe or {}).get("loras")
return len(loras) if isinstance(loras, list) else None
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
if payload is None:
return None
@@ -2481,11 +2478,20 @@ class RecipeManagementHandler:
target_dir: str | None,
user_edits: dict[str, Any],
old_title: str,
persisted_source_path: str,
) -> web.Response:
"""Re-import a recipe from a local image file.
Reads the original source file, re-parses its EXIF metadata, saves a
fresh recipe, then deletes the old one.
Reads the original source file, re-parses its original embedded
generation metadata (the appended recipe metadata block is ignored so
the current parser gets a fresh pass), saves a new recipe, then deletes
the old one.
``persisted_source_path`` is the source_path recorded on the new
recipe: the external source file when one exists, or empty when the
re-import fell back to the recipe's own previous preview image (that
file is deleted with the old recipe, so recording it would leave a
dangling path that blocks future re-imports).
"""
normalized = os.path.normpath(file_path)
if not os.path.isfile(normalized):
@@ -2501,6 +2507,7 @@ class RecipeManagementHandler:
analysis_result = await self._analysis_service.analyze_local_image(
file_path=normalized,
recipe_scanner=recipe_scanner,
ignore_recipe_metadata=True,
)
analysis_payload: dict[str, Any] = analysis_result.payload
@@ -2513,7 +2520,7 @@ class RecipeManagementHandler:
"base_model": base_model,
"loras": loras,
"gen_params": gen_params,
"source_path": normalized,
"source_path": persisted_source_path,
}
if checkpoint:
metadata["checkpoint"] = checkpoint
@@ -2545,6 +2552,10 @@ class RecipeManagementHandler:
metadata=metadata,
extension=extension,
target_dir=target_dir,
# The source is the recipe's own already-optimized preview image;
# store its bytes verbatim instead of re-compressing (which would
# only degrade quality) and skip the metadata re-append.
skip_optimize=True,
)
await self._persistence_service.delete_recipe(
@@ -2572,7 +2583,7 @@ class RecipeManagementHandler:
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": normalized,
"source_path": persisted_source_path,
}
)
@@ -3113,6 +3124,11 @@ class RecipeWorkflowHandler:
class BatchImportHandler:
"""Handle batch import operations for recipes."""
# Virtual path token for the Windows drive list. Kept as a class
# attribute for backwards compatibility; the canonical definition lives
# in py/utils/directory_browser.py.
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
def __init__(
self,
*,
@@ -3284,126 +3300,8 @@ class BatchImportHandler:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
if not is_allowed:
return web.json_response(
{"success": False, "error": "Access denied to this directory"},
status=403,
)
if not path.exists():
return web.json_response(
{"success": False, "error": "Directory does not exist"},
status=404,
)
if not path.is_dir():
return web.json_response(
{"success": False, "error": "Path is not a directory"},
status=400,
)
# List directory contents
directories = []
image_files = []
image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in image_extensions:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
# Sort directories and files alphabetically
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
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"},
+18 -1
View File
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_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("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
@@ -99,16 +101,31 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"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(
"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"
),
RouteDefinition(
"POST", "/api/lm/download-model-source", "download_model_source"
),
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
RouteDefinition(
"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
RouteDefinition(
"GET", "/api/lm/agent/skills", "get_agent_skills"
+6 -3
View File
@@ -39,8 +39,9 @@ from .handlers.misc_handlers import (
build_service_registry_adapter,
)
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.download_routing_handlers import DownloadRoutingHandler
from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__)
@@ -138,8 +139,9 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet()
hf_handler = HfHandler()
model_source_handler = ModelSourceHandler()
agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler()
return self._handler_set_factory(
health=health,
@@ -159,8 +161,9 @@ class MiscRoutes:
doctor=doctor,
example_workflows=example_workflows,
base_model=base_model,
hf_handler=hf_handler,
model_source_handler=model_source_handler,
agent_handler=agent_handler,
download_routing=download_routing,
)
+11
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}/move_model", "move_model"),
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("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition(
"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}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
@@ -68,6 +77,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
),
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(
"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)
+5 -5
View File
@@ -84,11 +84,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
),
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-bulk", "rematch_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
@@ -115,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"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(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
),
+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,
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
# 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]:
+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 json
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import aiohttp
import os
from ...config import config
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 .post_processor import PostProcessor
from .skill_registry import SkillRegistry
@@ -255,6 +260,11 @@ class AgentService:
llm = await self._ensure_llm()
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:
model_filename = os.path.basename(model_path)
logger.info(
@@ -267,24 +277,50 @@ class AgentService:
from ...metadata_ops import read_metadata
metadata = await read_metadata(model_path)
# Fast-fail: enrich_hf_metadata requires hf_url to have HF README context
if skill_name == "enrich_hf_metadata" and not metadata.get("hf_url", ""):
logger.info(
"[%s] SKIP %s — no hf_url in metadata",
skill_name, model_filename,
)
skipped_count += 1
skip_model = True
# Fast-fail: enrich_hf_metadata needs an external model source
# that exposes an accessible model card.
if skill_name == "enrich_hf_metadata":
skip_reason = self._enrichment_skip_reason(metadata)
if skip_reason:
logger.info(
"[%s] SKIP %s%s",
skill_name, model_filename, skip_reason,
)
skipped_count += 1
skip_model = True
if not skip_model:
prompt_vars: Dict[str, Any] = {"model_path": model_path}
if skill.llm_required and llm_configured:
prompt_vars = await self._build_prompt_context(
skill_name, model_path, metadata, registry, llm,
# The site's own data is deterministic and must land whether
# or not an LLM is available: a user without a key still gets
# the author summary, the example images and the tags.
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
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)
rendered = _render_prompt(prompt_template, prompt_vars)
llm_response = await llm.chat_completion_json(
@@ -307,7 +343,9 @@ class AgentService:
model_path=model_path,
llm_output=llm_response or {},
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):
@@ -358,6 +396,28 @@ class AgentService:
# 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
def _format_base_models(models: List[str]) -> str:
"""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)
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(
self,
skill_name: str,
@@ -375,19 +511,45 @@ class AgentService:
metadata: Dict[str, Any],
registry: SkillRegistry,
llm: Any,
*,
source_vars: Optional[Dict[str, Any]] = None,
source_context: Optional[ModelCardContext] = None,
) -> Dict[str, Any]:
"""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
``{{variable}}`` placeholders in ``prompt.md``.
"""
from ...metadata_ops import identify_model_type, list_base_models
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] = {
"model_path": model_path,
"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": "",
"repo": "",
"readme_content": "",
@@ -407,26 +569,33 @@ class AgentService:
"base_model": metadata.get("base_model", ""),
"tags": metadata.get("tags", []),
"modelDescription": metadata.get("modelDescription", ""),
"trainedWords": metadata.get("trainedWords", []),
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
"size": metadata.get("size", 0),
}
hf_url = metadata.get("hf_url", "")
context["hf_url"] = hf_url
repo = self._extract_repo_from_url(hf_url) if hf_url else ""
context["repo"] = repo or ""
if repo:
readme = await self._fetch_readme(repo)
# 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 ""
context["readme_content"] = cleaned if cleaned else "(README not available)"
context["readme_content_full"] = readme or ""
ref = resolve_source_ref(metadata)
if ref is not None:
context["source_url"] = ref.url
context["source_id"] = ref.source_id
context["source_platform"] = ref.platform
context["source_label"] = source_label(ref.platform, ref.platform)
if ref.platform == "huggingface":
context["hf_url"] = ref.url
context["repo"] = ref.source_id
source = get_source(ref.platform) if ref is not None else None
if ref is not None and source is not None and source.supports_enrichment:
# Values fetched once by _load_source_card and shared with the
# post-processor, so the network is not hit twice per model.
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:
raw_models = await list_base_models()
@@ -459,20 +628,14 @@ class AgentService:
@staticmethod
async def _fetch_readme(repo: str) -> str:
"""Fetch README.md from HuggingFace (tries ``main``, then ``master``)."""
async with aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
) as session:
for branch in ("main", "master"):
url = f"https://huggingface.co/{repo}/raw/{branch}/README.md"
try:
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 ""
"""Fetch a Hugging Face README (tries ``main``, then ``master``).
Kept for backward compatibility; new code should go through the
model-source registry so every supported site works.
"""
from ..model_sources import HuggingFaceSource
return await HuggingFaceSource().fetch_model_card(repo)
async def _emit_progress(
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"]
+316 -75
View File
@@ -10,12 +10,16 @@ refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
from __future__ import annotations
import html
import json
import logging
import os
import re
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__)
@@ -42,6 +46,9 @@ class PostProcessor:
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]:
"""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
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),
``preview_downloaded`` (bool), and ``errors`` (list).
"""
if skill_name == "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 {
"success": False,
@@ -72,12 +93,16 @@ class PostProcessor:
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]:
from ...metadata_ops import (
apply_metadata_updates,
download_preview,
refresh_cache,
)
from ..model_sources import get_source, has_external_source, resolve_source_ref
from .skills.enrich_hf_metadata.readme_processor import (
convert_readme_to_html,
extract_gallery_images,
@@ -85,24 +110,49 @@ class PostProcessor:
extract_relevant_section,
extract_simple_markdown_images,
extract_html_img_tags,
extract_repo_from_hf_url,
)
updated_fields: List[str] = []
preview_downloaded = False
# -- Determine whether this is an HF-sourced model -----------------
is_hf_model = not metadata.get("from_civitai", True)
# -- Determine whether this is an externally-sourced model ---------
# 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 -----------------------------------------------
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()
if not new_base:
new_base = (resolved_base_model or "").strip()
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
# 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
new_triggers = llm_output.get("trigger_words", [])
trigger_words_empty = True
@@ -110,45 +160,71 @@ class PostProcessor:
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")]
trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {}
current_triggers = current_civitai.get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_hf_model):
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
current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_source_model):
self._merge_civitai(updates, metadata, trainedWords=cleaned)
# modelDescription — from raw README content (converted to HTML)
if readme_content and is_hf_model:
converted = convert_readme_to_html(readme_content)
if converted:
updates["modelDescription"] = converted
# modelDescription — the author's own summary (when the site keeps one
# outside the README, e.g. ModelScope's ``Description``) followed by the
# README converted to HTML.
site_description = (
(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()
if short_desc and is_hf_model:
current_civitai = metadata.get("civitai") or {}
desc_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
desc_civitai.update(updates["civitai"])
desc_civitai["description"] = short_desc
updates["civitai"] = desc_civitai
if not short_desc:
short_desc = site_description
if short_desc and is_source_model:
self._merge_civitai(updates, metadata, description=short_desc)
# The version label completes the card the way a CivitAI download does:
# 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)
# 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]] = []
if readme_content and is_hf_model:
hf_url = metadata.get("hf_url", "") or ""
repo = extract_repo_from_hf_url(hf_url)
if repo:
rec_w = llm_output.get("recommended_width") or 0
rec_h = llm_output.get("recommended_height") or 0
if (readme_content or site_images) and is_source_model:
repo = source_id
readme_images: List[Dict[str, Any]] = []
if readme_content and repo:
# 1. Widget images (YAML frontmatter)
gallery = extract_gallery_images(
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
@@ -156,7 +232,8 @@ class PostProcessor:
table_images = extract_gallery_table_images(
readme_content, repo,
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"))
@@ -164,7 +241,8 @@ class PostProcessor:
simple_images = extract_simple_markdown_images(
readme_content, repo,
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"))
@@ -172,54 +250,71 @@ class PostProcessor:
html_images = extract_html_img_tags(
readme_content, repo,
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
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
readme_images = gallery + table_images + simple_images + html_images
# 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", [])
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 []
merged = self._merge_tags(existing_tags, new_tags)
if len(merged) > len(existing_tags) or is_hf_model:
merged = self._merge_tags(existing_tags, candidate_tags)
if len(merged) > len(existing_tags) or is_source_model:
updates["tags"] = merged
# metadata_source & llm_enriched_at (always set)
updates["metadata_source"] = "agent:enrich_hf_metadata"
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# metadata_source is recorded for provenance; llm_enriched_at only means
# something when a provider actually answered, so the deterministic
# 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()
if raw_confidence:
updates["_llm_confidence"] = raw_confidence
updates["llm_confidence"] = raw_confidence
# Fallback: extract instance_prompt from YAML frontmatter when the LLM
# returned empty trigger words but the README has instance_prompt.
# Fallback: use the trigger words the site records for this exact file,
# then the README's YAML `instance_prompt`, when the LLM returned none.
if trigger_words_empty:
instance_prompt = _extract_yaml_instance_prompt(readme_content)
if instance_prompt:
current_civitai = metadata.get("civitai") or {}
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = [instance_prompt]
updates["civitai"] = trig_civitai
site_triggers = (
list(source_context.trigger_words) if source_context else []
)
if not site_triggers:
instance_prompt = _extract_yaml_instance_prompt(readme_content)
if instance_prompt:
site_triggers = [instance_prompt]
if site_triggers:
self._merge_civitai(updates, metadata, trainedWords=site_triggers)
preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned
# README, find the first gallery image from the *model-specific
# section* of the README (not the repo-wide first image, which
# 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]
relevant_section = extract_relevant_section(
readme_content, model_basename,
@@ -245,8 +340,12 @@ class PostProcessor:
if 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()
if not raw_tips or raw_tips == "{}":
raw_tips = _extract_usage_tips(site_description)
if raw_tips and raw_tips != "{}":
try:
json.loads(raw_tips)
@@ -276,16 +375,35 @@ class PostProcessor:
# ------------------------------------------------------------------
@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 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",
)
@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 is_hf_model or not current_list
return is_source_model or not current_list
@staticmethod
def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
@@ -309,6 +427,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:
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
@@ -1,20 +1,23 @@
---
name: enrich_hf_metadata
title: "Enrich Metadata from HuggingFace"
title: "Enrich Metadata from Model Card"
description: >
Parse the HuggingFace model card via LLM to extract description, trigger
words, base model, tags, and preview image URL.
Parse the model card (README) from HuggingFace, ModelScope, or any other
supported model site via LLM to extract description, trigger words, base
model, tags, and preview image URL.
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
- **Repository**: {{hf_url}}
- **Source site**: {{source_label}} ({{source_platform}})
- **Model page**: {{source_url}}
- **Model file path**: {{model_path}}
- **Model filename**: {{model_basename}}
- **Repository ID**: {{repo}}
- **Repository ID**: {{source_id}}
- **Repository raw-file base URL**: {{asset_base_url}}
## 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}}
```
## 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
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}}
## HuggingFace README Content
## Model Card Content
```
{{readme_content}}
@@ -52,10 +83,11 @@ Extract the following information from the README content above:
### 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.
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
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
- 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)
@@ -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.
### 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
3-8 relevant tags for categorizing this model. **Quality over quantity.**
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 subject, style, character, or concept the model represents
- 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.
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.
@@ -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)
- In collection repos: the sample images listed **under the section** for this specific model version
- 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
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
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
{
@@ -121,7 +156,7 @@ Your confidence level in the extracted data:
## 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).
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
multiple formats (YAML widget, markdown, HTML ``<img>``, gallery tables),
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
@@ -15,12 +22,25 @@ from typing import Any, List, Tuple
_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(
markdown_text: str,
repo: str,
existing_urls: set[str] | None = None,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]:
"""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
:func:`extract_gallery_images`.
"""
if not markdown_text or not repo:
if not markdown_text or not (repo or base_url):
return []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = []
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,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]:
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
``<img>`` tags exclusively for their sample images, with no markdown
``![]()`` equivalents. This function finds those tags and constructs
resolvable HF URLs.
resolvable URLs.
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 []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = []
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:
"""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 ""
@@ -175,21 +196,23 @@ def extract_gallery_images(
repo: str,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> 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:
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_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
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
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 []
frontmatter = _extract_frontmatter(markdown_text)
@@ -197,7 +220,7 @@ def extract_gallery_images(
return []
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
h = default_height or 512
@@ -279,10 +302,11 @@ def extract_gallery_table_images(
existing_urls: set[str] | None = None,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]:
"""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
prompts. This function parses those tables and merges results with
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*)
are skipped.
"""
if not markdown_text or not repo:
if not markdown_text or not (repo or base_url):
return []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
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:
"""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:
return ""
text = markdown_text
text = _strip_frontmatter(text)
text = _strip_generated_card_boilerplate(text)
text = _strip_gallery(text)
text = _strip_badge_images(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.
_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:
"""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)
* ``<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)
* Standalone ``![...](...)`` image lines and ``<img>`` tags
* 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.
text = _strip_gallery(text)
text = _strip_widget_section(text)
text = _strip_generated_card_boilerplate(text)
text = _strip_fenced_code_blocks(text)
text = _strip_standalone_images(text)
text = _strip_training_tables(text)
+81 -11
View File
@@ -161,6 +161,11 @@ class Aria2Downloader:
(typically an expired CivitAI signed URL): a fresh URL is resolved
and the partial download continues. Recovery is bounded by
``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()
@@ -251,7 +256,11 @@ class Aria2Downloader:
await asyncio.sleep(self._poll_interval)
finally:
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)
async def _get_status_with_retry(
@@ -339,21 +348,43 @@ class Aria2Downloader:
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:
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:
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
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
async def _register_transfer(
@@ -372,7 +403,46 @@ class Aria2Downloader:
headers=headers,
)
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
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
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
+49 -15
View File
@@ -7,7 +7,7 @@ import logging
import os
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.metadata_manager import MetadataManager
from ..utils.usage_stats import UsageStats
@@ -21,6 +21,7 @@ from .model_query import (
resolve_sub_type,
)
from .settings_manager import get_settings_manager
from .model_sources import source_group_key
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__)
@@ -742,29 +743,32 @@ class BaseModelService(ABC):
@staticmethod
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
hf_url = item.get("hf_url") if isinstance(item, dict) else None
if not hf_url or not isinstance(hf_url, str):
return None
m = re.match(
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip()
)
if not m:
return None
return f"hf:{m.group(1)}"
key = BaseModelService._extract_source_group_key(item)
return key if key and key.startswith("hf:") else None
@staticmethod
def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Return the external-source group key for *item*, or None.
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
platforms use their own short prefix (``ms:`` / ``ta:``).
"""
return source_group_key(item)
@staticmethod
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:
1. CivitAI ``modelId`` (int)
2. HF repo identity ``hf:{owner}/{repo}`` (str)
2. External model source identity, e.g. ``hf:{owner}/{repo}``,
``ms:{owner}/{repo}``, ``ta:{model_id}`` (str)
3. ``None`` (no known grouping source)
"""
mid = BaseModelService._extract_model_id(item)
if mid is not None:
return mid
return BaseModelService._extract_hf_group_key(item)
return BaseModelService._extract_source_group_key(item)
@staticmethod
def _extract_model_id(item: Dict[str, Any]) -> Optional[int]:
@@ -904,6 +908,11 @@ class BaseModelService(ABC):
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
):
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
@@ -1295,6 +1304,27 @@ class BaseModelService(ABC):
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(
self,
search_term: str,
@@ -1404,9 +1434,13 @@ class BaseModelService(ABC):
):
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(
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
+5 -3
View File
@@ -410,6 +410,10 @@ class CheckpointScanner(ModelScanner):
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):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path)
@@ -419,9 +423,7 @@ class CheckpointScanner(ModelScanner):
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."""
sub_type = self._resolve_sub_type(
self._find_root_for_file(entry.get("file_path"))
)
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
if sub_type:
entry["sub_type"] = sub_type
return entry
+2
View File
@@ -67,6 +67,8 @@ class CheckpointService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
+52 -1
View File
@@ -21,7 +21,7 @@ from .model_metadata_provider import (
from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash
logger = logging.getLogger(__name__)
@@ -180,6 +180,11 @@ class CivitaiClient:
async def get_model_by_hash(
self, model_hash: 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:
success, version = await self._make_request(
"GET",
@@ -500,9 +505,55 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by id {version_id}")
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]]:
if not model_hash:
return None
if is_empty_placeholder_hash(model_hash):
return None
success, version = await self._make_request(
"GET",
+266 -27
View File
@@ -17,27 +17,35 @@ from dataclasses import dataclass, field
import uuid
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.models import (
LoraMetadata,
CheckpointMetadata,
EmbeddingMetadata,
OtherModelMetadata,
)
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
)
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256, calculate_autov3
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.metadata_manager import MetadataManager
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 .metadata_service import get_default_metadata_provider, get_metadata_provider
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
# Download to temporary file first
import tempfile
@@ -227,12 +235,21 @@ class DownloadManager:
return False
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":
return await self._get_checkpoint_scanner()
if model_type == "embedding":
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
def _resolve_target_file(
@@ -929,6 +946,42 @@ class DownloadManager:
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(
self,
*,
@@ -941,6 +994,8 @@ class DownloadManager:
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "embedding":
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)
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
@@ -1401,6 +1456,7 @@ class DownloadManager:
lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Check lora scanner first
if await lora_scanner.check_model_version_exists(model_version_id):
@@ -1425,6 +1481,13 @@ class DownloadManager:
"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'
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
if source == "civarchive":
@@ -1463,6 +1526,20 @@ class DownloadManager:
model_type = "lora"
elif model_type_from_info == "textualinversion":
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:
return {
"success": False,
@@ -1584,27 +1661,13 @@ class DownloadManager:
}
# Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False
if model_type == "checkpoint":
# Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
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"
)
# (shared with the download routing endpoint so the UI location
# step and the actual download agree on the target roots).
is_diffusion_model = is_diffusion_model_download(
model_type,
file_types=(f.get("type", "") for f in version_info.get("files", [])),
base_model=base_model_value,
)
# Existence check after the metadata fetch (#1058):
# - An explicit file selection only blocks when THIS file is
@@ -1663,6 +1726,13 @@ class DownloadManager:
"success": False,
"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
if use_default_paths:
@@ -1702,6 +1772,60 @@ class DownloadManager:
"error": "Default embedding root path not set in settings",
}
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
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -1858,6 +1982,24 @@ class DownloadManager:
if not download_urls:
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
file_name = file_info.get("name", "")
if not file_name:
@@ -1880,6 +2022,11 @@ class DownloadManager:
version_info, file_info, save_path
)
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:
return {
"success": False,
@@ -2092,6 +2239,8 @@ class DownloadManager:
scanner = await self._get_checkpoint_scanner()
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
@@ -2588,6 +2737,9 @@ class DownloadManager:
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
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 = (
getattr(scanner, "adjust_cached_entry", None)
@@ -2595,6 +2747,7 @@ class DownloadManager:
else None
)
downloaded_metadata: List[Dict[str, Any]] = []
for index, entry in enumerate(metadata_entries):
file_path_for_adjust = getattr(
entry, "file_path", actual_file_paths[index]
@@ -2637,6 +2790,15 @@ class DownloadManager:
if scanner is not None:
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:
await self._aria2_state_store.remove(download_id)
@@ -2676,8 +2838,85 @@ class DownloadManager:
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]:
if model_type == "checkpoint":
if model_type in ("checkpoint", "other"):
return {
".ckpt",
".pt",
+103
View File
@@ -0,0 +1,103 @@
"""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 (
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"})
def is_diffusion_model_download(
model_type: str,
file_types: Iterable[str] = (),
base_model: str = "",
) -> 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.
"""
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
return False
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
+2
View File
@@ -67,6 +67,8 @@ class EmbeddingService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
+150 -83
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, Dict, List, Optional
import aiohttp
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
# ``{provider_id: {model_id: max_output_tokens}}``.
_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)
# 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]]:
"""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
in memory after the first successful fetch.
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:
return _catalog_cache
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
async with session.get(_MODEL_CATALOG_URL) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
return _catalog_cache or {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
return _catalog_cache or {}
async with _catalog_lock:
# Re-check under the lock: another caller may have fetched (or
# failed) while we were waiting.
if _catalog_cache is not None:
return _catalog_cache
if (
_catalog_last_failure is not None
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
):
logger.debug(
"Skipping model catalog fetch: last attempt failed %.0fs ago",
time.monotonic() - _catalog_last_failure,
)
return {}
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
return _catalog_cache or {}
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
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]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
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
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
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
_model_output_limits = output_limits
logger.debug(
"Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result),
sum(len(m) for m in result.values()),
len(output_limits),
)
return result
_catalog_cache = result
_model_output_limits = output_limits
logger.debug(
"Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result),
sum(len(m) for m in result.values()),
len(output_limits),
)
return result
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"
try:
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:
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
return []
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)
return []
@@ -224,6 +267,16 @@ _PROVIDER_DEFAULTS: Dict[str, str] = {
# Request timeout for LLM calls (seconds)
_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:
"""Centralized LLM API client.
@@ -571,47 +624,61 @@ class LLMService:
if effective_max is None:
effective_max = 4096
# Use json_schema (not json_object) for broader provider compatibility:
# LM Studio and some other OpenAI-compatible servers reject
# json_object but accept json_schema. {"type": "object"} is
# functionally equivalent — it accepts any JSON object without
# constraining specific fields.
response_format = {
# Structured-output format. ``json_schema`` is preferred because LM
# Studio and other local OpenAI-compatible servers reject
# ``json_object`` but accept ``json_schema``; ``{"type": "object"}``
# accepts any JSON object without constraining specific fields, so the
# two modes are functionally equivalent here. Providers known to
# reject json_schema (see _JSON_OBJECT_ONLY_PROVIDERS) get
# ``json_object`` instead.
schema_format: Dict[str, Any] = {
"type": "json_schema",
"json_schema": {
"name": "metadata",
"schema": {"type": "object"},
},
}
json_object_format: Dict[str, Any] = {"type": "json_object"}
try:
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=response_format,
max_tokens=effective_max,
)
except LLMResponseError as e:
# Only fall back when the provider rejects the response_format
# type value (e.g. "'response_format.type' must be..."). Avoid
# catching unrelated 400 errors whose body happens to mention
# "response_format" (e.g. "model does not support
# response_format restrictions on this endpoint").
if "'response_format.type'" not in str(e).lower():
raise
logger.info(
"Provider rejected response_format, retrying without it. "
"Falling back to prompt-only JSON mode. Error: %s",
e,
)
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=None,
max_tokens=effective_max,
)
if self._get_config()["provider"] in _JSON_OBJECT_ONLY_PROVIDERS:
format_chain: List[Optional[Dict[str, Any]]] = [
json_object_format,
None,
]
else:
format_chain = [schema_format, json_object_format, None]
result: Optional[Dict[str, Any]] = None
for index, fmt in enumerate(format_chain):
try:
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=fmt,
max_tokens=effective_max,
)
break
except LLMResponseError as e:
message = str(e).lower()
if index + 1 >= len(format_chain):
raise
# Only downgrade when the failure is about ``response_format``.
# Everything else (auth, unknown model, rate limits) must
# surface unchanged. Matching on the bare parameter name also
# 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 ""
if not content:
+13 -5
View File
@@ -79,6 +79,8 @@ class LoraService(BaseModelService):
),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
@@ -712,12 +714,18 @@ class LoraService(BaseModelService):
),
)
# Return minimal data needed for cycling
return [
{
# Return minimal data needed for cycling. usage_tips is only included
# 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"],
"model_name": lora.get("model_name", lora["file_name"]),
"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
+30 -5
View File
@@ -14,10 +14,33 @@ from ..utils.model_utils import determine_base_model
from ..utils.models import autov3_from_civitai_files
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
from .errors import RateLimitError
from .model_sources import has_external_source
logger = logging.getLogger(__name__)
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):
"""Subset of metadata provider interface consumed by the sync service."""
@@ -114,9 +137,10 @@ class MetadataSyncService:
)
if "trainedWords" in existing_civitai:
existing_trained = existing_civitai.get("trainedWords", [])
new_trained = civitai_metadata.get("trainedWords", [])
merged_trained = list(set(existing_trained + new_trained))
existing_trained = existing_civitai.get("trainedWords", []) or []
new_trained = civitai_metadata.get("trainedWords", []) or []
# Order preserving merge: the saved order drives prompt order.
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
merged_civitai["trainedWords"] = merged_trained
local_metadata["civitai"] = merged_civitai
@@ -222,9 +246,10 @@ class MetadataSyncService:
error_msg = "CivitAI model is deleted and no archive provider is available"
return False, error_msg
else:
is_hf_source = bool(model_data.get("hf_url"))
is_hf_source = has_external_source(model_data)
if is_hf_source:
# HF-sourced model: only check CivitAI API directly.
# External-source model (Hugging Face / ModelScope /
# TensorArt): only check 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
+5
View File
@@ -33,6 +33,11 @@ class ModelCache:
raw_data: List[Dict[str, Any]]
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)
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
+370 -5
View File
@@ -2,13 +2,15 @@ import asyncio
import fnmatch
import os
import logging
import shutil
from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod
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 ..services.settings_manager import get_settings_manager
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__)
@@ -41,10 +43,22 @@ class AutoOrganizeResult:
def to_dict(self) -> Dict[str, Any]:
"""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] = {
'success': self.status != 'error',
'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': {
'total': self.total,
'success': self.success_count,
@@ -473,17 +487,368 @@ class ModelFileService:
class ModelMoveService:
"""Service for handling individual model moves"""
def __init__(self, scanner, model_type: str):
"""Initialize the service
Args:
scanner: Model scanner instance
model_type: Type of model (e.g., 'lora', 'checkpoint')
"""
self.scanner = scanner
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 ""
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``/``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):
return {"success": False, "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:
return {
"success": False,
"code": "not_empty",
"error": (
f"Folder still contains {manifest['model_count']} model "
"file(s); delete or move them first"
),
"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)
await self._forget_folder(relative_folder)
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.
"""
model_count = 0
file_count = 0
dir_count = 0
symlink_count = 0
total_bytes = 0
pending_delete_job = False
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
else:
file_count += 1
try:
total_bytes += os.path.getsize(full_path)
except OSError: # pragma: no cover - defensive
pass
return {
"model_count": 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
),
}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
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)
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):
return {"success": False, "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]:
"""Move a single model file
+9 -1
View File
@@ -1,6 +1,8 @@
from typing import Dict, Optional, Set, List
import os
from ..utils.constants import is_empty_placeholder_hash
class ModelHashIndex:
"""Index for looking up models by hash or filename"""
@@ -81,6 +83,8 @@ class ModelHashIndex:
# mapping. First-time registrations stay O(1).
if autov3:
autov3 = autov3.lower()
if is_empty_placeholder_hash(autov3):
autov3 = None
if is_re_registration and (existing_hash != sha256 or autov3):
stale_autov3_keys = [
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:
"""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
autov3 = autov3.lower()
self._autov3_to_path[autov3] = file_path
@@ -250,6 +254,8 @@ class ModelHashIndex:
def has_hash(self, hash_value: str) -> bool:
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
if is_empty_placeholder_hash(hash_value):
return False
normalized = hash_value.lower()
if normalized in self._hash_to_path:
return True
@@ -261,6 +267,8 @@ class ModelHashIndex:
def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
if is_empty_placeholder_hash(hash_value):
return None
normalized = hash_value.lower()
path = self._hash_to_path.get(normalized)
if path is not None:
+24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
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(
target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]:
@@ -404,6 +425,9 @@ class ModelLifecycleService:
if metadata and new_metadata_path:
metadata["file_name"] = new_file_name
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"):
old_preview = str(metadata["preview_url"])
+57
View File
@@ -169,6 +169,17 @@ class ModelMetadataProvider(ABC):
"""Published model count for the user; None when unsupported."""
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
class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata"""
@@ -203,6 +214,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]:
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)
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive API for metadata"""
@@ -700,6 +716,37 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
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):
return zip(self.providers, self._provider_labels)
@@ -791,6 +838,16 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]:
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,
)
class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers"""
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():
"""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.checkpoint_service import CheckpointService
from ..services.embedding_service import EmbeddingService
from ..services.other_model_service import OtherModelService
from ..routes.lora_routes import LoraRoutes
from ..routes.checkpoint_routes import CheckpointRoutes
from ..routes.embedding_routes import EmbeddingRoutes
from ..routes.other_routes import OtherRoutes
# Register LoRA model type
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
# Register Checkpoint model type
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
# 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)
+86
View File
@@ -0,0 +1,86 @@
"""External model-source providers (Hugging Face, ModelScope, TensorArt).
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 .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",
"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",
]
+446
View File
@@ -0,0 +1,446 @@
"""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, 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",
}
@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."""
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,
)
)
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) -> 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:
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT},
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
) -> 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:
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT},
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 = ""
#: 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 asset_base_url(self, source_id: str, revision: str = "") -> str:
"""Base URL used to resolve repository-relative asset paths."""
return ""
def group_key(self, source_id: str) -> str:
"""Return the version-group key for *source_id*."""
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
return f"{prefix}:{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 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
)
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",
]
+106
View File
@@ -0,0 +1,106 @@
"""Hugging Face model source."""
from __future__ import annotations
import logging
import re
from .base import (
ModelSource,
ModelSourceError,
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]+)/?$"
)
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 asset_base_url(self, source_id: str, revision: str = "") -> str:
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
for branch in ("main", "master"):
text = await fetch_text(
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
)
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}"
)
if status == 404:
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
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",
]
+644
View File
@@ -0,0 +1,644 @@
"""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, Optional
from .base import (
ModelCardContext,
ModelSource,
ModelSourceError,
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 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)
return context
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
+228
View File
@@ -0,0 +1,228 @@
"""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 .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(),
)
_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``.
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
"""
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.source_id)
__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",
]
+56
View File
@@ -0,0 +1,56 @@
"""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
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"]
+81
View File
@@ -0,0 +1,81 @@
import os
import logging
from typing import Any, Dict, Optional
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import OtherModelMetadata
from ..config import config
logger = logging.getLogger(__name__)
class OtherModelService(BaseModelService):
"""Other-model-specific service implementation (VAE, upscaler, text encoder, ...)"""
def __init__(self, scanner, update_service=None):
"""Initialize Other-model service
Args:
scanner: Other-model scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("other", scanner, OtherModelMetadata, update_service=update_service)
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Format other-model data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = model_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted other-model entry (missing file_path): %s",
model_data.get("file_name", "<unknown>"),
)
return None
# Get sub_type from cache entry (new canonical field)
sub_type = model_data.get("sub_type", "vae")
file_name = model_data.get("file_name") or ""
model_name = model_data.get("model_name") or file_name
folder = model_data.get("folder") or ""
return {
"model_name": model_name,
"file_name": file_name,
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
"base_model": model_data.get("base_model", ""),
"folder": folder,
"sha256": model_data.get("sha256", ""),
"autov3": model_data.get("autov3"),
"file_path": file_path.replace(os.sep, "/"),
"file_size": model_data.get("size", 0),
"modified": model_data.get("modified", ""),
"tags": model_data.get("tags", []),
"from_civitai": model_data.get("from_civitai", True),
"notes": model_data.get("notes", ""),
"sub_type": sub_type,
"favorite": model_data.get("favorite", False),
"exclude": bool(model_data.get("exclude", False)),
"update_available": bool(model_data.get("update_available", False)),
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
def find_duplicate_hashes(self) -> Dict[str, Any]:
"""Find other models with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict[str, Any]:
"""Find other models with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()
+478
View File
@@ -0,0 +1,478 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import asyncio
import json
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..utils.models import OtherModelMetadata
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
from ..utils.metadata_manager import MetadataManager
from ..config import config
from .model_scanner import ModelScanner, _is_excluded_dir
from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__)
class OtherScanner(ModelScanner):
"""Service for scanning and managing "other" model files.
Aggregates every enabled folder_paths category from
OTHER_MODEL_FOLDER_SUBTYPES (VAE, upscalers, text encoders, CLIP vision,
opt-in ControlNet) into one scanner; sub_type is derived from the root
containing the file (mirrors CheckpointScanner's checkpoints/unet split).
Hashing is lazy (checkpoint-style): text encoders can be ~10 GB, so the
initial scan records hash_status="pending" and the SHA256 is computed
on-demand via calculate_hash_for_model (e.g. when fetching CivitAI
metadata).
"""
def __init__(self):
# Same extension set as CheckpointScanner (ComfyUI's
# supported_pt_extensions plus ".gguf").
file_extensions = {
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".pkl",
".sft",
".gguf",
}
super().__init__(
model_type="other",
model_class=OtherModelMetadata,
file_extensions=file_extensions,
hash_index=ModelHashIndex(),
)
if not hasattr(self, "_hash_calculation_lock"):
self._hash_calculation_lock = asyncio.Lock()
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
async def _create_default_metadata(
self, file_path: str
) -> Optional[OtherModelMetadata]:
"""Create default metadata without calculating hash (lazy hash).
Other models include multi-GB text encoders, so hash calculation is
deferred until on-demand (e.g. CivitAI metadata fetch).
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
# AutoV3 reads only the safetensors header, so it is cheap even for
# large files; record the checked state at creation time ("" =
# checked but unavailable).
autov3 = calculate_autov3(real_path)
# Create metadata WITHOUT calculating hash
metadata = OtherModelMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256="", # Empty hash - will be calculated on-demand
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type=self.resolve_sub_type_for_path(file_path) or "vae",
from_civitai=False, # Mark as local model since no hash yet
hash_status="pending", # Mark hash as pending
autov3=autov3 or "",
)
# Save the created metadata
logger.info(f"Creating other-model metadata (hash pending) for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(
f"Error creating default other-model metadata for {file_path}: {e}"
)
return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
"""Calculate hash for a model on-demand with per-file singleflight.
Args:
file_path: Path to the model file
Returns:
SHA256 hash string, or None if calculation failed
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}")
return None
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if (
metadata is not None
and metadata.hash_status == "completed"
and metadata.sha256
):
# Ensure the in-memory hash index is populated even when
# the hash was already computed and persisted to the metadata
# file. Without this, usage tracking (and any other caller
# that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a
# tight loop that never populates the index.
self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256
async with self._hash_calculation_lock:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if (
metadata is not None
and metadata.hash_status == "completed"
and metadata.sha256
):
self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256
task = self._hash_calculation_tasks.get(real_path)
if task is None:
task = asyncio.create_task(
self._run_hash_calculation_task(file_path, real_path)
)
self._hash_calculation_tasks[real_path] = task
return await asyncio.shield(task)
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
return None
async def _run_hash_calculation_task(
self, file_path: str, real_path: str
) -> Optional[str]:
"""Run a hash calculation task and remove it from the in-flight map."""
try:
return await self._calculate_hash_for_model_uncached(file_path, real_path)
finally:
task = asyncio.current_task()
async with self._hash_calculation_lock:
if self._hash_calculation_tasks.get(real_path) is task:
del self._hash_calculation_tasks[real_path]
async def _calculate_hash_for_model_uncached(
self, file_path: str, real_path: str
) -> Optional[str]:
"""Calculate hash for a model without checking in-flight tasks."""
from ..utils.file_utils import calculate_sha256
try:
# Load current metadata
metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata is None:
if should_skip:
logger.error(f"Invalid metadata found for {file_path}")
return None
created_metadata = await self._create_default_metadata(file_path)
if created_metadata is None:
logger.error(f"No metadata found for {file_path}")
return None
metadata = created_metadata
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256
# Update status to calculating
metadata.hash_status = "calculating"
await MetadataManager.save_metadata(file_path, metadata)
# Calculate hash
logger.info(f"Calculating hash for other model: {file_path}")
sha256 = await calculate_sha256(real_path)
# Update metadata with hash
metadata.sha256 = sha256
metadata.hash_status = "completed"
await MetadataManager.save_metadata(file_path, metadata)
# Update hash index
self._hash_index.add_entry(
sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
# Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls
# write the hash back to the SQLite models table. Without
# this the hash only lives in the metadata file and the
# in-memory hash index, both of which are lost across
# restarts, causing the same re-computation loop on the
# next session.
if self._cache is not None and self._cache.raw_data:
for entry in self._cache.raw_data:
if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed"
self.bump_cache_version()
break
logger.info(f"Hash calculated for other model: {file_path}")
return sha256
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
# Update status to failed
try:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata:
metadata.hash_status = "failed"
await MetadataManager.save_metadata(file_path, metadata)
except Exception:
pass
return None
async def calculate_all_pending_hashes(
self, progress_callback=None
) -> Dict[str, int]:
"""Calculate hashes for all other models with pending hash status.
If cache is not initialized, scans filesystem directly for metadata files
with hash_status != 'completed'.
Args:
progress_callback: Optional callback(progress, total, current_file)
Returns:
Dict with 'completed', 'failed', 'total' counts
"""
# Try to get from cache first
cache = await self.get_cached_data()
if cache and cache.raw_data:
# Use cache if available
pending_models = [
item
for item in cache.raw_data
if item.get("hash_status") != "completed" or not item.get("sha256")
]
else:
# Cache not initialized, scan filesystem directly
pending_models = await self._find_pending_models_from_filesystem()
if not pending_models:
return {"completed": 0, "failed": 0, "total": 0}
total = len(pending_models)
completed = 0
failed = 0
for i, model_data in enumerate(pending_models):
file_path = model_data.get("file_path")
if not file_path:
continue
try:
sha256 = await self.calculate_hash_for_model(file_path)
if sha256:
completed += 1
else:
failed += 1
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
failed += 1
if progress_callback:
try:
await progress_callback(i + 1, total, file_path)
except Exception:
pass
return {"completed": completed, "failed": failed, "total": total}
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for other-model metadata files with pending hash status."""
pending_models = []
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
for dirpath, dirnames, filenames in os.walk(root_path):
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
for filename in filenames:
if not filename.endswith(".metadata.json"):
continue
metadata_path = os.path.join(dirpath, filename)
try:
with open(metadata_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if hash is pending
hash_status = data.get("hash_status", "completed")
sha256 = data.get("sha256", "")
if hash_status != "completed" or not sha256:
# Find corresponding model file
model_name = filename.replace(".metadata.json", "")
model_path = None
# Look for model file with matching name
for ext in self.file_extensions:
potential_path = os.path.join(dirpath, model_name + ext)
if os.path.exists(potential_path):
model_path = potential_path
break
if model_path:
pending_models.append(
{
"file_path": model_path.replace(os.sep, "/"),
"hash_status": hash_status,
"sha256": sha256,
**{
k: v
for k, v in data.items()
if k
not in [
"file_path",
"hash_status",
"sha256",
]
},
}
)
except (json.JSONDecodeError, Exception) as e:
logger.debug(
f"Error reading metadata file {metadata_path}: {e}"
)
continue
return pending_models
def _root_sub_type_map(self) -> Dict[str, str]:
"""Return the configured business root -> sub_type map."""
root_map = getattr(config, "other_root_subtypes", None)
return root_map if isinstance(root_map, dict) else {}
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub_type for a configured root path."""
if not root_path:
return None
normalized_root = self._normalize_path_value(root_path)
for root, sub_type in self._root_sub_type_map().items():
if self._normalize_path_value(root) == normalized_root:
return sub_type
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.
Uses the longest-prefix match so nested roots (e.g. a controlnet root
inside a vae root) resolve to the most specific category.
"""
normalized_path = self._normalize_path_value(file_path)
if not normalized_path:
return None
best_length = 0
best_sub_type: Optional[str] = None
for root, sub_type in self._root_sub_type_map().items():
normalized_root = self._normalize_path_value(root)
if not normalized_root:
continue
if (
normalized_path == normalized_root
or normalized_path.startswith(f"{normalized_root}/")
) and len(normalized_root) > best_length:
best_length = len(normalized_root)
best_sub_type = sub_type
return best_sub_type
def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path) or self.resolve_sub_type_for_path(
file_path
)
if sub_type:
metadata.sub_type = sub_type
return metadata
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.
sub_type is location-derived: it is re-derived on cache load, never
trusted from the persisted snapshot.
"""
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
if sub_type:
entry["sub_type"] = sub_type
return entry
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
"""Drop persisted entries whose folder is no longer a managed root.
sub_type is location-derived and config only maps enabled roots, so a
file under a disabled sub_type - or under any other root while the
feature is off - resolves to None here and is filtered out while the
persisted cache is hydrated.
"""
return self.resolve_sub_type_for_path(entry.get("file_path")) is not None
def get_model_roots(self) -> List[str]:
"""Get other-model root directories"""
roots: List[str] = []
roots.extend(config.other_roots or [])
# Remove duplicates while preserving order
seen: set[str] = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots
+2
View File
@@ -59,6 +59,7 @@ _MODEL_TYPE_PAGE_MAP = {
"lora": "loras",
"checkpoint": "checkpoints",
"embedding": "embeddings",
"other": "other",
}
# Module-level alias so tests can spy on timer task creation without patching
@@ -983,6 +984,7 @@ class PendingDeleteService:
"get_lora_scanner",
"get_checkpoint_scanner",
"get_embedding_scanner",
"get_other_scanner",
):
getter = getattr(ServiceRegistry, getter_name, None)
if not callable(getter):
+298 -228
View File
@@ -6,7 +6,10 @@ import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock
from .model_sources import normalize_metadata_source
logger = logging.getLogger(__name__)
@@ -19,6 +22,9 @@ class PersistedCacheData:
hash_rows: List[Tuple[str, str]]
excluded_models: List[str]
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
# Every directory under the model roots (including empty ones), or None
# when the snapshot predates folder recording.
all_folders: Optional[List[str]] = None
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
@@ -59,6 +65,8 @@ class PersistentModelCache:
"db_checked",
"last_checked_at",
"hash_status",
"source_platform",
"source_url",
"hf_url",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
@@ -128,6 +136,14 @@ class PersistentModelCache:
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
folder_rows = conn.execute(
"SELECT path FROM folders WHERE model_type = ?",
(model_type,),
).fetchall()
folders_recorded = conn.execute(
"SELECT value FROM cache_meta WHERE key = ?",
(f"folders_recorded:{model_type}",),
).fetchone()
finally:
conn.close()
except Exception as exc:
@@ -195,8 +211,13 @@ class PersistentModelCache:
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed",
"source_platform": row["source_platform"] or "",
"source_url": row["source_url"] or "",
"hf_url": row["hf_url"] or "",
}
# Legacy rows only carry `hf_url`; derive the canonical pair so
# every consumer sees the same shape.
normalize_metadata_source(item)
if row["autov3"] is not None:
item["autov3"] = (row["autov3"] or "").lower()
raw_data.append(item)
@@ -216,14 +237,20 @@ class PersistentModelCache:
]
excluded_paths = [row["file_path"] for row in excluded]
all_folders: Optional[List[str]] = None
if folders_recorded is not None:
all_folders = sorted(
(row["path"] for row in folder_rows), key=lambda x: x.lower()
)
return PersistedCacheData(
raw_data=raw_data,
hash_rows=hash_pairs,
excluded_models=excluded_paths,
autov3_hash_rows=autov3_pairs,
all_folders=all_folders,
)
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
if not self.is_enabled():
return
if not self._schema_initialized:
@@ -232,246 +259,271 @@ class PersistentModelCache:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Cross-process serialization: another LoRA Manager instance may
# share this settings directory, and the read-merge-write below
# spans several statements.
with exclusive_lock(self._db_path):
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
model_map: Dict[str, Tuple[Any, ...]] = {
row[1]: row for row in model_rows if row[1] # row[1] is file_path
}
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
model_map: Dict[str, Tuple[Any, ...]] = {
row[1]: row for row in model_rows if row[1] # row[1] is file_path
}
existing_models = conn.execute(
"SELECT "
+ ", ".join(self._MODEL_COLUMNS[1:])
+ " FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_model_map: Dict[str, sqlite3.Row] = {
row["file_path"]: row for row in existing_models
}
to_remove_models = [
(model_type, path)
for path in existing_model_map.keys()
if path not in model_map
]
if to_remove_models:
conn.executemany(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
insert_rows: List[Tuple[Any, ...]] = []
update_rows: List[Tuple[Any, ...]] = []
for file_path, row in model_map.items():
existing = existing_model_map.get(file_path)
if existing is None:
insert_rows.append(row)
continue
existing_values = tuple(
existing[column] for column in self._MODEL_COLUMNS[1:]
)
current_values = row[1:]
if existing_values != current_values:
update_rows.append(row[2:] + (model_type, file_path))
if insert_rows:
conn.executemany(self._insert_model_sql(), insert_rows)
if update_rows:
set_clause = ", ".join(
f"{column} = ?"
for column in self._MODEL_UPDATE_COLUMNS
)
update_sql = (
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
)
conn.executemany(update_sql, update_rows)
existing_tags_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
(model_type,),
).fetchall()
existing_tags: Dict[str, set[str]] = {}
for row in existing_tags_rows:
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
new_tags: Dict[str, set[str]] = {}
for item in raw_data:
file_path = item.get("file_path")
if not file_path:
continue
tags = set(item.get("tags") or [])
if tags:
new_tags[file_path] = tags
tag_inserts: List[Tuple[str, str, str]] = []
tag_deletes: List[Tuple[str, str, str]] = []
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
for path in all_tag_paths:
existing_set = existing_tags.get(path, set())
new_set = new_tags.get(path, set())
to_add = new_set - existing_set
to_remove = existing_set - new_set
for tag in to_add:
tag_inserts.append((model_type, path, tag))
for tag in to_remove:
tag_deletes.append((model_type, path, tag))
if tag_deletes:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
tag_deletes,
)
if tag_inserts:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
tag_inserts,
)
existing_hash_rows = conn.execute(
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_hash_map: Dict[str, set[str]] = {}
for row in existing_hash_rows:
sha_value = (row["sha256"] or "").lower()
if not sha_value:
continue
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
new_hash_map: Dict[str, set[str]] = {}
for sha_value, paths in hash_index.items():
normalized_sha = (sha_value or "").lower()
if not normalized_sha:
continue
bucket = new_hash_map.setdefault(normalized_sha, set())
for path in paths:
if path:
bucket.add(path)
hash_inserts: List[Tuple[str, str, str]] = []
hash_deletes: List[Tuple[str, str, str]] = []
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
for sha_value in all_shas:
existing_paths = existing_hash_map.get(sha_value, set())
new_paths = new_hash_map.get(sha_value, set())
for path in existing_paths - new_paths:
hash_deletes.append((model_type, sha_value, path))
for path in new_paths - existing_paths:
hash_inserts.append((model_type, sha_value, path))
if hash_deletes:
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
hash_deletes,
)
if hash_inserts:
conn.executemany(
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
hash_inserts,
)
if autov3_hash_index is not None:
existing_autov3_rows = conn.execute(
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
existing_models = conn.execute(
"SELECT "
+ ", ".join(self._MODEL_COLUMNS[1:])
+ " FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_autov3_map: Dict[str, set[str]] = {}
for row in existing_autov3_rows:
autov3_value = (row["autov3"] or "").lower()
if not autov3_value:
continue
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
existing_model_map: Dict[str, sqlite3.Row] = {
row["file_path"]: row for row in existing_models
}
new_autov3_map: Dict[str, set[str]] = {}
for autov3_value, paths in autov3_hash_index.items():
normalized_autov3 = (autov3_value or "").lower()
if not normalized_autov3:
to_remove_models = [
(model_type, path)
for path in existing_model_map.keys()
if path not in model_map
]
if to_remove_models:
conn.executemany(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
insert_rows: List[Tuple[Any, ...]] = []
update_rows: List[Tuple[Any, ...]] = []
for file_path, row in model_map.items():
existing = existing_model_map.get(file_path)
if existing is None:
insert_rows.append(row)
continue
bucket = new_autov3_map.setdefault(normalized_autov3, set())
existing_values = tuple(
existing[column] for column in self._MODEL_COLUMNS[1:]
)
current_values = row[1:]
if existing_values != current_values:
update_rows.append(row[2:] + (model_type, file_path))
if insert_rows:
conn.executemany(self._insert_model_sql(), insert_rows)
if update_rows:
set_clause = ", ".join(
f"{column} = ?"
for column in self._MODEL_UPDATE_COLUMNS
)
update_sql = (
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
)
conn.executemany(update_sql, update_rows)
existing_tags_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
(model_type,),
).fetchall()
existing_tags: Dict[str, set[str]] = {}
for row in existing_tags_rows:
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
new_tags: Dict[str, set[str]] = {}
for item in raw_data:
file_path = item.get("file_path")
if not file_path:
continue
tags = set(item.get("tags") or [])
if tags:
new_tags[file_path] = tags
tag_inserts: List[Tuple[str, str, str]] = []
tag_deletes: List[Tuple[str, str, str]] = []
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
for path in all_tag_paths:
existing_set = existing_tags.get(path, set())
new_set = new_tags.get(path, set())
to_add = new_set - existing_set
to_remove = existing_set - new_set
for tag in to_add:
tag_inserts.append((model_type, path, tag))
for tag in to_remove:
tag_deletes.append((model_type, path, tag))
if tag_deletes:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
tag_deletes,
)
if tag_inserts:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
tag_inserts,
)
existing_hash_rows = conn.execute(
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_hash_map: Dict[str, set[str]] = {}
for row in existing_hash_rows:
sha_value = (row["sha256"] or "").lower()
if not sha_value:
continue
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
new_hash_map: Dict[str, set[str]] = {}
for sha_value, paths in hash_index.items():
normalized_sha = (sha_value or "").lower()
if not normalized_sha:
continue
bucket = new_hash_map.setdefault(normalized_sha, set())
for path in paths:
if path:
bucket.add(path)
autov3_inserts: List[Tuple[str, str, str]] = []
autov3_deletes: List[Tuple[str, str, str]] = []
hash_inserts: List[Tuple[str, str, str]] = []
hash_deletes: List[Tuple[str, str, str]] = []
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
for autov3_value in all_autov3:
existing_paths = existing_autov3_map.get(autov3_value, set())
new_paths = new_autov3_map.get(autov3_value, set())
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
for sha_value in all_shas:
existing_paths = existing_hash_map.get(sha_value, set())
new_paths = new_hash_map.get(sha_value, set())
for path in existing_paths - new_paths:
autov3_deletes.append((model_type, autov3_value, path))
hash_deletes.append((model_type, sha_value, path))
for path in new_paths - existing_paths:
autov3_inserts.append((model_type, autov3_value, path))
hash_inserts.append((model_type, sha_value, path))
if autov3_deletes:
if hash_deletes:
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
autov3_deletes,
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
hash_deletes,
)
if autov3_inserts:
if hash_inserts:
conn.executemany(
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
autov3_inserts,
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
hash_inserts,
)
existing_excluded_rows = conn.execute(
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
new_excluded = {path for path in excluded_models if path}
if autov3_hash_index is not None:
existing_autov3_rows = conn.execute(
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_autov3_map: Dict[str, set[str]] = {}
for row in existing_autov3_rows:
autov3_value = (row["autov3"] or "").lower()
if not autov3_value:
continue
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
excluded_deletes = [
(model_type, path)
for path in existing_excluded - new_excluded
]
excluded_inserts = [
(model_type, path)
for path in new_excluded - existing_excluded
]
new_autov3_map: Dict[str, set[str]] = {}
for autov3_value, paths in autov3_hash_index.items():
normalized_autov3 = (autov3_value or "").lower()
if not normalized_autov3:
continue
bucket = new_autov3_map.setdefault(normalized_autov3, set())
for path in paths:
if path:
bucket.add(path)
if excluded_deletes:
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
excluded_deletes,
)
if excluded_inserts:
conn.executemany(
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
excluded_inserts,
)
autov3_inserts: List[Tuple[str, str, str]] = []
autov3_deletes: List[Tuple[str, str, str]] = []
conn.commit()
finally:
conn.close()
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
for autov3_value in all_autov3:
existing_paths = existing_autov3_map.get(autov3_value, set())
new_paths = new_autov3_map.get(autov3_value, set())
for path in existing_paths - new_paths:
autov3_deletes.append((model_type, autov3_value, path))
for path in new_paths - existing_paths:
autov3_inserts.append((model_type, autov3_value, path))
if autov3_deletes:
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
autov3_deletes,
)
if autov3_inserts:
conn.executemany(
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
autov3_inserts,
)
existing_excluded_rows = conn.execute(
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
new_excluded = {path for path in excluded_models if path}
excluded_deletes = [
(model_type, path)
for path in existing_excluded - new_excluded
]
excluded_inserts = [
(model_type, path)
for path in new_excluded - existing_excluded
]
if excluded_deletes:
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
excluded_deletes,
)
if excluded_inserts:
conn.executemany(
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
excluded_inserts,
)
if all_folders is not None:
conn.execute(
"DELETE FROM folders WHERE model_type = ?",
(model_type,),
)
folder_inserts = [
(model_type, path) for path in all_folders if path
]
if folder_inserts:
conn.executemany(
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
folder_inserts,
)
# Mark the snapshot as having folder data even when the
# library has no subfolders, so an empty list is not
# mistaken for "never recorded" on load.
conn.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(f"folders_recorded:{model_type}", "1"),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
@@ -524,6 +576,8 @@ class PersistentModelCache:
db_checked INTEGER,
last_checked_at REAL,
hash_status TEXT,
source_platform TEXT DEFAULT '',
source_url TEXT DEFAULT '',
hf_url TEXT DEFAULT '',
PRIMARY KEY (model_type, file_path)
);
@@ -554,6 +608,17 @@ class PersistentModelCache:
file_path TEXT NOT NULL,
PRIMARY KEY (model_type, file_path)
);
CREATE TABLE IF NOT EXISTS folders (
model_type TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (model_type, path)
);
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
)
self._ensure_additional_model_columns(conn)
@@ -580,6 +645,8 @@ class PersistentModelCache:
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'",
"source_platform": "TEXT DEFAULT ''",
"source_url": "TEXT DEFAULT ''",
"hf_url": "TEXT DEFAULT ''",
"autov3": "TEXT",
}
@@ -589,18 +656,19 @@ class PersistentModelCache:
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
detect_types=sqlite3.PARSE_DECLTYPES,
row_factory=sqlite3.Row,
)
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
# which caller populated the item.
normalize_metadata_source(item)
civitai = item.get("civitai") or {}
trained_words = civitai.get("trainedWords")
if isinstance(trained_words, str):
@@ -664,6 +732,8 @@ class PersistentModelCache:
1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"),
item.get("source_platform") or "",
item.get("source_url") or "",
item.get("hf_url") or "",
)
+79 -50
View File
@@ -19,7 +19,9 @@ import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock
logger = logging.getLogger(__name__)
@@ -52,7 +54,6 @@ class PersistentRecipeCache:
"file_mtime",
"file_size",
"favorite",
"repair_version",
"preview_nsfw_level",
"loras_json",
"checkpoint_json",
@@ -171,65 +172,98 @@ class PersistentRecipeCache:
recipes: List[Dict[str, Any]],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
skip_if_empty: bool = False,
) -> bool:
"""Save all recipes to SQLite cache.
Args:
recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id recipe_id mapping.
skip_if_empty: When True, refuse to replace a non-empty cache with an
empty one. This is the storage-level backstop against a scan that
silently loses every recipe (unavailable drive / mis-resolved
recipes directory): overwriting both deletes the user's data and
destroys their only record of it. Intentional full clears (manual
rebuild) must pass ``skip_if_empty=False``.
Returns:
``True`` when the write happened, ``False`` when it was skipped.
"""
if not self.is_enabled():
return
return False
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return
return False
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Cross-process serialization: another LoRA Manager instance may
# share this settings directory, and a full-table replace is a
# read-modify-write that SQLite alone cannot make atomic.
with exclusive_lock(self._db_path):
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Clear existing data
conn.execute("DELETE FROM recipes")
if skip_if_empty and not recipes:
existing = conn.execute(
"SELECT COUNT(*) FROM recipes"
).fetchone()
if existing and existing[0]:
conn.rollback()
logger.warning(
"Refusing to persist an empty recipe cache: the "
"stored cache still holds %d recipe(s). The scan "
"found nothing, which usually means the recipes "
"path was unavailable or resolved elsewhere; "
"keeping the stored cache so the data stays "
"recoverable.",
existing[0],
)
return False
# Prepare and insert all rows
recipe_rows = []
for recipe in recipes:
recipe_id = str(recipe.get("id", ""))
if not recipe_id:
continue
# Clear existing data
conn.execute("DELETE FROM recipes")
json_path = ""
if json_paths:
json_path = json_paths.get(recipe_id, "")
# Prepare and insert all rows
recipe_rows = []
for recipe in recipes:
recipe_id = str(recipe.get("id", ""))
if not recipe_id:
continue
row = self._prepare_recipe_row(recipe, json_path)
recipe_rows.append(row)
json_path = ""
if json_paths:
json_path = json_paths.get(recipe_id, "")
if recipe_rows:
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
columns = ", ".join(self._RECIPE_COLUMNS)
conn.executemany(
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
recipe_rows,
row = self._prepare_recipe_row(recipe, json_path)
recipe_rows.append(row)
if recipe_rows:
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
columns = ", ".join(self._RECIPE_COLUMNS)
conn.executemany(
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
recipe_rows,
)
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally:
conn.close()
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
return True
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to persist recipe cache: %s", exc)
return False
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
"""Return stored file stats for all cached recipes.
@@ -442,7 +476,6 @@ class PersistentRecipeCache:
file_mtime REAL,
file_size INTEGER,
favorite INTEGER DEFAULT 0,
repair_version INTEGER DEFAULT 0,
preview_nsfw_level INTEGER DEFAULT 0,
loras_json TEXT,
checkpoint_json TEXT,
@@ -488,16 +521,14 @@ class PersistentRecipeCache:
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
detect_types=sqlite3.PARSE_DECLTYPES,
row_factory=sqlite3.Row,
)
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
"""Convert a recipe dict to a row tuple for SQLite insertion."""
@@ -541,7 +572,6 @@ class PersistentRecipeCache:
file_mtime,
file_size,
1 if recipe.get("favorite") else 0,
int(recipe.get("repair_version") or 0),
int(recipe.get("preview_nsfw_level") or 0),
loras_json,
checkpoint_json,
@@ -599,7 +629,6 @@ class PersistentRecipeCache:
"created_date": row["created_date"] or 0.0,
"modified": row["modified"] or 0.0,
"favorite": bool(row["favorite"]),
"repair_version": row["repair_version"] or 0,
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
"has_workflow": bool(row["has_workflow"]),
"loras": loras,
+8 -10
View File
@@ -16,6 +16,7 @@ import threading
import time
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
logger = logging.getLogger(__name__)
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection."""
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
row_factory=sqlite3.Row,
)
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
"""Remove a recipe entry. Caller must hold the lock."""
File diff suppressed because it is too large Load Diff
+17
View File
@@ -368,6 +368,7 @@ class RecipeAnalysisService:
*,
file_path: str | None,
recipe_scanner,
ignore_recipe_metadata: bool = False,
) -> AnalysisResult:
"""Analyze a file already present on disk."""
@@ -389,6 +390,22 @@ class RecipeAnalysisService:
}
return result
if ignore_recipe_metadata:
# Re-import: re-parse the original embedded generation metadata
# instead of the recipe JSON block LoRA Manager appended on save.
from ...recipes.parsers.recipe_format import strip_recipe_metadata
metadata = strip_recipe_metadata(metadata)
if not metadata:
result = self._metadata_not_found_response(normalized_path)
result.payload["diagnostics"] = {
"channel": "local",
"exif_present": True,
"ignore_recipe_metadata": True,
"reason": "only_recipe_metadata",
}
return result
result = await self._parse_metadata(
metadata,
recipe_scanner=recipe_scanner,
+16 -3
View File
@@ -58,6 +58,7 @@ class RecipePersistenceService:
extension: str | None = None,
recipe_id: str | None = None,
target_dir: str | None = None,
skip_optimize: bool = False,
) -> PersistenceResult:
"""Persist a user uploaded recipe.
@@ -67,6 +68,11 @@ class RecipePersistenceService:
target_dir: If provided, save recipe files to this directory instead
of the default recipes_dir. Used by re-import to preserve the
original folder location.
skip_optimize: If True, store the image bytes verbatim without
resizing/re-encoding (recipe metadata is still embedded via a
byte-level EXIF update that leaves the pixels untouched). Used
by local re-import, where the source is the recipe's own
already-optimized preview image.
"""
missing_fields = []
@@ -87,9 +93,12 @@ class RecipePersistenceService:
recipe_id = recipe_id or str(uuid.uuid4())
# Handle video formats by bypassing optimization and metadata embedding
# Handle video formats by bypassing optimization and metadata embedding.
# Local re-import also bypasses optimization: the source is the
# recipe's own already-optimized preview image, so re-compressing it
# would only degrade quality.
is_video = extension in [".mp4", ".webm"]
if is_video:
if is_video or skip_optimize:
optimized_image = resolved_image_bytes
# extension is already set
else:
@@ -175,7 +184,11 @@ class RecipePersistenceService:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
if not is_video:
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
self._exif_utils.append_recipe_metadata(
normalized_image_path,
recipe_data,
pixel_preserving=skip_optimize,
)
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
await recipe_scanner.add_recipe(recipe_data)
+26 -5
View File
@@ -297,23 +297,44 @@ class ServiceRegistry:
async def get_embedding_scanner(cls):
"""Get or create Embedding scanner instance"""
service_name = "embedding_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .embedding_scanner import EmbeddingScanner
scanner = await EmbeddingScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
async def get_other_scanner(cls):
"""Get or create Other-model scanner instance"""
service_name = "other_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .other_scanner import OtherScanner
scanner = await OtherScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
def clear_services(cls):
"""Clear all registered services - mainly for testing"""
+270 -22
View File
@@ -19,19 +19,26 @@ from typing import (
Mapping,
Optional,
Sequence,
Set,
Tuple,
)
from platformdirs import user_config_dir
from ..utils.constants import (
DEFAULT_DOWNLOAD_PATH_TEMPLATES,
DEFAULT_ENABLED_OTHER_SUB_TYPES,
DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
OTHER_SUB_TYPE_FOLDER_KEYS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_OTHER_SUB_TYPES,
normalize_other_sub_types,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import (
APP_NAME,
_portable_env_override,
ensure_settings_file,
get_legacy_settings_path,
get_settings_dir_override,
@@ -83,9 +90,15 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"default_checkpoint_root": "",
"default_unet_root": "",
"default_embedding_root": "",
"default_other_roots": {},
# Other Models management is opt-in: nothing is scanned, shown or offered
# for download until the user turns the feature on.
"enable_other_models": False,
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
"recipes_path": "",
"base_model_path_mappings": {},
"download_path_templates": {},
"download_filename_templates": {},
"folder_paths": {},
"extra_folder_paths": {},
"example_images_path": "",
@@ -116,6 +129,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
"sticky_controls": False,
# AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "",
@@ -161,13 +175,23 @@ class SettingsManager:
self._check_environment_variables()
self._collect_configuration_warnings()
if (
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
and not is_settings_dir_pinned()
):
portable_override = _portable_env_override()
if portable_override is True and not is_settings_dir_pinned():
if not self.settings.get("use_portable_settings"):
self.settings["use_portable_settings"] = True
self._save_settings()
elif portable_override is False and self.settings.get(
"use_portable_settings"
):
# Explicit opt-out from a persisted portable mode: clear the flag so
# later runs go back to the shared settings directory instead of
# requiring a manual edit of settings.json.
logger.info(
"Clearing the persisted portable-mode flag because %s=0",
"LORA_MANAGER_PORTABLE",
)
self.settings["use_portable_settings"] = False
self._save_settings()
if self._needs_initial_save:
self._save_settings()
@@ -286,6 +310,29 @@ class SettingsManager:
return payload == template
def get_template_folder_path_placeholders(self) -> Set[str]:
"""Placeholder folder_paths values shipped in settings.json.example.
A fresh standalone install is seeded from the template, so its
documentation-only placeholder paths end up in the live settings
file. The Model Paths settings UI hides them; the first real save
overwrites them via ``set("folder_paths")``.
"""
template = self._read_template_payload()
if not template:
return set()
folder_paths = template.get("folder_paths")
if not isinstance(folder_paths, Mapping):
return set()
placeholders: Set[str] = set()
for value in folder_paths.values():
paths = value if isinstance(value, list) else [value]
placeholders.update(p for p in paths if isinstance(p, str) and p)
return placeholders
def _merge_template_with_defaults(
self, defaults: Dict[str, Any], template: Mapping[str, Any]
) -> Dict[str, Any]:
@@ -308,6 +355,7 @@ class SettingsManager:
default_checkpoint_root=merged.get("default_checkpoint_root"),
default_unet_root=merged.get("default_unet_root"),
default_embedding_root=merged.get("default_embedding_root"),
default_other_roots=merged.get("default_other_roots"),
recipes_path=merged.get("recipes_path"),
)
}
@@ -442,6 +490,7 @@ class SettingsManager:
),
default_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""),
default_other_roots=self.settings.get("default_other_roots"),
recipes_path=self.settings.get("recipes_path", ""),
)
libraries = {library_name: library_payload}
@@ -493,6 +542,7 @@ class SettingsManager:
default_checkpoint_root=data.get("default_checkpoint_root"),
default_unet_root=data.get("default_unet_root"),
default_embedding_root=data.get("default_embedding_root"),
default_other_roots=data.get("default_other_roots"),
recipes_path=data.get("recipes_path"),
metadata=data.get("metadata"),
base=data,
@@ -540,6 +590,9 @@ class SettingsManager:
self.settings["default_embedding_root"] = active_library.get(
"default_embedding_root", ""
)
self.settings["default_other_roots"] = self._normalize_default_other_roots(
active_library.get("default_other_roots", {})
)
self.settings["recipes_path"] = active_library.get("recipes_path", "")
if save:
@@ -557,6 +610,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
base: Optional[Mapping[str, Any]] = None,
@@ -596,6 +650,15 @@ class SettingsManager:
else:
payload.setdefault("default_embedding_root", "")
if default_other_roots is not None:
payload["default_other_roots"] = self._normalize_default_other_roots(
default_other_roots
)
else:
payload["default_other_roots"] = self._normalize_default_other_roots(
payload.get("default_other_roots", {})
)
if recipes_path is not None:
payload["recipes_path"] = recipes_path
else:
@@ -631,6 +694,71 @@ class SettingsManager:
normalized[key] = cleaned
return normalized
def _normalize_default_other_roots(
self, value: Any, *, strict: bool = False
) -> Dict[str, str]:
"""Normalize a ``default_other_roots`` mapping ({sub_type: root path}).
Unknown sub_type keys and non-string/empty paths are dropped; with
``strict=True`` unknown sub_type keys raise instead (used by ``set()``
so typos in API payloads surface as errors).
"""
if not isinstance(value, Mapping):
if strict and value is not None:
raise ValueError("default_other_roots must be a mapping")
return {}
normalized: Dict[str, str] = {}
for sub_type, path in value.items():
if sub_type not in VALID_OTHER_SUB_TYPES:
if strict:
raise ValueError(
f"Unknown other-model sub-type '{sub_type}'; "
f"expected one of {sorted(VALID_OTHER_SUB_TYPES)}"
)
continue
if not isinstance(path, str):
continue
stripped = path.strip()
if stripped:
normalized[sub_type] = stripped
return normalized
def is_other_models_enabled(self) -> bool:
"""Return True when the opt-in Other Models management is enabled."""
return bool(self.settings.get("enable_other_models", False))
def get_enabled_other_sub_types(self) -> List[str]:
"""Return the enabled other-model sub_types (empty when the feature is off)."""
if not self.is_other_models_enabled():
return []
return normalize_other_sub_types(self.settings.get("enabled_other_sub_types"))
def is_other_sub_type_enabled(self, sub_type: Optional[str]) -> bool:
"""Return True when ``sub_type`` is currently managed."""
if not sub_type:
return False
return sub_type in self.get_enabled_other_sub_types()
def _apply_other_model_settings_change(self) -> None:
"""Rebuild other-model roots and refresh the other scanner after a toggle."""
try:
from ..config import config # Local import to avoid circular dependency
config.refresh_other_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other-model roots: %s", exc)
try:
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
scanner = ServiceRegistry.get_service_sync("other_scanner")
if scanner is not None and hasattr(scanner, "on_library_changed"):
# reconcile=True lets the scanner pick up newly enabled roots and
# purge rows for folders that are no longer managed.
scanner.on_library_changed(reconcile=True)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
def _has_configured_paths(self, folder_paths: Any) -> bool:
if not isinstance(folder_paths, Mapping):
return False
@@ -743,6 +871,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
) -> bool:
libraries = self.settings.get("libraries", {})
@@ -793,6 +922,14 @@ class SettingsManager:
library["default_embedding_root"] = default_embedding_root
changed = True
if default_other_roots is not None:
normalized_other_roots = self._normalize_default_other_roots(
default_other_roots
)
if library.get("default_other_roots") != normalized_other_roots:
library["default_other_roots"] = normalized_other_roots
changed = True
if recipes_path is not None and library.get("recipes_path") != recipes_path:
library["recipes_path"] = recipes_path
changed = True
@@ -893,12 +1030,53 @@ class SettingsManager:
updated = _check_and_auto_set("unet", "default_unet_root") or updated
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
# Other-model default roots: one entry per enabled sub_type; candidates
# are the union of that sub_type's folder_paths keys (text_encoder
# merges the legacy 'clip' key with 'text_encoders'). When the opt-in
# feature is off the existing mapping is left untouched.
other_roots = self._normalize_default_other_roots(
self.settings.get("default_other_roots")
)
if self.is_other_models_enabled():
for sub_type in self.get_enabled_other_sub_types():
candidates: List[str] = []
candidate_identities: set[str] = set()
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
for candidate in self._get_valid_root_candidates(folder_key):
identity = _normalize_root_identity(candidate)
if identity in candidate_identities:
continue
candidate_identities.add(identity)
candidates.append(candidate)
if not candidates:
continue
current = other_roots.get(sub_type, "")
if current and _normalize_root_identity(current) in candidate_identities:
continue
other_roots[sub_type] = candidates[0]
if current:
logger.info(
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
sub_type,
current,
candidates[0],
)
else:
logger.info(
"Auto-set default_other_roots[%s] to '%s'",
sub_type,
candidates[0],
)
updated = True
if updated:
self.settings["default_other_roots"] = other_roots
self._update_active_library_entry(
default_lora_root=self.settings.get("default_lora_root"),
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
default_unet_root=self.settings.get("default_unet_root"),
default_embedding_root=self.settings.get("default_embedding_root"),
default_other_roots=other_roots,
)
if self._bootstrap_reason == "missing":
self._needs_initial_save = True
@@ -1066,19 +1244,27 @@ class SettingsManager:
if self._bootstrap_reason == "missing":
message = (
"LoRA Manager created a default settings.json because no configuration was found. "
"Edit settings.json to add your model directories so library scanning can run."
"Open Settings → Model Paths to add your model directories so library scanning can run."
)
else:
message = (
"LoRA Manager could not locate any configured model directories. "
"Edit settings.json to add your model folders so library scanning can run."
"Open Settings → Model Paths to add your model folders so library scanning can run."
)
self._add_startup_message(
code="missing-model-paths",
title="Model folders need setup",
message=message,
severity="warning",
actions=self._default_settings_actions(),
actions=[
{
"action": "open-model-paths-settings",
"label": "Configure model folders",
"type": "primary",
"icon": "fas fa-cog",
},
*self._default_settings_actions(),
],
dismissible=False,
)
@@ -1091,6 +1277,7 @@ class SettingsManager:
defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults["base_model_path_mappings"] = {}
defaults["download_path_templates"] = {}
defaults["download_filename_templates"] = {}
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault("folder_paths", {})
defaults.setdefault("extra_folder_paths", {})
@@ -1598,6 +1785,12 @@ class SettingsManager:
value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
elif key == "default_other_roots":
value = self._normalize_default_other_roots(value, strict=True)
elif key == "enabled_other_sub_types":
value = normalize_other_sub_types(value)
elif key == "enable_other_models":
value = bool(value)
elif key == "recipes_path":
current_recipes_dir = self._get_effective_recipes_dir()
value = self._normalize_recipes_path_value(value)
@@ -1625,6 +1818,8 @@ class SettingsManager:
self._update_active_library_entry(default_unet_root=str(value))
elif key == "default_embedding_root":
self._update_active_library_entry(default_embedding_root=str(value))
elif key == "default_other_roots":
self._update_active_library_entry(default_other_roots=value)
elif key == "recipes_path":
self._update_active_library_entry(recipes_path=str(value))
elif key == "model_name_display":
@@ -1632,6 +1827,8 @@ class SettingsManager:
self._save_settings()
if key == "recipes_path":
self._notify_library_change(self.get_active_library_name())
if key in ("enable_other_models", "enabled_other_sub_types"):
self._apply_other_model_settings_change()
if portable_switch_pending:
self._finalize_portable_switch()
@@ -1795,6 +1992,7 @@ class SettingsManager:
"lora_scanner",
"checkpoint_scanner",
"embedding_scanner",
"other_scanner",
"recipe_scanner",
):
service = ServiceRegistry.get_service_sync(service_name)
@@ -1959,6 +2157,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
@@ -2003,6 +2202,11 @@ class SettingsManager:
if default_embedding_root is not None
else existing.get("default_embedding_root")
),
default_other_roots=(
default_other_roots
if default_other_roots is not None
else existing.get("default_other_roots")
),
recipes_path=(
recipes_path
if recipes_path is not None
@@ -2035,6 +2239,7 @@ class SettingsManager:
default_checkpoint_root: str = "",
default_unet_root: str = "",
default_embedding_root: str = "",
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: str = "",
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
@@ -2053,6 +2258,7 @@ class SettingsManager:
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
default_other_roots=default_other_roots,
recipes_path=recipes_path,
metadata=metadata,
activate=activate,
@@ -2113,6 +2319,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
) -> None:
"""Update folder paths for the active library."""
@@ -2126,6 +2333,7 @@ class SettingsManager:
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
default_other_roots=default_other_roots,
recipes_path=recipes_path,
activate=True,
)
@@ -2150,6 +2358,7 @@ class SettingsManager:
"lora_scanner",
"checkpoint_scanner",
"embedding_scanner",
"other_scanner",
"recipe_scanner",
"model_update_service",
):
@@ -2172,10 +2381,14 @@ class SettingsManager:
"""Get download path template for specific model type
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding')
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}'
Template string for the model type. Falls back to the per-type
default in ``DEFAULT_DOWNLOAD_PATH_TEMPLATES``; unknown model types
resolve to an empty string (flat layout) rather than silently
nesting downloads under an unconfigured subfolder.
"""
templates = self.settings.get("download_path_templates", {})
@@ -2199,27 +2412,62 @@ class SettingsManager:
logger.warning(
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
)
default_template = "{base_model}/{first_tag}"
templates = {
"lora": default_template,
"checkpoint": default_template,
"embedding": default_template,
}
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
self.settings["download_path_templates"] = templates
self._save_settings()
# Ensure templates is a dictionary
if not isinstance(templates, dict):
default_template = "{base_model}/{first_tag}"
templates = {
"lora": default_template,
"checkpoint": default_template,
"embedding": default_template,
}
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
self.settings["download_path_templates"] = templates
self._save_settings()
return templates.get(model_type, "{base_model}/{first_tag}")
return templates.get(
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
)
def get_download_filename_template(self, model_type: str) -> str:
"""Get the download filename template for a specific model type.
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
Returns:
Template string for the model type. Empty string (the default for
every model type) means downloaded files keep their original
filename.
"""
templates = self.settings.get("download_filename_templates", {})
# Handle edge case where templates might be stored as JSON string
if isinstance(templates, str):
try:
parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict):
self.settings["download_filename_templates"] = parsed_templates
self._save_settings()
templates = parsed_templates
logger.info(
"Successfully parsed download_filename_templates from JSON string"
)
else:
raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e:
logger.warning(
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
)
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
if not isinstance(templates, dict):
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
template = templates.get(model_type, "")
return template if isinstance(template, str) else ""
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
+8 -10
View File
@@ -20,6 +20,7 @@ import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
logger = logging.getLogger(__name__)
@@ -677,16 +678,13 @@ class TagFTSIndex:
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection."""
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
row_factory=sqlite3.Row,
)
def _build_fts_query(self, query: str) -> str:
"""Build an FTS5 query string with prefix matching.
+2
View File
@@ -20,6 +20,7 @@ from .example_images import (
ImportExampleImagesUseCase,
ImportExampleImagesValidationError,
)
from .filename_template_use_case import FilenameTemplateUseCase
__all__ = [
"AutoOrganizeInProgressError",
@@ -34,4 +35,5 @@ __all__ = [
"DownloadExampleImagesUseCase",
"ImportExampleImagesUseCase",
"ImportExampleImagesValidationError",
"FilenameTemplateUseCase",
]
@@ -7,6 +7,7 @@ import time
from typing import Any, Dict, List, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService
from ..model_sources import has_external_source
from ...utils.metadata_manager import MetadataManager
@@ -51,10 +52,11 @@ class BulkMetadataRefreshUseCase:
if not model.get("skip_metadata_refresh", False)
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
and (not model.get("civitai") or not model["civitai"].get("id"))
# Skip models downloaded from Hugging Face — they are not on
# CivitAI / CivArchive. Users can still refresh them individually
# via the right-click context menu.
and not model.get("hf_url", "")
# Skip models linked to an external model site (Hugging Face /
# ModelScope / TensorArt) — they are not on CivitAI / CivArchive.
# Users can still refresh them individually via the right-click
# context menu.
and not has_external_source(model)
and not (
# Skip models confirmed not on CivitAI when no need to retry
model.get("from_civitai") is False
@@ -0,0 +1,245 @@
"""Filename template use case: bulk-rename library models per the configured template.
An empty template reverts previously renamed models to the original filename
recorded in their ``.metadata.json`` sidecar (``original_file_name``).
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ...utils.utils import calculate_filename_for_model
from ..model_file_service import AutoOrganizeResult, ProgressCallback
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
from ..settings_manager import get_settings_manager
from .auto_organize_use_case import (
AutoOrganizeInProgressError,
AutoOrganizeLockProvider,
)
logger = logging.getLogger(__name__)
_PROGRESS_TYPE = "filename_template_progress"
class FilenameTemplateUseCase:
"""Apply the download filename template to existing library models.
An empty template restores the recorded original filename instead of
rendering a template. Shares the auto-organize lock (and its in-progress
error) so a bulk rename never runs concurrently with an auto-organize
operation.
"""
def __init__(
self,
*,
scanner,
lifecycle_service: ModelLifecycleService,
lock_provider: AutoOrganizeLockProvider,
model_type: str,
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]] = load_local_metadata,
) -> None:
self._scanner = scanner
self._lifecycle_service = lifecycle_service
self._lock_provider = lock_provider
self._model_type = model_type
self._metadata_loader = metadata_loader
async def execute(
self,
*,
file_paths: Optional[Sequence[str]] = None,
progress_callback: Optional[ProgressCallback] = None,
) -> AutoOrganizeResult:
"""Run the bulk rename guarded by the shared library-operation lock."""
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
if callable(is_running) and is_running():
raise AutoOrganizeInProgressError(
"A filename template operation is already running"
)
if self._lock_provider.is_auto_organize_running():
raise AutoOrganizeInProgressError("Auto-organize is already running")
lock = await self._lock_provider.get_auto_organize_lock()
if lock.locked():
raise AutoOrganizeInProgressError(
"Another library operation is already running"
)
async with lock:
return await self._run(
file_paths=file_paths, progress_callback=progress_callback
)
async def _run(
self,
*,
file_paths: Optional[Sequence[str]],
progress_callback: Optional[ProgressCallback],
) -> AutoOrganizeResult:
result = AutoOrganizeResult()
result.operation_type = "filename_template"
self._scanner.reset_cancellation()
try:
template = get_settings_manager().get_download_filename_template(
self._model_type
)
cache = await self._scanner.get_cached_data()
models = list(cache.raw_data)
if file_paths:
wanted = set(file_paths)
models = [
model for model in models if model.get("file_path") in wanted
]
result.total = len(models)
await self._emit_progress(progress_callback, result, "started")
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self._scanner.is_cancelled():
logger.info(
"Filename template apply cancelled for %s", self._model_type
)
break
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
if self._scanner.is_cancelled():
break
await self._process_model(model, template, result)
result.processed += 1
await self._emit_progress(progress_callback, result, "processing")
# Yield between batches so the server stays responsive.
await asyncio.sleep(0.1)
if self._scanner.is_cancelled():
result.status = "cancelled"
await self._emit_progress(progress_callback, result, "cancelled")
return result
await self._emit_progress(progress_callback, result, "completed")
return result
except Exception as exc:
logger.error("Error in filename template apply: %s", exc, exc_info=True)
if progress_callback:
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": "error",
"error": str(exc),
"operation_type": result.operation_type,
}
)
raise
async def _process_model(
self,
model: Dict[str, Any],
template: str,
result: AutoOrganizeResult,
) -> None:
model_name = model.get("model_name", "Unknown")
try:
file_path = model.get("file_path")
if not file_path:
self._add_result(result, model_name, False, "No file path found")
result.failure_count += 1
return
if not template:
# Empty template = revert to the original filename recorded
# by the first rename; models without a record are skipped.
new_stem = await self._resolve_recorded_original(file_path)
else:
new_stem = calculate_filename_for_model(model, self._model_type)
if not new_stem:
result.skipped_count += 1
return
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):
result.skipped_count += 1
return
await self._lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
result.success_count += 1
except ValueError as exc:
# Conflicts (e.g. target name already exists) count as failures
# without aborting the batch.
self._add_result(result, model_name, False, str(exc))
result.failure_count += 1
except Exception as exc:
logger.error(
"Error applying filename template to %s: %s", model_name, exc,
exc_info=True,
)
self._add_result(result, model_name, False, f"Error: {exc}")
result.failure_count += 1
async def _resolve_recorded_original(self, file_path: str) -> str:
"""Return the original filename stem recorded at the first rename.
Reads the ``.metadata.json`` sidecar; returns an empty string when no
sidecar or no ``original_file_name`` entry exists (models never
renamed, or renamed before the recording shipped).
"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata = await self._metadata_loader(metadata_path)
original = metadata.get("original_file_name")
if not isinstance(original, str):
return ""
return original.strip()
async def _emit_progress(
self,
progress_callback: Optional[ProgressCallback],
result: AutoOrganizeResult,
status: str,
) -> None:
if not progress_callback:
return
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": status,
"total": result.total,
"processed": result.processed,
"success": result.success_count,
"failures": result.failure_count,
"skipped": result.skipped_count,
"operation_type": result.operation_type,
}
)
@staticmethod
def _add_result(
result: AutoOrganizeResult,
model_name: str,
success: bool,
message: str,
) -> None:
"""Add a result entry if under the limit (mirrors ModelFileService)."""
if len(result.results) < 100:
result.results.append(
{"model": model_name, "success": success, "message": message}
)
elif len(result.results) == 100:
result.results_truncated = True
result.sample_results = result.results[:50]
+27 -24
View File
@@ -20,8 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict[str, Any]] = None
# Add recipe repair progress tracking
self._recipe_repair_progress: Optional[Dict[str, Any]] = None
# Add filename template progress tracking
self._filename_template_progress: Optional[Dict[str, Any]] = None
# Add recipe rematch progress tracking
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock()
@@ -172,6 +172,13 @@ class WebSocketManager:
progress_entry['status'] = data['status']
if 'message' in data:
progress_entry['message'] = data['message']
# Post-transfer stage reporting (see `model_source_handlers._report_phase`):
# the byte counter has stopped by then, so the stage is the only thing
# that still says the download is working.
if 'stage' in data:
progress_entry['stage'] = data['stage']
if 'platform' in data:
progress_entry['platform'] = data['platform']
self._download_progress[download_id] = progress_entry
@@ -193,14 +200,6 @@ class WebSocketManager:
# Broadcast via WebSocket
await self.broadcast(data)
async def broadcast_recipe_repair_progress(self, data: Dict[str, Any]):
"""Broadcast recipe repair progress to connected clients"""
# Store progress data in memory
self._recipe_repair_progress = data
# Broadcast via WebSocket
await self.broadcast(data)
def get_auto_organize_progress(self) -> Optional[Dict[str, Any]]:
"""Get current auto-organize progress"""
return self._auto_organize_progress
@@ -208,21 +207,25 @@ class WebSocketManager:
def cleanup_auto_organize_progress(self):
"""Clear auto-organize progress data"""
self._auto_organize_progress = None
def get_recipe_repair_progress(self) -> Optional[Dict[str, Any]]:
"""Get current recipe repair progress"""
return self._recipe_repair_progress
def cleanup_recipe_repair_progress(self):
"""Clear recipe repair progress data if it is in a finished state"""
if self._recipe_repair_progress and self._recipe_repair_progress.get('status') in ['completed', 'cancelled', 'error']:
self._recipe_repair_progress = None
def is_recipe_repair_running(self) -> bool:
"""Check if recipe repair is currently running"""
if not self._recipe_repair_progress:
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
"""Broadcast filename template progress to connected clients"""
self._filename_template_progress = data
await self.broadcast(data)
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
"""Get current filename template progress"""
return self._filename_template_progress
def cleanup_filename_template_progress(self):
"""Clear filename template progress data"""
self._filename_template_progress = None
def is_filename_template_running(self) -> bool:
"""Check if a filename template operation is currently running"""
if not self._filename_template_progress:
return False
status = self._recipe_repair_progress.get('status')
status = self._filename_template_progress.get('status')
return status in ['started', 'processing']
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
await ws_manager.broadcast_auto_organize_progress(progress_data)
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
"""WebSocket progress callback for filename template operations."""
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
"""Send filename template progress via WebSocket."""
await ws_manager.broadcast_filename_template_progress(progress_data)
class WebSocketBroadcastCallback:
"""Generic WebSocket progress callback broadcasting to all clients."""
+81
View File
@@ -0,0 +1,81 @@
"""Shared SQLite connection setup for LoRA Manager cache databases.
Cache databases live under the settings directory (``cache/model/<library>.sqlite``,
``cache/recipe/<library>.sqlite``, ``cache/fts/*.sqlite``). With portable mode or a
pinned ``LORA_MANAGER_SETTINGS_DIR`` off, that directory is shared by every ComfyUI
instance on the machine, so two processes can open the same cache file at once.
SQLite serializes writers, but the default ``timeout`` is 5 seconds: a second
instance that writes while the first is mid-transaction fails with "database is
locked". These settings make concurrent access wait instead of failing, and keep
the write path in WAL so readers are never blocked by a writer.
"""
from __future__ import annotations
import sqlite3
from typing import Any
# How long a connection waits for a competing writer before raising.
CONCURRENT_TIMEOUT_SECONDS = 30.0
# PRAGMAs applied to every cache connection.
#
# ``busy_timeout`` mirrors the connection timeout so a busy database is retried
# inside SQLite rather than surfacing as an immediate error. ``synchronous=NORMAL``
# is the documented companion of WAL: still crash-safe, far fewer fsyncs.
_TUNING_PRAGMAS = (
"PRAGMA busy_timeout = 30000",
"PRAGMA synchronous = NORMAL",
)
def connect_cache_db(
path: str,
*,
readonly: bool = False,
uri: bool = False,
detect_types: int = 0,
row_factory: Any = None,
) -> sqlite3.Connection:
"""Open a cache database with multi-instance-friendly settings.
Args:
path: Database path, or a ``file:`` URI when *uri* is True.
readonly: Open through a read-only URI. Callers still pass the
plain path; the ``mode=ro`` suffix is added here. The
write-oriented tuning pragmas are skipped in that case so a
read-only connection never attempts to change the file.
uri: Treat *path* as a SQLite URI.
detect_types: Forwarded to :func:`sqlite3.connect`.
row_factory: Optional ``row_factory`` for the connection.
Returns:
A configured :class:`sqlite3.Connection`.
"""
if readonly:
if not uri and not path.startswith("file:"):
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(
path,
check_same_thread=False,
uri=uri,
detect_types=detect_types,
timeout=CONCURRENT_TIMEOUT_SECONDS,
)
if row_factory is not None:
conn.row_factory = row_factory
try:
for pragma in _TUNING_PRAGMAS:
# A read-only connection may reject write PRAGMAs; they are not
# needed there anyway.
conn.execute(pragma)
except sqlite3.Error:
# Tuning is best-effort: a connection that cannot set pragmas still
# works, just without the concurrency headroom.
pass
return conn
+160 -5
View File
@@ -1,3 +1,5 @@
from typing import Any, Dict, List
NSFW_LEVELS = {
"PG": 1,
"PG13": 2,
@@ -81,6 +83,126 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
# folder_paths key -> sub_type; single source of truth for extensibility.
# Adding support for a new ComfyUI folder category is a one-line change here.
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",
}
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
# Sub-types managed when the (opt-in) Other Models feature is switched on.
# The feature itself defaults to off (``enable_other_models`` = False), so
# nothing here is scanned until the user enables it.
#
# The default set is deliberately limited 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. ``clip_vision`` and ``controlnet`` are
# workflow-driven instead (IPAdapter/SVD, per-workflow ControlNet variants) and
# ControlNet libraries routinely run to dozens of files, so both stay opt-in
# and are treated symmetrically.
DEFAULT_ENABLED_OTHER_SUB_TYPES: List[str] = [
"vae",
"upscaler",
"text_encoder",
]
def other_sub_type_folder_keys() -> Dict[str, List[str]]:
"""Invert OTHER_MODEL_FOLDER_SUBTYPES into sub_type -> folder_paths keys.
``text_encoder`` maps to two folder keys (``text_encoders`` and the legacy
``clip``), so every consumer that resolves a sub_type back to folders must
merge both.
"""
mapping: Dict[str, List[str]] = {}
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
mapping.setdefault(sub_type, []).append(folder_key)
return mapping
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
# Core folder_paths keys every LoRA Manager installation understands.
CORE_FOLDER_PATH_KEYS: List[str] = ["loras", "checkpoints", "unet", "embeddings"]
def folder_path_schema() -> List[Dict[str, Any]]:
"""Ordered schema describing the editable folder_paths keys.
Drives the standalone-only Model Paths settings UI: the frontend renders
one multi-path editor per entry and resolves labels via the
``settings.modelPaths.folderKeys.<key>`` i18n keys, so adding a new model
category is a constants + locale change only. ``sub_type`` lets the UI
hide editors for other-model categories the user has not enabled.
"""
schema: List[Dict[str, Any]] = [
{"key": key, "category": "core", "sub_type": None}
for key in CORE_FOLDER_PATH_KEYS
]
schema.extend(
{"key": folder_key, "category": "other", "sub_type": sub_type}
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
)
return schema
def normalize_other_sub_types(value: Any) -> List[str]:
"""Normalize a stored/requested enabled-sub_type list.
Unknown values and duplicates are dropped; the result follows the
canonical VALID_OTHER_SUB_TYPES order so the stored setting and the UI
stay stable. Non-list input falls back to the defaults.
"""
if isinstance(value, str):
candidates: Any = [value]
elif isinstance(value, (list, tuple, set)):
candidates = value
else:
return list(DEFAULT_ENABLED_OTHER_SUB_TYPES)
allowed = {item for item in candidates if isinstance(item, str)}
return [sub_type for sub_type in VALID_OTHER_SUB_TYPES if sub_type in allowed]
# CivitAI model.type values accepted by the "other" page's fetch-metadata
# validation (lowercased). CLIP/CLIPVision are retired upstream but still
# appear on grandfathered models.
VALID_OTHER_CIVITAI_TYPES = {
"vae",
"upscaler",
"textencoder",
"clip",
"clipvision",
"controlnet",
"other",
}
# CivitAI model.type -> internal sub_type for the "other" model page.
CIVITAI_TYPE_TO_OTHER_SUB_TYPE = {
"vae": "vae",
"upscaler": "upscaler",
"textencoder": "text_encoder",
"clip": "text_encoder",
"clipvision": "clip_vision",
"controlnet": "controlnet",
}
# CivitAI ModelFile.type values -> internal sub_type for the "other" model
# page. Used for download routing only, and strictly as an explicit user file
# pick or a fallback when model.type maps to nothing — checkpoint models
# routinely bundle VAE/Text Encoder component files, so file types must never
# override a mapped model.type.
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
"VAE": "vae",
"Upscaler": "upscaler",
"Text Encoder": "text_encoder",
"Vision Encoder": "clip_vision",
"CLIPVision": "clip_vision",
"ControlNet": "controlnet",
}
# Backward compatibility alias
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
@@ -89,6 +211,7 @@ CIVITAI_USER_MODEL_TYPES = [
*VALID_LORA_TYPES,
"textualinversion",
"checkpoint",
*sorted(VALID_OTHER_CIVITAI_TYPES),
]
# Default chunk size in megabytes used for hashing large files.
@@ -99,11 +222,30 @@ DEFAULT_HASH_CHUNK_SIZE_MB = 4
# absurd 64-bit header length from forcing a multi-GB allocation during scan.
MAX_SAFETENSORS_HEADER_BYTES = 64 * 1024 * 1024
# First 12 chars of the SHA256 of an empty byte string. Some (re-packaging)
# training tools write this placeholder into safetensors metadata instead of a
# real hash; it must never be treated as a valid AutoV3 — several broken
# models sharing it would collide in the hash index and falsely match recipes.
INVALID_AUTOV3_EMPTY_HASH = "e3b0c44298fc"
# SHA256 of an empty byte string. Some (re-packaging) training tools write a
# truncated form of this placeholder into safetensors metadata (as
# ``modelspec.hash_sha256`` / ``sshs_model_hash``), and hashing an empty or
# unreadable file produces it directly. It must never be treated as a valid
# hash: several broken models share it, CivitAI's by-hash index can contain
# such polluted entries, and matching it falsely attributes recipes.
EMPTY_HASH_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
INVALID_AUTOV3_EMPTY_HASH = EMPTY_HASH_SHA256[:12]
INVALID_AUTOV2_EMPTY_HASH = EMPTY_HASH_SHA256[:10]
def is_empty_placeholder_hash(value: Any) -> bool:
"""True for a 10/12/64-hex-char spelling of the empty-hash placeholder.
These are the AutoV2, AutoV3 and full-SHA256 forms of the placeholder;
such values identify no real model and must never be resolved against
local files or CivitAI.
"""
if not isinstance(value, str):
return False
v = value.strip().lower()
if len(v) not in (10, 12, 64):
return False
return v == EMPTY_HASH_SHA256[: len(v)]
# Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = (
@@ -138,6 +280,19 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
}
# Default download path template for each model type. "other" defaults to a
# flat layout (empty template) on purpose: other-model downloads are already
# separated by sub_type roots (default_other_roots), and priority_tags has no
# "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and
# scatter files into unstable folders. Users can still opt in to a template by
# writing "other" into download_path_templates in settings.json.
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"lora": "{base_model}/{first_tag}",
"checkpoint": "{base_model}/{first_tag}",
"embedding": "{base_model}/{first_tag}",
"other": "",
}
# baseModel values from CivitAI that should be treated as diffusion models (unet)
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset(
+152
View File
@@ -0,0 +1,152 @@
"""Shared directory-browsing logic for HTTP directory pickers."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Tuple
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
_IMAGE_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
"""Browse a directory and return (payload, http_status).
The payload shape matches the JSON responses historically produced by
``BatchImportHandler.browse_directory``: on success a dict with
``success``, ``current_path``, ``parent_path``, ``directories``,
``image_files``, ``image_count`` and ``directory_count``; on failure a
``{"success": False, "error": ...}`` dict with a 400/403/404/500 status.
"""
if os.name == "nt" and directory_path == WINDOWS_DRIVES_TOKEN:
return _windows_drives_payload(), 200
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return {"success": False, "error": "Access denied to this directory"}, 403
if not path.exists():
return {"success": False, "error": "Directory does not exist"}, 404
if not path.is_dir():
return {"success": False, "error": "Path is not a directory"}, 400
directories = []
image_files = []
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in _IMAGE_EXTENSIONS:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
else:
parent_path = str(path.parent)
return (
{
"success": True,
"current_path": str(path),
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
},
200,
)
except PermissionError:
return {"success": False, "error": "Permission denied"}, 403
except OSError as exc:
return {"success": False, "error": f"Error reading directory: {str(exc)}"}, 500
def _windows_drives_payload() -> Dict[str, Any]:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [{"name": drive, "path": drive, "is_parent": False} for drive in drives]
return {
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
@@ -420,6 +420,10 @@ class DownloadManager:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
if "other" in model_types:
other_scanner = await ServiceRegistry.get_other_scanner()
scanners.append(("other", other_scanner))
# Load progress file to check processed models (async to avoid blocking)
settings_manager = get_settings_manager()
active_library = settings_manager.get_active_library_name()
@@ -600,6 +604,10 @@ class DownloadManager:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
if "other" in model_types:
other_scanner = await ServiceRegistry.get_other_scanner()
scanners.append(("other", other_scanner))
# Get all models
all_models = []
for scanner_type, scanner in scanners:
@@ -1098,6 +1106,10 @@ class DownloadManager:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
if "other" in model_types:
other_scanner = await ServiceRegistry.get_other_scanner()
scanners.append(("other", other_scanner))
# Find the specified models
models_to_process = []
for scanner_type, scanner in scanners:

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